From 83c4e7a33448601ffa6d0d14a6acd0ddaf03570f Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Mon, 1 Jun 2026 12:24:13 +0000 Subject: [PATCH] fix(live): SH01 esegue shape-ML reale, non il wrapper squeeze scartato Bug di wiring: runner.py avvolgeva lo sleeve SH01 nel MLWorkerWrapper legacy di multi_runner, che usa SignalEngine (famiglia squeeze ML01 SCARTATA), apre con Signal nudo ed esce a hold_bars=3 con tick() propria. Risultato: lo sleeve "SH01" del portafoglio live NON eseguiva SH01_shape_ml (generate_signals mai chiamata) e il fix horizon-exit era in un ramo morto -> SH01 continuava a chiudere a hold_limit/3. Fix: SH01 (kind="ml") gira come StrategyWorker normale. SH01_shape_ml. generate_signals fa il walk-forward internamente ad ogni tick ed emette metadata.max_bars=H=12 -> exit via StrategyWorker.tick (orizzonte H, fix applicato). Rimosso l'import/uso di MLWorkerWrapper e il blocco train esterno. ml_wf_entries ha train_min=4000 (>=4000 barre 1h per produrre segnali): aggiunto _ML_LOOKBACK_DAYS=365 cosi gli asset di sleeve ml fetchano >=365g (~8760 barre), senza dipendere dal fetch 440g di TSM01/ROT02. generate_signals su 365g: 0,17-0,24s (logit) -> trascurabile sul poll 60s. Test: test_build_ml_sh01_is_plain_strategyworker (StrategyWorker + strategy SH01_shape_ml + niente engine squeeze). Suite: 51 passed. Stato live: SH01 BTC/ETH flat -> contatori resettati (capitale preservato), trade squeeze archiviati. Rebuild+recreate: 14 worker RESUME puliti, healthy. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-01-sh01-wiring-squeeze-bug.md | 71 +++++++++++++++++++ src/portfolio/runner.py | 34 +++++---- tests/portfolio/test_runner_build.py | 12 ++++ 3 files changed, 103 insertions(+), 14 deletions(-) create mode 100644 docs/diary/2026-06-01-sh01-wiring-squeeze-bug.md diff --git a/docs/diary/2026-06-01-sh01-wiring-squeeze-bug.md b/docs/diary/2026-06-01-sh01-wiring-squeeze-bug.md new file mode 100644 index 0000000..45065dc --- /dev/null +++ b/docs/diary/2026-06-01-sh01-wiring-squeeze-bug.md @@ -0,0 +1,71 @@ +# 2026-06-01 — SH01 live eseguiva la strategia SBAGLIATA (squeeze scartato), non shape-ML + +> Scoperto verificando perché SH01 continuava a chiudere a `hold_limit/3` **anche dopo** +> il rebuild col fix horizon-exit. Il fix era corretto ma in un **ramo morto**: SH01 live +> non passava da `StrategyWorker.tick()`. + +## Sintomo + +Dopo il deploy del fix SH01 (exit a H=12), un close SH01_BTC delle 12:00 era ancora +`reason=hold_limit bars=3` (perdita −1,27%). Il fix non aveva effetto sul path reale. + +## Causa (bug di wiring, più grave del previsto) + +`src/portfolio/runner.py` importava `MLWorkerWrapper` da **`src/live/multi_runner.py`** e +ci avvolgeva lo sleeve SH01: + +```python +if spec.kind == "ml": + return MLWorkerWrapper(worker, {"retrain_hours": 24}) +``` + +Ma quel wrapper è **legacy, per la famiglia squeeze ML01** (scartata, vedi CLAUDE.md): +- usa `SignalEngine` = squeeze-detection + GradientBoosting (NON SH01_shape_ml); +- ha una `tick()` propria che apre con un `Signal` **nudo** (niente tp/sl/max_bars) ed + esce con `if bars_held >= hold_bars: close("hold_limit")` → ignora del tutto la + strategia caricata e il fix horizon. + +Quindi lo sleeve "SH01" del portafoglio live **non eseguiva shape-ML**: eseguiva il +motore squeeze scartato. I log `TRAIN OK / oos_signal_precision` venivano da lì. Il +`worker` con strategy=SH01_shape_ml era costruito ma la sua `generate_signals` non +veniva **mai** chiamata. + +## Fix + +SH01 (`kind="ml"`) ora gira come **StrategyWorker normale**: `SH01_shape_ml.generate_signals` +fa il walk-forward (retraining) **internamente** ad ogni tick (`ml_wf_entries`) ed emette +`metadata.max_bars=H=12` → gli exit passano per `StrategyWorker.tick()` e il fix horizon +si applica davvero. + +```python +# runner.py: niente più MLWorkerWrapper per kind="ml" +return StrategyWorker(strategy=strategy, asset=spec.asset, tf=spec.tf, ...) +``` + +**Lookback dati.** `ml_wf_entries` ha `train_min=4000` → servono ≥4000 barre 1h prima di +produrre segnali (con 90g/2160 barre → 0 segnali, runtime 0.01s — il falso "muto"). Le +candele 1h di BTC/ETH già arrivano a 440g (le richiede TSM01/ROT02 a 1d), ma per non +dipendere da quella coincidenza ho aggiunto `_ML_LOOKBACK_DAYS=365`: gli asset usati da +sleeve ml fetchano ≥365g (~8760 barre). Costo `generate_signals` su 365g: **0,17–0,24s** +(modello logit) → trascurabile sul poll 60s. + +**Verifica.** Build SH01 → `StrategyWorker` con `strategy.name=="SH01_shape_ml"`, niente +attributo `engine` (regression test `test_build_ml_sh01_is_plain_strategyworker`). Smoke +su 365g: 766–1786 segnali, tutti `max_bars=12`; tick live 0,17s. `ml_wf_entries` non +predice mai l'ultima barra (`n-1`) ma fino a `n-2` = esattamente la condizione di apertura +del worker (`idx >= last_idx-1`) → apre quando il segnale è fresco. Suite: 51 passed. + +**Stato live.** SH01 BTC/ETH erano flat: contatori resettati a 0 (capitale preservato +58,76/58,78), vecchi trade squeeze archiviati in `trades_squeeze_archive.jsonl`. Rebuild ++ recreate: 14 worker RESUME puliti, container healthy, nessun log TRAIN/squeeze, zero +errori. + +## Lezione + +1. **Verificare il path REALE, non solo il codice del fix.** Il fix horizon era giusto ma + SH01 non lo attraversava. Un fix non testato end-to-end sul percorso vivo è un fix + presunto. (Mi ero fidato del rebuild senza confermare il reason dei close SH01.) +2. Riusare un wrapper legacy "perché c'è" è un rischio: `MLWorkerWrapper` di multi_runner + era per la famiglia squeeze scartata, non per shape-ML. +3. Un modello ML "muto" può essere solo **fame di dati** (train_min), non un bug logico: + controllare sempre la dimensione della finestra prima di concludere. diff --git a/src/portfolio/runner.py b/src/portfolio/runner.py index d5ad9e8..d59dbba 100644 --- a/src/portfolio/runner.py +++ b/src/portfolio/runner.py @@ -2,9 +2,9 @@ Riusa i worker esistenti come esecutori e il data layer Cerbero v2. Worker per tipo di sleeve: - single (fade/dip) -> StrategyWorker | ml (shape) -> MLWorkerWrapper(StrategyWorker) - pairs -> PairsWorker (2 gambe) | basket (TR01) -> BasketTrendWorker - rotation (ROT02) -> RotationWorker | tsmom (TSM01) -> TsmomWorker + single (fade/dip) -> StrategyWorker | ml (shape, SH01) -> StrategyWorker (WF interno) + pairs -> PairsWorker (2 gambe) | basket (TR01) -> BasketTrendWorker + rotation (ROT02) -> RotationWorker | tsmom (TSM01) -> TsmomWorker Feed: il runner fetcha candele 1h da Cerbero v2 e le RESAMPLA a 4h/1d (come get_df nel backtest) per i worker a cadenza piu' lenta. Il lookback per asset e' dimensionato sul @@ -22,7 +22,6 @@ from src.live.pairs_worker import PairsWorker from src.live.basket_trend_worker import BasketTrendWorker from src.live.rotation_worker import RotationWorker from src.live.tsmom_worker import TsmomWorker -from src.live.multi_runner import MLWorkerWrapper from src.live.strategy_loader import load_strategy # Codice-breve sleeve -> nome modulo Strategy in scripts/strategies/ (worker single/ml) @@ -36,6 +35,9 @@ DATA_DIR = Path("data/portfolio_paper") # giorni di storia da fetchare per timeframe (TSM01 1d usa 252 barre -> ~440 giorni col buffer) _LOOKBACK_DAYS = {"1h": 90, "4h": 220, "1d": 440} +# SH01 (ml) richiede >=4000 barre 1h (train_min di ml_wf_entries); 365g (~8760 barre) danno +# margine ampio per il walk-forward. Difensivo: non dipende dal fetch 440g di TSM01/ROT02. +_ML_LOOKBACK_DAYS = 365 def build_worker_for(spec: SleeveSpec, alloc_capital: float, leverage: float, @@ -70,13 +72,16 @@ def build_worker_for(spec: SleeveSpec, alloc_capital: float, leverage: float, if module is None: raise ValueError(f"sleeve live non supportato: {spec.name} (kind={spec.kind})") strategy = load_strategy(module) - worker = StrategyWorker( + # SH01 (kind="ml") gira come StrategyWorker NORMALE: SH01_shape_ml.generate_signals fa il + # walk-forward (retraining) internamente ad ogni tick ed emette metadata.max_bars=H -> gli + # exit passano per StrategyWorker.tick (orizzonte H). NON usare il vecchio MLWorkerWrapper di + # multi_runner: quello usa SignalEngine (famiglia squeeze SCARTATA), apre senza metadata ed + # esce a hold_bars=3, ignorando del tutto SH01_shape_ml. Serve >=4000 barre 1h (train_min): + # garantite da _ML_LOOKBACK_DAYS. + return StrategyWorker( strategy=strategy, asset=spec.asset, tf=spec.tf, capital=alloc_capital, position_size=position_size, leverage=leverage, params=spec.params, data_dir=data_dir, ) - if spec.kind == "ml": # SH01: retraining periodico - return MLWorkerWrapper(worker, {"retrain_hours": 24}) - return worker def _worker_equity(w) -> float: @@ -161,8 +166,11 @@ def run(config_path: str = "portfolios.yml"): asset_days: dict[str, int] = {} for s in live_specs: assets, tf = _spec_assets_tf(s) + days = _LOOKBACK_DAYS.get(tf, 90) + if s.kind == "ml": # SH01 ha bisogno di molta storia 1h + days = max(days, _ML_LOOKBACK_DAYS) for a in assets: - asset_days[a] = max(asset_days.get(a, 0), _LOOKBACK_DAYS.get(tf, 90)) + asset_days[a] = max(asset_days.get(a, 0), days) inst_map = dict(INSTRUMENT_MAP) last_day = "" @@ -193,11 +201,9 @@ def run(config_path: str = "portfolios.yml"): elif s.kind in _MULTI_KINDS: w.tick(res) else: - df = res[s.asset] - inner = getattr(w, "worker", w) - if hasattr(w, "needs_training") and w.needs_training(): - w.train(df, hold=inner.hold_bars) - w.tick(df) + # single (fade/dip) e ml (SH01): StrategyWorker. SH01 retraina dentro + # generate_signals (walk-forward) -> nessun training esterno. + w.tick(res[s.asset]) ledger.update_equity({sid: _worker_equity(wk) for sid, wk in workers.items()}) diff --git a/tests/portfolio/test_runner_build.py b/tests/portfolio/test_runner_build.py index 3cfc7ab..71c7288 100644 --- a/tests/portfolio/test_runner_build.py +++ b/tests/portfolio/test_runner_build.py @@ -18,3 +18,15 @@ def test_build_pairs_worker(tmp_path): w = build_worker_for(spec, alloc_capital=200.0, leverage=2.0, data_dir=tmp_path) assert isinstance(w, PairsWorker) assert w.capital == 200.0 + + +def test_build_ml_sh01_is_plain_strategyworker(tmp_path): + """SH01 (kind=ml) deve costruirsi come StrategyWorker che esegue SH01_shape_ml — NON il + vecchio MLWorkerWrapper (SignalEngine squeeze scartato, che ignorava la strategia ed usciva + a hold_bars=3). Regressione del bug di wiring scoperto il 2026-06-01.""" + spec = SleeveSpec(kind="ml", name="SH01", sid="SH_BTC", asset="BTC") + w = build_worker_for(spec, alloc_capital=58.82, leverage=2.0, data_dir=tmp_path) + assert isinstance(w, StrategyWorker) + assert w.strategy.name == "SH01_shape_ml" # esegue davvero shape-ML + assert not hasattr(w, "engine") # niente SignalEngine squeeze + assert w.tf == "1h"