diff --git a/CLAUDE.md b/CLAUDE.md index 80dcb39..f2f8798 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -357,6 +357,16 @@ queste fade, ma va confermato col paper trader live prima di rischiare capitale - **SH01 SENZA STOP-LOSS — by design, CONFERMATO da ricerca (2026-06-05).** Dopo il crash ETH (−15.6% su un trade SH01 live), ricerca multi-agente con harness dedicato `scripts/analysis/sh01_exit_lab.py` (cache segnali walk-forward, engine con **fill gap-aware** `worse(livello, open)`, parity esatta con explore_lab, protocollo train≤2023-11-01/OOS): **11 famiglie di stop testate (ATR intrabar/close-confirm, %, chandelier, breakeven, giveback, loser-timestop, disaster-cap close+intrabar, swing, vol-regime), 0 sopravvissute** al gate (ETH migliorato senza degradare BTC, train E oos, plateau). Pattern: ogni stop stretto abbastanza da toccare la coda ETH rompe BTC; ogni stop largo non arriva alla coda; nei crash il fill è al gap, non al livello (lo stop "protettivo" PEGGIORA la coda OOS). Mitigazione adottata: **cap famiglia SHAPE a 0.0588 in PORT06** (≈ dimezzata; costo OOS Sharpe −0.24, FULL DD −0.14pp) — la prossima coda impatta il conto per metà. NON impostare mai `sl`/`sl_confirm_atr` su SH_BTC/SH_ETH. Direzione futura: liquidity-gate sull'entry (skip dopo feed flat). Diario `docs/diary/2026-06-05-sh01-sl-research.md`. - **Data layer Cerbero v2:** `get_historical_v2` unificato + `get_instruments` (naming robusto) + `get_ticker_batch`. Trading su Deribit. - **SCOPE LIVE (fase 2 completata):** il runner esegue TUTTI gli sleeve di PORT06. Worker: single `StrategyWorker` (fade MR01/02/07, DIP01, **e SH01**), `PairsWorker` (PR01 2 gambe), e i multi-asset dedicati `BasketTrendWorker` (TR01 4h), `RotationWorker` (ROT02 1d), `TsmomWorker` (TSM01 1d). Il runner fetcha 1h da Cerbero v2 e **resampla a 4h/1d** (lookback dimensionato sui daily: TSM01 usa 252g). Validazione: runner pool/ribilancio/ledger == backtest (`validate_portfolio_runner.py`, identico); worker multi-asset == reference (`validate_honest_workers.py`: TSM01 esatto, ROT02 +1303% canonico, TR01 stesso ordine — differenza di convenzione capitale-unico vs media-equity). +- **SH01 bootstrap full-history (punto-10, 2026-06-07).** La ri-validazione col train-window del + regime live (`sh01_trainwindow_validate.py`) ha mostrato che SH01 a train 365g **NON è robusto** + (BTC fee-2x FULL −42%, ETH Sharpe −0.02, trade-rate 22-26% vs 10% validato: LogReg over-confident, + soglia inerte — la diagnosi sweep era esatta). L'edge è MONOTONO nella memoria: solo l'expanding + full-history passa il gate. Fix live: il runner passa agli sleeve `ml` la storia FULL + (`_with_history`: parquet locale + feed, gap-guard con WARN) e `ml_wf_entries(last_block_only=True)` + fitta SOLO l'ultimo blocco del walk-forward → segnali **identici per costruzione** al WF completo + (parity test esatto), 0.6 s/tick su 73k barre. Manutenzione: tenere fresco il parquet + (`download_all()`). MAI ri-tunare la soglia nel regime corto (instabile/incoerente fra asset). + Diario `docs/diary/2026-06-07-sh01-trainwindow.md`. - **FIX SH01 wiring (2026-06-01).** SH01 gira come **`StrategyWorker` NORMALE** (NON il vecchio `MLWorkerWrapper` di `multi_runner`, che usava il `SignalEngine` **squeeze SCARTATO**: apriva senza metadata ed usciva a `hold_bars=3`, ignorando del tutto SH01_shape_ml). `SH01_shape_ml.generate_signals` fa il walk-forward (retraining) internamente ad ogni tick ed emette `metadata.max_bars=12` → exit a orizzonte via `StrategyWorker.tick`. Serve ≥4000 barre 1h (`_ML_LOOKBACK_DAYS=365`). Vedi `docs/diary/2026-06-01-sh01-wiring-squeeze-bug.md`. - **Altri fix StrategyWorker (2026-06-01).** Exit a orizzonte puro per strategie senza TP/SL (`elif self.max_bars`, SH01 esce a H=12 non hold_bars=3); `is_win = net > 0` (win NETTO fee, non lordo); filtro `min_tp_frac` (salta micro-scalp col TP entro le fee); loss-guard `hurst_max=0.55` sulle fade (vedi sopra). - **Exit intrabar (fase 3, risolto):** lo `StrategyWorker` ora esce sui TP/SL toccati INTRABAR (high/low della barra, al livello, SL prioritario) come il backtest — non più solo sul close. Allinea fade/DIP01 live al backtest intrabar (`tests/portfolio/test_intrabar_exit.py`). Caveat residuo onesto: nel paper trading l'high/low usato è quello della barra in corso al poll; su un fill reale conterebbe il momento del tocco. diff --git a/docs/diary/2026-06-07-sh01-trainwindow.md b/docs/diary/2026-06-07-sh01-trainwindow.md new file mode 100644 index 0000000..255ac78 --- /dev/null +++ b/docs/diary/2026-06-07-sh01-trainwindow.md @@ -0,0 +1,51 @@ +# 2026-06-07 — SH01 punto-10: il regime live (train 365g) NON è robusto → bootstrap full-history + +Punto 10 della roadmap sweep: ri-validare SH01 col train-window del regime live +(~8760 barre, il `_ML_LOOKBACK_DAYS=365` del runner) invece dell'expanding +full-history usato da tutta la validazione storica. + +## Ri-validazione (`scripts/analysis/sh01_trainwindow_validate.py`) + +`ml_wf_entries(train_window=...)` rolling su storia piena ≈ il regime live. +Protocollo explore_lab (FULL / OOS 30% / sweep fee / anni positivi), W24 H12 th0.58. + +| BTC train | trade-rate | FULL / OOS | Sharpe | fee 0.2%: FULL/OOS | anni+ | robusto | +|---|---|---|---|---|---|---| +| 8760 (live) | **21.7%** | +82 / +74 | 1.31 | **−42** / +20 | 6/9 | ❌ | +| 17520 (2y) | 15.4% | +166 / +96 | 2.05 | +9 / +47 | 6/9 | ❌ | +| 26280 (3y) | 11.8% | +348 / +95 | 3.09 | +109 / +61 | 7/9 | ❌ | +| EXPANDING | 9.8% | +219 / +42 | 2.72 | +60 / +26 | 8/9 | ✅ | + +ETH a tw=8760: **FULL −33%, Sharpe −0.02** (lancio di moneta che paga fee). + +Conferme: (a) la diagnosi del sweep era esatta (trade-rate 22-26% live vs 10% +validato — soglia inerte per over-confidence su train piccolo); (b) la +progressione è MONOTONA: l'edge di SH01 *è* la memoria lunga; (c) lo sweep della +soglia nel regime corto dà risultati instabili e incoerenti fra asset → ri-tunare +th sarebbe curve-fitting sul rumore di calibrazione (come da warning del piano). + +## Decisione (utente): bootstrap full-history + +Ripristinare in live ESATTAMENTE il regime validato (expanding full-history): + +1. **`ml_wf_entries(last_block_only=True)`** — fitta/predice SOLO l'ultimo blocco + del walk-forward. I confini dei blocchi sono deterministici (start + k·retrain) + e al worker servono solo i segnali recenti → le entries dell'ultimo blocco sono + **identiche per costruzione** al tail del WF completo (parity test esatto in + `tests/portfolio/test_sh01_last_block.py`). Costo live: **0.6 s/tick** su 73k + barre (1 fit LogReg) vs ~140 fit del WF completo. +2. **Bootstrap storia nel runner**: per gli sleeve `ml` il tick riceve + `concat(parquet_locale < feed_start, feed_live)` (`runner._with_history`). + Gap-guard: se il parquet è stantio oltre il lookback del feed → solo feed + + WARN una-tantum (meglio il regime corto dichiarato che una serie col buco). + Manutenzione: tenere fresco il parquet con `download_all()` (oggi al 2026-05-28, + il feed copre 365g → margine ~11 mesi). +3. `_defs.py`: `params={"last_block_only": True}` sugli sleeve SHAPE (solo path + live; il backtest canonico resta WF completo). + +Effetto atteso live: trade-rate SH01 da ~25% a ~10% delle barre, selettività +della soglia ripristinata, WR atteso ≈ validazione (acc OOS ~56% BTC). +Il monitor naturale è il report orario (SH01 farà MENO trade). + +Nota: `_ML_LOOKBACK_DAYS` resta 365 (il feed serve comunque per le barre recenti +e come fallback se il parquet è stantio). diff --git a/scripts/analysis/sh01_trainwindow_validate.py b/scripts/analysis/sh01_trainwindow_validate.py new file mode 100644 index 0000000..2ecd4dd --- /dev/null +++ b/scripts/analysis/sh01_trainwindow_validate.py @@ -0,0 +1,75 @@ +"""Ri-validazione SH01 col train-window del REGIME LIVE (sweep punto 10). + +Diagnosi (improvement-sweep 2026-06-06): tutta la validazione SH01 +(`shape_ml_validate.py`) usa training EXPANDING full-history (~73k barre dal 2018), +ma il worker live fetcha solo `_ML_LOOKBACK_DAYS=365` (~8760 barre 1h) -> la LogReg +allenata su train piccolo e' OVER-CONFIDENT: frac(proba>=0.58) 44.8% vs 3.8%, +trade-rate live 25.1% delle barre vs 2.9% validato, WR live 1/9. La soglia th=0.58 +e' di fatto inerte nel regime live. + +Questo harness misura l'edge SH01 (W24 H12 logit th0.58, protocollo explore_lab: +FULL / OOS ultimo 30% / sweep fee / anni positivi) in funzione del train-window +ROLLING (`ml_wf_entries(train_window=...)`): + + 8760 = regime live ATTUALE (365g) <- la validazione mai fatta + 17520 = 2 anni (gia' noto: BTC regge, +166%/+96%) + 26280 = 3 anni + None = EXPANDING full-history <- la validazione canonica (riferimento) + ++ diagnostica trade-rate per variante e, per il regime live, sweep della soglia +(SOLO diagnostica: la lezione del sweep e' di NON ri-tunare th a forza — se +l'edge non sopravvive a 8760 la risposta e' cambiare il regime di training del +live, non la soglia). + +Esiti possibili: + a) edge OK a 8760 -> il live va bene cosi' (e la diagnosi WR 1/9 e' altro); + b) edge OK da un lookback X > 8760 -> portare il train live a X + (bootstrap storico dal parquet locale + append feed, o lookback maggiore); + c) edge solo expanding -> bootstrap parquet full-history o riconsiderare SH01. + + uv run python scripts/analysis/sh01_trainwindow_validate.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)) + +from scripts.analysis.explore_lab import get_df, evaluate, robust # noqa: E402 +from scripts.analysis.shape_ml_research import ml_wf_entries # noqa: E402 + +CFG = dict(W=24, H=12, model="logit", thresh=0.58) +WINDOWS = [8760, 17520, 26280, None] # None = expanding (canonico) +TH_DIAG = [0.58, 0.62, 0.66, 0.70] # sweep soglia SOLO per il regime live + + +def main(): + print("=" * 108) + print(" SH01 — edge vs train-window ROLLING (W24 H12 logit th0.58, protocollo explore_lab)") + print(" regime live = 8760 barre (365g); canonico = expanding full-history") + print("=" * 108) + + for asset in ("BTC", "ETH"): + df = get_df(asset, "1h") + nbars = len(df) + print(f"\n--- {asset} ({nbars} barre 1h) ---") + for tw in WINDOWS: + tag = f"tw={tw}" if tw else "EXPANDING" + ents = ml_wf_entries(df, train_window=tw, **CFG) + rate = len(ents) / nbars * 100 + print(f" [{tag:<10s}] trade-rate {rate:4.1f}% delle barre") + res = evaluate(f"{asset} {tag}", ents, df) + print(f" -> {'ROBUSTO' if robust(res) else 'NON robusto'}") + + print(f"\n diagnostica soglia nel REGIME LIVE (tw=8760), {asset}:") + for th in TH_DIAG: + ents = ml_wf_entries(df, train_window=8760, **{**CFG, "thresh": th}) + rate = len(ents) / nbars * 100 + print(f" [th={th:<5}] trade-rate {rate:4.1f}%") + evaluate(f"{asset} tw8760 th{th}", ents, df) + + +if __name__ == "__main__": + main() diff --git a/scripts/analysis/shape_ml_research.py b/scripts/analysis/shape_ml_research.py index 516e637..e35dce7 100644 --- a/scripts/analysis/shape_ml_research.py +++ b/scripts/analysis/shape_ml_research.py @@ -245,12 +245,18 @@ def analog_feat_entries(df, W=24, H=12, K=60, rebuild=300, min_lib=1500, def ml_wf_entries(df, W=24, H=12, model="gb", thresh=0.58, train_min=4000, retrain=500, n_estimators=80, max_depth=3, tp_atr=None, sl_atr=None, trend_max=None, ema_long=200, - train_window=None) -> list[dict]: + train_window=None, last_block_only=False) -> list[dict]: """Walk-forward: a blocchi di `retrain` barre, allena sul passato il cui esito e' noto, predice il blocco corrente. Scaler+modello fittati solo sul train. Entra a close[i] se proba della classe predetta >= thresh. model in {gb, logit}. train_window: se None -> expanding (tutto il passato); se int -> ROLLING (solo le - ultime train_window barre prima del blocco) -> test di robustezza piu' severo.""" + ultime train_window barre prima del blocco) -> test di robustezza piu' severo. + last_block_only: PATH LIVE (2026-06-07) — fitta e predice SOLO l'ultimo blocco + (un fit invece di ~n/retrain): al worker servono solo i segnali sulla barra + corrente e i confini dei blocchi sono deterministici (start + k*retrain), quindi + le entries dell'ultimo blocco sono IDENTICHE per costruzione al tail del + walk-forward completo (parity test in tests/portfolio/test_sh01_last_block.py). + Rende sostenibile il train EXPANDING full-history nel runner (~73k barre/tick).""" c = df["close"].values n = len(c) a = atr(df, 14) @@ -269,6 +275,9 @@ def ml_wf_entries(df, W=24, H=12, model="gb", thresh=0.58, blk = start while blk < n - 1: blk_end = min(blk + retrain, n - 1) + if last_block_only and blk_end < n - 1: + blk = blk_end # salta i blocchi storici (stessi confini del WF completo) + continue # TRAIN: finestre la cui forma E il cui esito (e+H) sono < blk # cioe' e <= blk-1-H (esito realizzato prima del primo test del blocco) lo_end = (blk - 1 - H - train_window) if train_window is not None else (W - 1) diff --git a/scripts/portfolios/_defs.py b/scripts/portfolios/_defs.py index 544e6f6..820157c 100644 --- a/scripts/portfolios/_defs.py +++ b/scripts/portfolios/_defs.py @@ -67,7 +67,12 @@ PAIRS = [ TSM = [SleeveSpec(kind="tsmom", name="TSM01", sid="TSM01", cluster="trend", params={"universe": UNIVERSE8, "tf": "1d", "horizons": [63, 126, 252], "thr": 1.0, "gross": 0.30})] -SHAPE = [SleeveSpec(kind="ml", name="SH01", sid=f"SH_{a}", asset=a, cluster="shape") +# SH01 live (punto-10, 2026-06-07): il runner passa la storia FULL (parquet+feed) e +# last_block_only fitta/predice solo l'ultimo blocco del walk-forward (segnali == +# WF completo per costruzione). Il regime corto train-365g NON e' robusto (BTC +# negativo a fee 2x, trade-rate 22% vs 10% validato): sh01_trainwindow_validate.py. +SHAPE = [SleeveSpec(kind="ml", name="SH01", sid=f"SH_{a}", asset=a, cluster="shape", + params={"last_block_only": True}) for a in ("BTC", "ETH")] PORTFOLIOS = { diff --git a/scripts/strategies/SH01_shape_ml.py b/scripts/strategies/SH01_shape_ml.py index 3ad38e1..4dc00d1 100644 --- a/scripts/strategies/SH01_shape_ml.py +++ b/scripts/strategies/SH01_shape_ml.py @@ -75,7 +75,14 @@ class ShapeMLStrategy(Strategy): def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex, **params) -> list[Signal]: cfg = {**CONFIG, **{k: params[k] for k in ("W", "H", "model", "thresh") if k in params}} - ents = ml_wf_entries(df, W=cfg["W"], H=cfg["H"], model=cfg["model"], thresh=cfg["thresh"]) + # last_block_only (live, 2026-06-07): il runner passa la storia FULL (parquet + # bootstrap + feed) e qui si fitta/predice solo l'ultimo blocco del walk-forward + # (segnali identici al WF completo per costruzione, 1 fit invece di ~140). + # La ri-validazione punto-10 ha mostrato che il regime corto (train 365g) NON + # e' robusto (BTC negativo a fee 2x, trade-rate 22% vs 10%): l'edge richiede + # il training expanding full-history. Vedi sh01_trainwindow_validate.py. + ents = ml_wf_entries(df, W=cfg["W"], H=cfg["H"], model=cfg["model"], thresh=cfg["thresh"], + last_block_only=bool(params.get("last_block_only"))) c = df["close"].values out: list[Signal] = [] for e in ents: diff --git a/src/portfolio/runner.py b/src/portfolio/runner.py index eacb069..135d4f4 100644 --- a/src/portfolio/runner.py +++ b/src/portfolio/runner.py @@ -116,6 +116,32 @@ def rebalance_allocations(ledger: PortfolioLedger, workers: dict, weights: dict[ ledger.save() +_OHLCV = ["timestamp", "open", "high", "low", "close", "volume"] + + +def _with_history(hist: pd.DataFrame | None, live: pd.DataFrame, + warned: set | None = None, asset: str = "") -> pd.DataFrame: + """Bootstrap storia per SH01 (punto-10, 2026-06-07): parquet locale PRIMA del + feed live (dedup sul timestamp). La ri-validazione ha mostrato che l'edge SH01 + richiede il training EXPANDING full-history: col solo lookback live (365g) la + LogReg e' over-confident e la strategia NON e' robusta. Se c'e' un gap fra + parquet e feed (parquet stantio oltre il lookback) si usa il SOLO feed con + WARN: meglio il regime corto dichiarato che una serie con un buco.""" + if hist is None or live is None or live.empty: + return live + lo = int(live["timestamp"].iloc[0]) + h = hist[hist["timestamp"] < lo] + if h.empty: + return live + if int(h["timestamp"].iloc[-1]) < lo - 2 * 3_600_000: # buco > 1 barra 1h + if warned is not None and asset not in warned: + warned.add(asset) + print(f"[runner] WARN: gap fra parquet e feed live per {asset} " + f"(parquet stantio? rilanciare download_all) — SH01 senza bootstrap") + return live + return pd.concat([h[_OHLCV], live[_OHLCV]], ignore_index=True) + + def _resample(df: pd.DataFrame, tf: str) -> pd.DataFrame: """Resampla candele 1h -> 4h/1d mantenendo timestamp ms reale (come get_df del backtest).""" if tf == "1h": @@ -247,6 +273,21 @@ def run(config_path: str = "portfolios.yml"): if ps_family: print(f"[runner] position_size globale={position_size} override famiglia={ps_family}") + # bootstrap storia full per gli sleeve ML (SH01): parquet locale + feed live. + # L'edge SH01 richiede train expanding full-history (sh01_trainwindow_validate); + # il path live fitta solo l'ultimo blocco (last_block_only nei params SHAPE). + ml_hist: dict[str, pd.DataFrame] = {} + ml_gap_warned: set[str] = set() + for a in sorted({s.asset for s in live_specs if s.kind == "ml"}): + try: + from src.data.downloader import load_data + ml_hist[a] = load_data(a, "1h") + last = pd.to_datetime(ml_hist[a]["timestamp"].iloc[-1], unit="ms", utc=True) + print(f"[runner] bootstrap storia SH01 {a}: {len(ml_hist[a])} barre parquet " + f"(fino a {last:%Y-%m-%d %H:%M})") + except Exception as e: + print(f"[runner] WARN bootstrap storia {a} fallito: {e} — SH01 col solo feed") + # lookback (giorni) richiesto per ogni asset = max sui worker che lo usano asset_days: dict[str, int] = {} for s in live_specs: @@ -322,9 +363,13 @@ def run(config_path: str = "portfolios.yml"): w.tick(res[s.a], res[s.b]) elif s.kind in _MULTI_KINDS: w.tick(res) + elif s.kind == "ml": + # SH01: storia full (parquet bootstrap + feed) -> il walk-forward + # interno fitta solo l'ultimo blocco (last_block_only nei params). + w.tick(_with_history(ml_hist.get(s.asset), res[s.asset], + ml_gap_warned, s.asset)) else: - # single (fade/dip) e ml (SH01): StrategyWorker. SH01 retraina dentro - # generate_signals (walk-forward) -> nessun training esterno. + # single (fade/dip): StrategyWorker su feed live. w.tick(res[s.asset]) ledger.update_equity({sid: _worker_equity(wk) for sid, wk in workers.items()}) diff --git a/tests/portfolio/test_sh01_last_block.py b/tests/portfolio/test_sh01_last_block.py new file mode 100644 index 0000000..b5fa093 --- /dev/null +++ b/tests/portfolio/test_sh01_last_block.py @@ -0,0 +1,60 @@ +"""SH01 path live: last_block_only == tail del walk-forward completo (parity by +construction) + merge storia parquet/feed del runner.""" +import numpy as np +import pandas as pd +import pytest + +from src.portfolio.runner import _with_history + + +def _df(n=6500, seed=0, t0=1_600_000_000_000): + rng = np.random.default_rng(seed) + c = 100 * np.exp(np.cumsum(rng.normal(0, 0.01, n))) + h = c * (1 + np.abs(rng.normal(0, 0.004, n))) + l = c * (1 - np.abs(rng.normal(0, 0.004, n))) + o = np.roll(c, 1); o[0] = c[0] + ts = t0 + 3_600_000 * np.arange(n) + return pd.DataFrame({"timestamp": ts, "open": o, "high": h, "low": l, + "close": c, "volume": 1.0}) + + +def test_last_block_only_matches_full_wf_tail(): + from scripts.analysis.shape_ml_research import ml_wf_entries + df = _df() + cfg = dict(W=24, H=12, model="logit", thresh=0.55, train_min=4000, retrain=500) + full = ml_wf_entries(df, **cfg) + last = ml_wf_entries(df, last_block_only=True, **cfg) + n = len(df) + # confine dell'ultimo blocco: start + k*retrain (deterministico) + start = max(cfg["train_min"], cfg["W"] - 1) + B = start + while B + cfg["retrain"] < n - 1: + B += cfg["retrain"] + full_tail = [e for e in full if e["i"] >= B] + assert last == full_tail # identita' esatta, non solo simile + assert all(e["i"] >= B for e in last) + + +def test_with_history_merges_contiguous(): + hist = _df(n=100) + live = _df(n=50, t0=int(hist["timestamp"].iloc[60])) # overlap: live parte dentro hist + out = _with_history(hist, live) + assert len(out) == 60 + 50 # hist pre-overlap + live completo + ts = out["timestamp"].values + assert (np.diff(ts) > 0).all() # serie strettamente crescente + + +def test_with_history_gap_falls_back_to_live(): + hist = _df(n=100) + gap_start = int(hist["timestamp"].iloc[-1]) + 10 * 3_600_000 # buco di 10 barre + live = _df(n=50, t0=gap_start) + warned = set() + out = _with_history(hist, live, warned, "BTC") + assert len(out) == 50 and "BTC" in warned # solo feed + warn deduplicato + out2 = _with_history(hist, live, warned, "BTC") + assert len(out2) == 50 # secondo giro: nessun nuovo warn + + +def test_with_history_none_hist_passthrough(): + live = _df(n=50) + assert _with_history(None, live) is live