# 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.
