Merge fix/sh01-horizon-exit: exit a orizzonte SH01 (H=12, non hold_bars=3)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-01 09:06:49 +00:00
3 changed files with 145 additions and 0 deletions
@@ -0,0 +1,81 @@
# 2026-06-01 — Bugfix: SH01 usciva a 3 barre invece di H=12 (exit a orizzonte)
> Diagnosi partita da un check sulla debolezza apparente di **SH01_BTC** nel paper
> trading live PORT06 (accuratezza 33,3% su 3 trade). Non era sfortuna statistica:
> era un bug di exit nello `StrategyWorker`.
## Sintomo
Nel live PORT06 (Docker `pythagoras-portfolio`), SH01_BTC mostrava 3 trade tutti
`long`, **tutti chiusi con `reason: "hold_limit"` a `bars_held: 3`**, con `tp: null
sl: null`:
| # | entry | exit | bars | net % | esito |
|---|-------|------|------|------:|:---:|
| 1 | 73529.5 | 73433.0 | 3 | 0,46% | ❌ |
| 2 | 73759.5 | 73839.5 | 3 | +0,02% | ✅ |
| 3 | 73811.5 | 73766.0 | 3 | 0,32% | ❌ |
`oos_signal_precision` nei log di TRAIN scendeva 55,6% → 50,0% → 43,3%.
## Causa
SH01 (`scripts/strategies/SH01_shape_ml.py`, config **W24 H12 th0.58**) è una
strategia **horizon-only**: predice il segno del rendimento a **H=12 barre** ed esce a
H barre. I suoi Signal portano `metadata={"max_bars": H}` (=12) e **nessun TP/SL**.
Nello `StrategyWorker.tick()` la logica di uscita era:
```python
if self.tp and self.sl: # SH01: False (tp=sl=0)
... usa self.max_bars ... # -> max_bars=12 consultato SOLO qui
elif self.bars_held >= self.hold_bars: # fallback legacy hold_bars=3
self._close_position(..., "hold_limit") # SH01 finiva QUI
```
`self.max_bars` (=12, settato correttamente in `_open_position`) era onorato **solo
dentro il ramo `tp and sl`**. Senza TP/SL, SH01 cadeva sul fallback `hold_bars=3` e
chiudeva a 3 barre. L'edge di SH01 — per CLAUDE.md è nell'**asimmetria sull'orizzonte
H, non nella frequenza** (win-rate ~50%) — non aveva tempo di realizzarsi: tagliato a
3/12, degenera in rumore.
Solo SH01 (BTC+ETH) era colpito: tutte le fade (MR01/MR02/MR07, DIP01) portano
tp+sl+max_bars e usano il ramo intrabar corretto.
## Fix
`src/live/strategy_worker.py`: aggiunto un ramo per l'exit a orizzonte puro, prima del
fallback `hold_bars`:
```python
elif self.max_bars:
# Exit puro a orizzonte (strategie senza TP/SL, es. SH01 shape-ML H=12):
# onora max_bars dalla metadata del Signal, non il fallback hold_bars=3.
if self.bars_held >= self.max_bars:
self._close_position(current_price, "time_limit")
```
Le fade restano invariate (entrano nel ramo `tp and sl`).
## Verifica
- Nuovo test `tests/portfolio/test_horizon_exit.py` (2 casi): con `max_bars=12` resta
in posizione a 3 barre; esce a 12 con `reason: "time_limit"` e `bars_held: 12`.
- Suite completa: **43 passed**.
- Container riavviato: **tutti i 17 sleeve RESUME puliti**, inclusa una posizione
SH01_ETH short aperta che ora seguirà l'exit a 12 barre.
## Atteso d'ora in poi
I trade SH01 nei log mostreranno `reason: "time_limit"` con `bars_held: 12` invece di
`hold_limit / 3`. Il 33% di accuratezza era un artefatto dell'exit prematuro; ora la
strategia gira sull'orizzonte su cui è validata (BTC OOS Sharpe 2,72, expanding).
Resta comunque un **diversificatore** del MASTER, non un motore di ritorno standalone.
## Lezione
Il backtest di SH01 (`fade_base`/engine onesto) esce a H barre via `max_bars`; il
worker live deve replicarlo. Quando una strategia non porta TP/SL ma solo un
orizzonte, il fallback `hold_bars` del worker la **falsa silenziosamente**. Verificare
sempre che la convenzione di exit del worker live coincida con quella del backtest
validato — non solo l'ingresso.
+5
View File
@@ -237,6 +237,11 @@ class StrategyWorker:
self._close_position(self.tp, "take_profit")
elif self.max_bars and self.bars_held >= self.max_bars:
self._close_position(current_price, "time_limit")
elif self.max_bars:
# Exit puro a orizzonte (strategie senza TP/SL, es. SH01 shape-ML H=12):
# onora max_bars dalla metadata del Signal, non il fallback hold_bars=3.
if self.bars_held >= self.max_bars:
self._close_position(current_price, "time_limit")
elif self.bars_held >= self.hold_bars:
self._close_position(current_price, "hold_limit")
else:
+59
View File
@@ -0,0 +1,59 @@
"""Fix exit a orizzonte puro: una strategia che porta solo `max_bars` nella metadata del
Signal (niente TP/SL, es. SH01 shape-ML con H=12) deve uscire a `max_bars` barre — NON sul
fallback legacy `hold_bars=3`. Prima del fix lo StrategyWorker chiudeva tali sleeve a 3 barre
(reason "hold_limit"), tagliando l'orizzonte su cui è validato l'edge di forma."""
import pandas as pd
from src.live.strategy_worker import StrategyWorker
from src.live.strategy_loader import load_strategy
def _df(n, price=100.0, last_ts=None):
c = [price] * n
ts = (pd.date_range("2024-01-01", periods=n, freq="1h", tz="UTC").astype("int64") // 10**6)
df = pd.DataFrame({"timestamp": ts, "open": c, "high": c, "low": c, "close": c, "volume": 1.0})
if last_ts is not None:
df.loc[df.index[-1], "timestamp"] = last_ts
return df
def _horizon_worker(tmp, max_bars=12):
"""Worker con posizione aperta, solo max_bars (tp/sl=0) — come SH01."""
w = StrategyWorker(strategy=load_strategy("MR01_bollinger_fade"), asset="BTC", tf="1h",
capital=1000.0, data_dir=tmp)
w._notify = lambda *a, **k: None
w.in_position = True
w.direction = 1
w.entry_price = 100.0
w.tp = 0.0
w.sl = 0.0
w.max_bars = max_bars
w.bars_held = 0
w.last_bar_ts = 0
return w
def _tick_bars(w, k, base_n=120):
"""Avanza k barre nuove (ogni tick incrementa bars_held di 1 col nuovo timestamp)."""
for b in range(1, k + 1):
w.tick(_df(base_n, last_ts=b))
def test_holds_until_horizon_not_hold_bars(tmp_path):
"""max_bars=12: a 3 barre (vecchio hold_bars) NON deve chiudere; deve restare in posizione."""
w = _horizon_worker(tmp_path, max_bars=12)
_tick_bars(w, 3)
assert w.in_position
assert w.bars_held == 3
def test_exits_at_max_bars_with_time_limit(tmp_path):
"""A max_bars barre chiude con reason 'time_limit' (non 'hold_limit')."""
w = _horizon_worker(tmp_path, max_bars=12)
_tick_bars(w, 12)
assert not w.in_position
# leggi l'ultimo CLOSE dal log (formato piatto) e verificane reason/bars_held
import json
rows = [json.loads(l) for l in w.trades_path.read_text().strip().splitlines()]
close = [r for r in rows if r.get("event") == "CLOSE"]
assert close and close[-1]["reason"] == "time_limit"
assert close[-1]["bars_held"] == 12