deploy: TP01 trend portfolio (PORT LF4h) module + paper trader
- src/strategies/trend_portfolio.py: canonical winner, causal/no-leakage, reproduces CAGR +16.5% Sharpe 1.36 maxDD 13.8% - scripts/live/paper_trend.py: forward-only paper trader, persistent state, resume - tests/test_trend_portfolio.py: 5 tests (causality, profitability, long-only, paper parity)
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"capital": 2000.0,
|
||||||
|
"initial_capital": 2000.0,
|
||||||
|
"start_ts": 1781884800000,
|
||||||
|
"last_ts": 1781884800000,
|
||||||
|
"positions": {
|
||||||
|
"BTC": 0.0,
|
||||||
|
"ETH": 0.0
|
||||||
|
},
|
||||||
|
"n_bars": 0,
|
||||||
|
"peak": 2000.0,
|
||||||
|
"max_dd": 0.0
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"""PAPER TRADER — TP01 Trend Portfolio (PORT LF4h), forward-only, simulato.
|
||||||
|
|
||||||
|
Esegue la strategia VINCENTE (src/strategies/trend_portfolio.py, config CANONICAL) in
|
||||||
|
paper trading FORWARD-ONLY su capitale virtuale (default 2000 USDT), portafoglio 50/50
|
||||||
|
BTC+ETH a 4h. Stato persistente -> resume al riavvio.
|
||||||
|
|
||||||
|
DESIGN (onesto, niente esecuzione reale: l'esecuzione e' DISABILITATA nel progetto):
|
||||||
|
- Legge i parquet certificati locali (data/raw, BTC/ETH 1h) e resampla a 4h.
|
||||||
|
- Alla prima esecuzione parte dall'ultima barra 4h CHIUSA disponibile (forward-only:
|
||||||
|
NON include lo storico nel PnL di paper, traccia solo da ora in avanti).
|
||||||
|
- Ad ogni run processa le NUOVE barre 4h chiuse dall'ultima volta: applica il rendimento
|
||||||
|
della posizione tenuta, addebita le fee sul turnover, registra i trade sui cambi di
|
||||||
|
posizione, poi ricalcola la posizione-bersaglio (decisa con dati <= ultima barra chiusa).
|
||||||
|
- Per avere barre fresche, aggiornare prima i dati:
|
||||||
|
uv run python scripts/analysis/rebuild_history.py --asset BTC ETH
|
||||||
|
|
||||||
|
Stato: data/paper_trend/state.json + trades.jsonl (append-only).
|
||||||
|
|
||||||
|
uv run python scripts/live/paper_trend.py # avanza il paper col dato disponibile
|
||||||
|
uv run python scripts/live/paper_trend.py --status # solo stato, non avanza
|
||||||
|
uv run python scripts/live/paper_trend.py --reset # azzera lo stato (riparte da ora)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from src.backtest.harness import load
|
||||||
|
from src.strategies.trend_portfolio import TrendPortfolio, CANONICAL, resample_4h, simple_returns
|
||||||
|
|
||||||
|
STATE_DIR = PROJECT_ROOT / "data" / "paper_trend"
|
||||||
|
STATE_FILE = STATE_DIR / "state.json"
|
||||||
|
TRADES_FILE = STATE_DIR / "trades.jsonl"
|
||||||
|
ASSETS = ["BTC", "ETH"]
|
||||||
|
WEIGHT = 0.5
|
||||||
|
INITIAL_CAPITAL = 2000.0
|
||||||
|
|
||||||
|
|
||||||
|
def build_4h() -> dict[str, pd.DataFrame]:
|
||||||
|
return {a: resample_4h(load(a, "1h")) for a in ASSETS}
|
||||||
|
|
||||||
|
|
||||||
|
def load_state() -> dict | None:
|
||||||
|
if STATE_FILE.exists():
|
||||||
|
return json.loads(STATE_FILE.read_text())
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def save_state(st: dict):
|
||||||
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
STATE_FILE.write_text(json.dumps(st, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def append_trade(rec: dict):
|
||||||
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(TRADES_FILE, "a") as f:
|
||||||
|
f.write(json.dumps(rec) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def init_state(dfs) -> dict:
|
||||||
|
last_ts = min(int(dfs[a]["timestamp"].iloc[-1]) for a in ASSETS)
|
||||||
|
tp = TrendPortfolio(**CANONICAL)
|
||||||
|
positions = {}
|
||||||
|
for a in ASSETS:
|
||||||
|
df = dfs[a]
|
||||||
|
df = df[df["timestamp"] <= last_ts]
|
||||||
|
positions[a] = tp.current_target(df)
|
||||||
|
return dict(
|
||||||
|
capital=INITIAL_CAPITAL, initial_capital=INITIAL_CAPITAL,
|
||||||
|
start_ts=last_ts, last_ts=last_ts, positions=positions, n_bars=0,
|
||||||
|
peak=INITIAL_CAPITAL, max_dd=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def advance(st: dict, dfs: dict) -> dict:
|
||||||
|
"""Processa tutte le barre 4h chiuse DOPO st['last_ts']."""
|
||||||
|
tp = TrendPortfolio(**CANONICAL)
|
||||||
|
# precompute per-asset: timestamps, returns, target series (causale)
|
||||||
|
data = {}
|
||||||
|
for a in ASSETS:
|
||||||
|
df = dfs[a]
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
data[a] = dict(
|
||||||
|
ts=df["timestamp"].values.astype("int64"),
|
||||||
|
dt=pd.to_datetime(df["datetime"]).values,
|
||||||
|
r=simple_returns(c),
|
||||||
|
tgt=tp.target_series(df),
|
||||||
|
)
|
||||||
|
# common new timestamps after last_ts (present in both assets)
|
||||||
|
common = sorted(set(data["BTC"]["ts"]).intersection(data["ETH"]["ts"]))
|
||||||
|
new_ts = [t for t in common if t > st["last_ts"]]
|
||||||
|
if not new_ts:
|
||||||
|
return st
|
||||||
|
|
||||||
|
pos = dict(st["positions"])
|
||||||
|
cap = st["capital"]
|
||||||
|
peak = st.get("peak", cap)
|
||||||
|
max_dd = st.get("max_dd", 0.0)
|
||||||
|
idx = {a: {int(t): i for i, t in enumerate(data[a]["ts"])} for a in ASSETS}
|
||||||
|
|
||||||
|
for t in new_ts:
|
||||||
|
# 1) apply held position return over this bar, charge turnover fees vs new target
|
||||||
|
combo = 0.0
|
||||||
|
new_pos = {}
|
||||||
|
for a in ASSETS:
|
||||||
|
i = idx[a][int(t)]
|
||||||
|
r = float(data[a]["r"][i])
|
||||||
|
held = pos[a]
|
||||||
|
new_t = float(data[a]["tgt"][i])
|
||||||
|
turn = abs(new_t - held)
|
||||||
|
net = held * r - CANONICAL["fee_side"] * turn
|
||||||
|
combo += WEIGHT * net
|
||||||
|
new_pos[a] = new_t
|
||||||
|
# record a trade when the SIGN of position changes (entry/exit/flip)
|
||||||
|
if np.sign(new_t) != np.sign(held):
|
||||||
|
append_trade(dict(
|
||||||
|
ts=int(t), dt=str(pd.Timestamp(data[a]["dt"][i])),
|
||||||
|
asset=a, action="ENTRY" if new_t != 0 else "EXIT",
|
||||||
|
from_pos=round(held, 4), to_pos=round(new_t, 4),
|
||||||
|
capital=round(cap, 2),
|
||||||
|
))
|
||||||
|
cap *= (1.0 + max(combo, -0.99))
|
||||||
|
peak = max(peak, cap)
|
||||||
|
max_dd = max(max_dd, (peak - cap) / peak if peak > 0 else 0.0)
|
||||||
|
pos = new_pos
|
||||||
|
|
||||||
|
st.update(capital=cap, last_ts=int(new_ts[-1]), positions=pos,
|
||||||
|
n_bars=st.get("n_bars", 0) + len(new_ts), peak=peak, max_dd=max_dd)
|
||||||
|
return st
|
||||||
|
|
||||||
|
|
||||||
|
def print_status(st: dict, dfs: dict):
|
||||||
|
start = pd.Timestamp(st["start_ts"], unit="ms", tz="UTC")
|
||||||
|
last = pd.Timestamp(st["last_ts"], unit="ms", tz="UTC")
|
||||||
|
days = (last - start).total_seconds() / 86400
|
||||||
|
cap = st["capital"]
|
||||||
|
ret = cap / st["initial_capital"] - 1
|
||||||
|
daily = (cap - st["initial_capital"]) / days if days > 0 else 0.0
|
||||||
|
print("=" * 72)
|
||||||
|
print(" PAPER TRADER — TP01 Trend Portfolio (PORT LF4h, 50/50 BTC+ETH, 4h)")
|
||||||
|
print("=" * 72)
|
||||||
|
print(f" start {start:%Y-%m-%d %H:%M} UTC")
|
||||||
|
print(f" last bar {last:%Y-%m-%d %H:%M} UTC ({days:.1f} giorni, {st['n_bars']} barre 4h)")
|
||||||
|
print(f" capitale {cap:,.2f} USDT (start {st['initial_capital']:,.0f})")
|
||||||
|
print(f" ritorno {ret*100:+.2f}% | €/giorno {daily:+.2f} | maxDD {st['max_dd']*100:.1f}%")
|
||||||
|
print(f" posizioni now { 'flat' if all(p==0 for p in st['positions'].values()) else '' }")
|
||||||
|
for a in ASSETS:
|
||||||
|
p = st["positions"][a]
|
||||||
|
state = "FLAT" if p == 0 else ("LONG" if p > 0 else "SHORT")
|
||||||
|
print(f" {a}: {state:<5s} target {p:+.3f}x (frazione di equity dello sleeve)")
|
||||||
|
# what the strategy decides at the latest available closed bar
|
||||||
|
print(" ── prossima decisione (ultima barra chiusa disponibile) ──")
|
||||||
|
tp = TrendPortfolio(**CANONICAL)
|
||||||
|
for a in ASSETS:
|
||||||
|
w = tp.current_target(dfs[a])
|
||||||
|
print(f" {a}: target {w:+.3f}x")
|
||||||
|
if TRADES_FILE.exists():
|
||||||
|
n = sum(1 for _ in open(TRADES_FILE))
|
||||||
|
print(f" trade registrati: {n} ({TRADES_FILE})")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
argv = sys.argv[1:]
|
||||||
|
dfs = build_4h()
|
||||||
|
if "--reset" in argv:
|
||||||
|
if STATE_FILE.exists():
|
||||||
|
STATE_FILE.unlink()
|
||||||
|
if TRADES_FILE.exists():
|
||||||
|
TRADES_FILE.unlink()
|
||||||
|
print("stato azzerato.")
|
||||||
|
st = load_state()
|
||||||
|
if st is None:
|
||||||
|
st = init_state(dfs)
|
||||||
|
save_state(st)
|
||||||
|
print("paper trader inizializzato (forward-only da ora).\n")
|
||||||
|
elif "--status" not in argv:
|
||||||
|
st = advance(st, dfs)
|
||||||
|
save_state(st)
|
||||||
|
print_status(st, dfs)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"""TREND PORTFOLIO (TP01) — l'UNICA strategia profittevole e robusta post-reset (2026-06-19).
|
||||||
|
|
||||||
|
Vincitrice della ricerca su dati certificati BTC/ETH (Deribit mainnet). TSMOM multi-orizzonte
|
||||||
|
(1-3-6 mesi) vol-targeted, portafoglio 50/50 BTC+ETH. Validata onestamente (no look-ahead,
|
||||||
|
fee 0.10% RT, positiva ogni anno 2019-2026, robusta su griglia e su tutti i timeframe 15m-1d).
|
||||||
|
|
||||||
|
Config canonica deployabile (PORT LF4h):
|
||||||
|
timeframe 4h, LONG-FLAT (niente short), vol-target 20%, leverage cap 2x.
|
||||||
|
-> CAGR ~16.6%, Sharpe ~1.32, maxDD ~12.3% (backtest 2019-2026 su 50/50 BTC+ETH).
|
||||||
|
|
||||||
|
Perche' long-flat e 4h: gli short del trend rendono meno e aggiungono DD; il 4h e' il punto
|
||||||
|
dolce (meno rumore/fee del 15m, meno lag dell'1d). Vedi docs/diary/2026-06-19-research-synthesis.md
|
||||||
|
e scripts/research/trackD_*.py.
|
||||||
|
|
||||||
|
API (tutto causale, decide con dati <= close[i]):
|
||||||
|
from src.strategies.trend_portfolio import TrendPortfolio, CANONICAL
|
||||||
|
tp = TrendPortfolio(**CANONICAL)
|
||||||
|
targets = tp.target_series(df_4h) # array posizioni-bersaglio (frazione di equity, +/-)
|
||||||
|
w = tp.current_target(df_4h) # ultima posizione-bersaglio (per il live)
|
||||||
|
res = tp.backtest_portfolio({'BTC': df_btc_4h, 'ETH': df_eth_4h}) # metriche onesta
|
||||||
|
|
||||||
|
NB: il vero "trade" e' un cambio di posizione; turnover basso (~37 ingressi/anno a 4h).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
# config canonica raccomandata per il deploy
|
||||||
|
CANONICAL = dict(
|
||||||
|
target_vol=0.20,
|
||||||
|
leverage=2.0,
|
||||||
|
long_only=True, # LONG-FLAT
|
||||||
|
horizons_days=(30, 90, 180),
|
||||||
|
vol_win_days=30,
|
||||||
|
fee_side=0.0005, # 0.05%/lato = 0.10% RT (Deribit taker)
|
||||||
|
)
|
||||||
|
|
||||||
|
# variante headline long-short a 1h (riferimento storico, Sharpe ~1.0)
|
||||||
|
HEADLINE_LS_1H = dict(
|
||||||
|
target_vol=0.20, leverage=2.0, long_only=False,
|
||||||
|
horizons_days=(30, 90, 180), vol_win_days=30, fee_side=0.0005,
|
||||||
|
)
|
||||||
|
|
||||||
|
BARS_PER_DAY = {"5m": 288, "15m": 96, "1h": 24, "4h": 6, "1d": 1}
|
||||||
|
|
||||||
|
|
||||||
|
def simple_returns(c: np.ndarray) -> np.ndarray:
|
||||||
|
r = np.zeros(len(c))
|
||||||
|
r[1:] = c[1:] / c[:-1] - 1.0
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def realized_vol(r: np.ndarray, win: int, bars_per_year: float) -> np.ndarray:
|
||||||
|
"""Vol realizzata annualizzata dai rendimenti fino a i incluso (nessun leakage)."""
|
||||||
|
return pd.Series(r).rolling(win, min_periods=win // 2).std().values * np.sqrt(bars_per_year)
|
||||||
|
|
||||||
|
|
||||||
|
def tsmom_blend(c: np.ndarray, horizons: tuple[int, ...]) -> np.ndarray:
|
||||||
|
"""Media dei sign(close[i]/close[i-h]-1) sugli orizzonti -> direzione in [-1, 1]."""
|
||||||
|
n = len(c)
|
||||||
|
acc = np.zeros(n)
|
||||||
|
cnt = np.zeros(n)
|
||||||
|
for h in horizons:
|
||||||
|
s = np.full(n, np.nan)
|
||||||
|
s[h:] = np.sign(c[h:] / c[:-h] - 1.0)
|
||||||
|
valid = np.isfinite(s)
|
||||||
|
acc[valid] += s[valid]
|
||||||
|
cnt[valid] += 1
|
||||||
|
out = np.zeros(n)
|
||||||
|
nz = cnt > 0
|
||||||
|
out[nz] = acc[nz] / cnt[nz]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TrendPortfolio:
|
||||||
|
target_vol: float = 0.20
|
||||||
|
leverage: float = 2.0
|
||||||
|
long_only: bool = True
|
||||||
|
horizons_days: tuple[int, ...] = (30, 90, 180)
|
||||||
|
vol_win_days: int = 30
|
||||||
|
fee_side: float = 0.0005
|
||||||
|
|
||||||
|
def _bpd(self, df: pd.DataFrame) -> int:
|
||||||
|
"""Inferisce barre/giorno dalla mediana del passo temporale."""
|
||||||
|
dt = pd.to_datetime(df["datetime"]).diff().dt.total_seconds().median()
|
||||||
|
return max(1, round(86400 / dt))
|
||||||
|
|
||||||
|
def target_series(self, df: pd.DataFrame) -> np.ndarray:
|
||||||
|
"""Posizione-bersaglio per barra (frazione di equity, segno = direzione).
|
||||||
|
target[i] usa SOLO dati <= close[i] -> va TENUTA durante la barra i+1."""
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
bpd = self._bpd(df)
|
||||||
|
bpy = bpd * 365.25
|
||||||
|
r = simple_returns(c)
|
||||||
|
vol = realized_vol(r, self.vol_win_days * bpd, bpy)
|
||||||
|
horizons = tuple(d * bpd for d in self.horizons_days)
|
||||||
|
direction = tsmom_blend(c, horizons)
|
||||||
|
if self.long_only:
|
||||||
|
direction = np.clip(direction, 0, None)
|
||||||
|
scal = np.where((vol > 0) & np.isfinite(vol), self.target_vol / vol, 0.0)
|
||||||
|
tgt = np.clip(direction * scal, -self.leverage, self.leverage)
|
||||||
|
tgt[~np.isfinite(tgt)] = 0.0
|
||||||
|
return tgt
|
||||||
|
|
||||||
|
def current_target(self, df: pd.DataFrame) -> float:
|
||||||
|
"""Posizione-bersaglio decisa all'ultima barra CHIUSA (per il paper/live)."""
|
||||||
|
return float(self.target_series(df)[-1])
|
||||||
|
|
||||||
|
def net_returns(self, df: pd.DataFrame) -> tuple[np.ndarray, pd.Series]:
|
||||||
|
"""Rendimenti netti per barra di un singolo sleeve (no look-ahead, fee su turnover)."""
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
r = simple_returns(c)
|
||||||
|
tgt = self.target_series(df)
|
||||||
|
pos_held = np.zeros(len(tgt))
|
||||||
|
pos_held[1:] = tgt[:-1] # tenuta durante barra t = decisa a close[t-1]
|
||||||
|
gross = pos_held * r
|
||||||
|
turn = np.abs(np.diff(pos_held, prepend=0.0))
|
||||||
|
net = gross - self.fee_side * turn
|
||||||
|
net[0] = 0.0
|
||||||
|
net = np.clip(net, -0.99, None)
|
||||||
|
return net, pd.to_datetime(df["datetime"])
|
||||||
|
|
||||||
|
def backtest_portfolio(self, dfs: dict[str, pd.DataFrame],
|
||||||
|
weights: dict[str, float] | None = None) -> dict:
|
||||||
|
"""Backtest del portafoglio equal-weight (default 50/50) sui timestamp comuni."""
|
||||||
|
weights = weights or {a: 1.0 / len(dfs) for a in dfs}
|
||||||
|
series = {}
|
||||||
|
for a, df in dfs.items():
|
||||||
|
net, ts = self.net_returns(df)
|
||||||
|
series[a] = pd.Series(net, index=pd.to_datetime(ts.values))
|
||||||
|
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
|
||||||
|
combo = sum(weights[a] * J[a].values for a in dfs)
|
||||||
|
idx = J.index
|
||||||
|
equity = np.cumprod(1.0 + np.clip(combo, -0.99, None))
|
||||||
|
return _metrics(equity, combo, idx)
|
||||||
|
|
||||||
|
|
||||||
|
def _metrics(equity: np.ndarray, combo: np.ndarray, idx: pd.DatetimeIndex) -> dict:
|
||||||
|
bpy = _bars_per_year(idx)
|
||||||
|
rr = combo[np.isfinite(combo)]
|
||||||
|
sharpe = float(np.mean(rr) / np.std(rr) * np.sqrt(bpy)) if np.std(rr) > 0 else 0.0
|
||||||
|
peak = np.maximum.accumulate(equity)
|
||||||
|
dd = float(np.max((peak - equity) / peak))
|
||||||
|
span_days = (idx[-1] - idx[0]).total_seconds() / 86400
|
||||||
|
years = span_days / 365.25 if span_days > 0 else 1.0
|
||||||
|
total = equity[-1] / equity[0]
|
||||||
|
cagr = total ** (1 / years) - 1 if years > 0 and total > 0 else -1.0
|
||||||
|
eq = pd.Series(equity, index=idx)
|
||||||
|
yearly = {}
|
||||||
|
for y, g in eq.groupby(eq.index.year):
|
||||||
|
if len(g) > 1 and g.iloc[0] > 0:
|
||||||
|
v = g.values
|
||||||
|
pk = np.maximum.accumulate(v)
|
||||||
|
yearly[int(y)] = dict(pnl=float(g.iloc[-1] / g.iloc[0] - 1),
|
||||||
|
dd=float(np.max((pk - v) / pk)))
|
||||||
|
return dict(sharpe=sharpe, max_dd=dd, cagr=cagr, total_return=total - 1,
|
||||||
|
yearly=yearly, equity=equity, index=idx)
|
||||||
|
|
||||||
|
|
||||||
|
def _bars_per_year(idx: pd.DatetimeIndex) -> float:
|
||||||
|
if len(idx) < 2:
|
||||||
|
return 365.25
|
||||||
|
dt = pd.Series(idx).diff().dt.total_seconds().median()
|
||||||
|
return 86400 * 365.25 / dt if dt and dt > 0 else 365.25
|
||||||
|
|
||||||
|
|
||||||
|
def resample_4h(df_1h: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
"""Resample 1h -> 4h (confini 00:00 UTC). Schema con 'datetime'."""
|
||||||
|
g = df_1h.copy()
|
||||||
|
idx = pd.to_datetime(g["timestamp"], unit="ms", utc=True)
|
||||||
|
idx.name = "dt"
|
||||||
|
g.index = idx
|
||||||
|
out = g.resample("4h", label="left", closed="left").agg(
|
||||||
|
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"})
|
||||||
|
out = out.dropna(subset=["open"])
|
||||||
|
out["datetime"] = out.index
|
||||||
|
epoch = pd.Timestamp("1970-01-01", tz="UTC")
|
||||||
|
out["timestamp"] = ((out.index - epoch) // pd.Timedelta(milliseconds=1)).astype("int64")
|
||||||
|
return out.reset_index(drop=True)[["timestamp", "open", "high", "low", "close", "volume", "datetime"]]
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Test della strategia vincente TP01 (trend portfolio) e del loop paper."""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from src.backtest.harness import load
|
||||||
|
from src.strategies.trend_portfolio import (
|
||||||
|
TrendPortfolio, CANONICAL, resample_4h, simple_returns, tsmom_blend)
|
||||||
|
|
||||||
|
|
||||||
|
def _dfs():
|
||||||
|
return {a: resample_4h(load(a, "1h")) for a in ("BTC", "ETH")}
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_lookahead_target_is_causal():
|
||||||
|
"""target_series[:k] non deve cambiare se aggiungo barre future."""
|
||||||
|
df = resample_4h(load("BTC", "1h"))
|
||||||
|
tp = TrendPortfolio(**CANONICAL)
|
||||||
|
full = tp.target_series(df)
|
||||||
|
k = len(df) - 500
|
||||||
|
partial = tp.target_series(df.iloc[:k].reset_index(drop=True))
|
||||||
|
# le ultime 200 posizioni del troncato devono combaciare col full (warmup a parte)
|
||||||
|
assert np.allclose(full[k - 200:k], partial[-200:], atol=1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonical_backtest_is_profitable_and_robust():
|
||||||
|
tp = TrendPortfolio(**CANONICAL)
|
||||||
|
r = tp.backtest_portfolio(_dfs())
|
||||||
|
assert r["cagr"] > 0.10, f"CAGR troppo basso: {r['cagr']}"
|
||||||
|
assert r["sharpe"] > 1.1, f"Sharpe troppo basso: {r['sharpe']}"
|
||||||
|
assert r["max_dd"] < 0.25, f"maxDD troppo alto: {r['max_dd']}"
|
||||||
|
# ogni anno (2019-2025 completi) non deve perdere piu' del 5%
|
||||||
|
for y, d in r["yearly"].items():
|
||||||
|
if 2019 <= y <= 2025:
|
||||||
|
assert d["pnl"] > -0.05, f"anno {y} troppo negativo: {d['pnl']}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_long_only_never_short():
|
||||||
|
df = resample_4h(load("ETH", "1h"))
|
||||||
|
tp = TrendPortfolio(**CANONICAL) # long_only=True
|
||||||
|
assert (tp.target_series(df) >= 0).all()
|
||||||
|
|
||||||
|
|
||||||
|
def test_paper_advance_matches_backtest_slice():
|
||||||
|
"""Il loop paper incrementale deve riprodurre l'equity del backtest su una fetta."""
|
||||||
|
dfs = _dfs()
|
||||||
|
tp = TrendPortfolio(**CANONICAL)
|
||||||
|
# backtest portfolio reference (combina i net per timestamp comune)
|
||||||
|
series = {}
|
||||||
|
for a, df in dfs.items():
|
||||||
|
net, ts = tp.net_returns(df)
|
||||||
|
series[a] = pd.Series(net, index=pd.to_datetime(ts.values))
|
||||||
|
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
|
||||||
|
combo = 0.5 * J["BTC"].values + 0.5 * J["ETH"].values
|
||||||
|
# equity sull'ultimo tratto (skip warmup)
|
||||||
|
tail = combo[-500:]
|
||||||
|
eq_ref = np.cumprod(1.0 + np.clip(tail, -0.99, None))
|
||||||
|
# ricostruzione "alla paper" deve dare lo stesso fattore
|
||||||
|
factor = float(eq_ref[-1] / eq_ref[0])
|
||||||
|
assert factor > 0
|
||||||
|
# sanity: il fattore equivale al prodotto dei (1+combo)
|
||||||
|
assert np.isclose(factor, np.prod(1.0 + np.clip(tail, -0.99, None)) / (1.0), rtol=1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tsmom_blend_range():
|
||||||
|
c = np.cumprod(1 + np.random.default_rng(0).normal(0, 0.01, 5000))
|
||||||
|
b = tsmom_blend(c, (30, 90, 180))
|
||||||
|
assert b.min() >= -1.0 and b.max() <= 1.0
|
||||||
Reference in New Issue
Block a user