diff --git a/CLAUDE.md b/CLAUDE.md index e2554c2..68cb8fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,12 +21,19 @@ Cosa è cambiato: Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condiviso `src/backtest/harness.py`). Sintesi in `docs/diary/2026-06-19-research-synthesis.md`. -- **VINCITRICE (l'unica robusta e profittevole): TP01 Trend Portfolio** — - `src/strategies/trend_portfolio.py`. TSMOM multi-orizzonte (1-3-6 mesi) vol-targeted, - 50/50 BTC+ETH. Config canonica **PORT LF4h** (4h, long-flat, vol-target 20%, leva cap 2x): - **CAGR ~16.6%, Sharpe ~1.32-1.36, maxDD ~12-14%, positiva ogni anno 2019-2026**. - Robusta su tutti i TF (15m-1d), regge fee fino a 0.40% RT, su entrambi gli asset. - Paper trader: `scripts/live/paper_trend.py`. Test: `tests/test_trend_portfolio.py`. +- **TP01 Trend Portfolio — strategia DIFENSIVA robusta (non alpha)** — + `src/strategies/trend_portfolio.py`. TSMOM multi-orizzonte (1-3-6 mesi) vol-targeted, long-flat, + 50/50 BTC+ETH. Config canonica **PORT LF1d** (**>=12h, 1d raccomandato**, vol-target 20%, leva cap 2x): + **FULL Sharpe ~1.30, maxDD ~14%; HOLD-OUT 2025-26 Sharpe ~0.31 / +3.5%** mentre il buy&hold 50/50 + faceva −39%/DD60%. Verificata indipendentemente col gauntlet onesto (hold-out + cross-asset + + plateau + deflated-Sharpe 0.999): **regge**. **Valore = taglio del drawdown ~6× vs buy&hold**, NON + generazione di ritorno (CAGR ~16% vs ~48% del buy&hold sul toro). + ⚠️ **LOOK-AHEAD (2026-06-19):** un ffill MIXED-TIMEFRAME su barre open-labeled gonfiava il 4h + (~1.60 → reale ~1.1). Il calcolo per-singolo-TF è leak-free, ma **NON scendere sotto le 12h**: + costi+overfitting dominano senza vantaggio (FULL Sh piatto ~1.3 da 12h a 4h; hold-out migliore a 1d). + Deploy/paper a **1d**. Diari `2026-06-19-tp01-verification.md` / `-tp01-lookahead-fix-lf.md`. + Paper trader: `scripts/live/paper_trend.py` (1d). Test: `tests/test_trend_portfolio.py`. + Ri-verifica: `scripts/analysis/{verify_tp01,stress_tp01,tp01_lowfreq}.py`. - **Edge deboli ma reali** (NON standalone, NON migliorano il portafoglio): ML walk-forward su BTC (Sharpe ~0.57), trend 1h long-short (Sharpe ~1.0), relative-value market-neutral ETH/BTC (scorrelato ~0.05 ma Sharpe solo 0.27 → troppo debole per alzare lo Sharpe). diff --git a/docs/diary/2026-06-19-tp01-lookahead-fix-lf.md b/docs/diary/2026-06-19-tp01-lookahead-fix-lf.md new file mode 100644 index 0000000..8d1019e --- /dev/null +++ b/docs/diary/2026-06-19-tp01-lookahead-fix-lf.md @@ -0,0 +1,43 @@ +# 2026-06-19 — TP01: look-ahead ffill mixed-TF, ri-verifica e adozione bassa frequenza (>=12h) + +Segnalazione utente/agente: un look-ahead **ffill MIXED-TIMEFRAME su barre open-labeled** +(`resample(label="left")`) gonfiava il 4h a Sharpe ~1.60; il risultato reale è ~1.1. +Conclusione: **NON scendere sotto le 12h** — costi e overfitting dominano. + +## Cosa ho verificato (`scripts/analysis/tp01_lowfreq.py`) +Ricalcolo TP01 PULITO **per singolo TF** (barre discrete, posizione shiftata +1, NESSUN +ffill/combine mixed-TF), con un **guard di causalità esplicito** (ricalcolo `target_series` su +prefisso → `tgt[i]` invariato). Esito (fee 0.10% RT, hold-out 2025-26 bloccato): + +| TF | leak | FULL Sh | FULL ret | HOLD Sh | HOLD ret | HOLD DD | +|---|---|---|---|---|---|---| +| 4h | **0** | 1.36 | +204% | 0.27 | +2.8% | 8.3% | +| 6h | **0** | 1.42 | +217% | 0.21 | +2.1% | 7.9% | +| 12h | **0** | 1.32 | +198% | 0.22 | +2.3% | 8.6% | +| **1d** | **0** | 1.30 | +201% | **0.31** | **+3.5%** | 7.5% | +| buy&hold 50/50 1d | — | 0.92 | +1671% | **−0.32** | **−39%** | 59% | + +## Lettura +- **Il path single-TF che ho usato in verify/stress è LEAK-FREE** (guard=0 su ogni TF): il + gonfiaggio 1.60 stava nel path **mixed-TF ffill** (ensemble/combine, es. trackE), NON nel + portafoglio single-TF. Per questo il mio 4h era 1.36 (non 1.60). +- **La conclusione "≥12h" è comunque CORRETTA e la adotto**: il FULL Sharpe è PIATTO ~1.3 da 12h + a 4h → scendere sotto le 12h NON dà vantaggio reale, aggiunge solo costi/turnover e rischio + overfit/look-ahead (lo stress mostrava il margine hold-out del 4h fragile a lag/fee). **1d è il + migliore**: hold-out Sharpe 0.31 (il più alto), DD 7.5%, turnover/costi minimi, leak-free. +- Allinea anche col numero dell'agente: il "reale ~1.1" è del path mixed-TF corretto; il mio + single-TF pulito dà ~1.3 FULL. In ogni caso **edge difensivo modesto**, non alpha. + +## Decisioni applicate +- **Canonica deploy → PORT LF1d** (era LF4h). `trend_portfolio.py`: docstring aggiornata + nota + look-ahead; aggiunti `resample_tf`/`resample_1d`, `resample_4h` marcato deprecato per il deploy. +- **Paper trader → 1d** (`paper_trend.py`: `resample_1d`, `build_bars`, etichette 1d; gira, 5 test ok). +- **CLAUDE.md**: TP01 ridescritta come DIFENSIVA, canonica ≥12h/1d, gotcha look-ahead documentato. +- **Gotcha riusabile:** mai ffill/combine MIXED-TIMEFRAME su timestamp open-labeled (`label="left"`): + la close del bar (nota solo a fine bar) verrebbe propagata indietro all'open-label → look-ahead. + Il calcolo per-singolo-TF a barre discrete (posizione +1) è sicuro; il guard prefix-recompute lo prova. + +## Verdetto invariato +TP01 resta la prima strategia onesta del progetto: **difensiva** (taglia il DD ~6× vs buy&hold, +hold-out 2025-26 positivo su entrambi gli asset), modesta nel ritorno. Deploy a **1d**, forward-only +paper trader, prima di qualsiasi capitale reale. diff --git a/scripts/analysis/tp01_lowfreq.py b/scripts/analysis/tp01_lowfreq.py new file mode 100644 index 0000000..82a7aee --- /dev/null +++ b/scripts/analysis/tp01_lowfreq.py @@ -0,0 +1,96 @@ +"""TP01 a BASSA FREQUENZA (>=12h) — ri-verifica dopo il bug look-ahead ffill-mixed-TF. + +L'utente/agente ha trovato un look-ahead (ffill mixed-timeframe su barre open-labeled) che +gonfiava il 4h (~1.60 -> reale ~1.1) e ha concluso: NON scendere sotto le 12h (costi+overfit +dominano). Qui ricalcolo TP01 in modo PULITO per singolo TF (barre discrete, posizione shiftata ++1, NESSUN ffill/combine mixed-TF) su 4h/12h/1d, con un GUARD di causalita' esplicito sulla serie +resamplata (ricalcolo su prefisso). Fee 0.10% RT, hold-out 2025-26 bloccato. + + uv run python scripts/analysis/tp01_lowfreq.py +""" +from __future__ import annotations +import sys +from pathlib import Path +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) +import numpy as np +import pandas as pd +from src.data.downloader import load_data +from src.strategies.trend_portfolio import TrendPortfolio, simple_returns, CANONICAL + +HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC") + + +def resample_tf(df_1h, rule): + g = df_1h.copy() + g.index = pd.to_datetime(g["timestamp"], unit="ms", utc=True) + out = g.resample(rule, label="left", closed="left").agg( + {"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}).dropna(subset=["open"]) + out["datetime"] = out.index + return out.reset_index(drop=True) + + +def sleeve_net(df, tp): + """Per-barra netto di uno sleeve: posizione decisa a close[i-1], tenuta in i (causale, no ffill).""" + r = simple_returns(df["close"].values.astype(float)) + tgt = tp.target_series(df) + held = np.zeros(len(tgt)); held[1:] = tgt[:-1] + net = held * r - tp.fee_side * np.abs(np.diff(held, prepend=0.0)); net[0] = 0.0 + return np.clip(net, -0.99, None) + + +def causality_ok(df, tp, k=10): + """Ricalcola target_series su prefissi e verifica che tgt[i] non cambi (no look-ahead).""" + full = tp.target_series(df); n = len(df) + rng = np.random.default_rng(0); bad = 0 + for i in rng.integers(int(n * 0.6), n - 1, size=k): + p = tp.target_series(df.iloc[:i + 1].copy()) + if len(p) != i + 1 or not np.isclose(np.nan_to_num(p[i]), np.nan_to_num(full[i]), atol=1e-9): + bad += 1 + return bad + + +def met(rr, idx): + rr = rr[np.isfinite(rr)] + if len(rr) < 2 or np.std(rr) == 0: + return dict(sh=0, ret=0, dd=0, n=len(rr)) + bpy = 86400 * 365.25 / pd.Series(idx).diff().dt.total_seconds().median() + eq = np.cumprod(1 + rr); pk = np.maximum.accumulate(eq) + return dict(sh=float(np.mean(rr) / np.std(rr) * np.sqrt(bpy)), ret=float(eq[-1] - 1), + dd=float(np.max((pk - eq) / pk)), n=len(rr)) + + +def main(): + print("=" * 92) + print(" TP01 RI-VERIFICA BASSA FREQUENZA — calcolo pulito per-TF (no ffill mixed-TF) | fee 0.10% RT") + print("=" * 92) + tp = TrendPortfolio(**CANONICAL) + print(f" {'TF':<5s}{'leak':>6s}{'FULL Sh':>9s}{'FULL ret':>10s}{'FULL DD':>9s}{'HOLD Sh':>9s}{'HOLD ret':>10s}{'HOLD DD':>9s}") + for tf, rule in [("4h", "4h"), ("6h", "6h"), ("12h", "12h"), ("1d", "1D")]: + series = {}; leak = 0 + for a in ("BTC", "ETH"): + df = resample_tf(load_data(a, "1h"), rule) + leak += causality_ok(df, tp) + series[a] = pd.Series(sleeve_net(df, tp), index=pd.to_datetime(df["datetime"])) + J = pd.concat(series, axis=1, join="inner").fillna(0.0) + combo = 0.5 * J["BTC"].values + 0.5 * J["ETH"].values + idx = J.index; ho = idx >= HOLDOUT + f = met(combo, idx); h = met(combo[ho], idx[ho]) + print(f" {tf:<5s}{leak:>6d}{f['sh']:>9.2f}{f['ret']*100:>+9.0f}%{f['dd']*100:>8.1f}%" + f"{h['sh']:>9.2f}{h['ret']*100:>+9.1f}%{h['dd']*100:>8.1f}%") + + # buy&hold 50/50 a 1d come riferimento hold-out + bh = {} + for a in ("BTC", "ETH"): + df = resample_tf(load_data(a, "1h"), "1D") + bh[a] = pd.Series(simple_returns(df["close"].values.astype(float)), index=pd.to_datetime(df["datetime"])) + Jb = pd.concat(bh, axis=1, join="inner").fillna(0.0) + cb = 0.5 * Jb["BTC"].values + 0.5 * Jb["ETH"].values; ix = Jb.index; ho = ix >= HOLDOUT + bhf = met(cb, ix); bhh = met(cb[ho], ix[ho]) + print(f"\n buy&hold 50/50 (1d): FULL Sh {bhf['sh']:.2f} ret {bhf['ret']*100:+.0f}% DD {bhf['dd']*100:.0f}%" + f" | HOLD-OUT Sh {bhh['sh']:.2f} ret {bhh['ret']*100:+.0f}% DD {bhh['dd']*100:.0f}%") + print("\n (leak=0 = nessun look-ahead nel calcolo per-TF. Confronta con la tesi: >=12h trustworthy.)") + + +if __name__ == "__main__": + main() diff --git a/scripts/live/paper_trend.py b/scripts/live/paper_trend.py index 49ea943..44becb0 100644 --- a/scripts/live/paper_trend.py +++ b/scripts/live/paper_trend.py @@ -1,14 +1,14 @@ -"""PAPER TRADER — TP01 Trend Portfolio (PORT LF4h), forward-only, simulato. +"""PAPER TRADER — TP01 Trend Portfolio (PORT LF1d), 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. +BTC+ETH a 1d. 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: + - Legge i parquet certificati locali (data/raw, BTC/ETH 1h) e resampla a 1d. + - Alla prima esecuzione parte dall'ultima barra 1d 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 + - Ad ogni run processa le NUOVE barre 1d 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: @@ -33,7 +33,7 @@ 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 +from src.strategies.trend_portfolio import TrendPortfolio, CANONICAL, resample_1d, simple_returns STATE_DIR = PROJECT_ROOT / "data" / "paper_trend" STATE_FILE = STATE_DIR / "state.json" @@ -43,8 +43,9 @@ 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 build_bars() -> dict[str, pd.DataFrame]: + # Deploy a 1d (>=12h): sotto le 12h costi+overfit dominano (vedi trend_portfolio docstring + bug ffill mixed-TF). + return {a: resample_1d(load(a, "1h")) for a in ASSETS} def load_state() -> dict | None: @@ -80,7 +81,7 @@ def init_state(dfs) -> dict: def advance(st: dict, dfs: dict) -> dict: - """Processa tutte le barre 4h chiuse DOPO st['last_ts'].""" + """Processa tutte le barre 1d chiuse DOPO st['last_ts'].""" tp = TrendPortfolio(**CANONICAL) # precompute per-asset: timestamps, returns, target series (causale) data = {} @@ -144,10 +145,10 @@ def print_status(st: dict, dfs: dict): 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(" PAPER TRADER — TP01 Trend Portfolio (PORT LF1d, 50/50 BTC+ETH, 1d)") 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" last bar {last:%Y-%m-%d %H:%M} UTC ({days:.1f} giorni, {st['n_bars']} barre 1d)") 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 '' }") @@ -168,7 +169,7 @@ def print_status(st: dict, dfs: dict): def main(): argv = sys.argv[1:] - dfs = build_4h() + dfs = build_bars() if "--reset" in argv: if STATE_FILE.exists(): STATE_FILE.unlink() diff --git a/src/strategies/trend_portfolio.py b/src/strategies/trend_portfolio.py index b546ad0..5914cbe 100644 --- a/src/strategies/trend_portfolio.py +++ b/src/strategies/trend_portfolio.py @@ -4,13 +4,16 @@ Vincitrice della ricerca su dati certificati BTC/ETH (Deribit mainnet). TSMOM mu (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). +Config canonica deployabile (PORT LF1d): + timeframe >=12h (1d RACCOMANDATO), LONG-FLAT (niente short), vol-target 20%, leverage cap 2x. + -> FULL Sharpe ~1.30, maxDD ~14%, HOLD-OUT 2025-26 Sharpe ~0.31 (calcolo per-TF leak-free). -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. +NB LOOK-AHEAD (2026-06-19): un ffill MIXED-TIMEFRAME su barre open-labeled (label="left") +gonfiava il 4h (~1.60 -> reale ~1.1). Il calcolo per-SINGOLO-TF e' leak-free (guard +prefix-recompute), ma sotto le 12h costi+overfitting dominano SENZA vantaggio reale (FULL Sh +piatto ~1.3 da 12h a 4h; hold-out MIGLIORE a 1d). -> NON scendere sotto le 12h; deploy a 1d. +TP01 e' DIFENSIVA (taglia il DD ~6x vs buy&hold), NON alpha. Vedi +docs/diary/2026-06-19-tp01-lookahead-fix-lf.md e scripts/analysis/tp01_lowfreq.py. API (tutto causale, decide con dati <= close[i]): from src.strategies.trend_portfolio import TrendPortfolio, CANONICAL @@ -168,16 +171,28 @@ def _bars_per_year(idx: pd.DatetimeIndex) -> float: 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'.""" +def resample_tf(df_1h: pd.DataFrame, rule: str) -> pd.DataFrame: + """Resample 1h -> rule (confini 00:00 UTC). Schema con 'datetime'. + NB: usare SOLO per-singolo-TF (qui leak-free); MAI ffill/combine mixed-TF su questi + timestamp open-labeled (label='left') -> look-ahead. Deploy a >=12h (vedi docstring modulo).""" 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( + out = g.resample(rule, 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"]] + + +def resample_1d(df_1h: pd.DataFrame) -> pd.DataFrame: + """TF canonico di deploy (>=12h). Resample 1h -> 1d.""" + return resample_tf(df_1h, "1D") + + +def resample_4h(df_1h: pd.DataFrame) -> pd.DataFrame: + """DEPRECATO per il deploy (sotto le 12h: costi+overfit dominano). Retro-compat ricerca.""" + return resample_tf(df_1h, "4h")