From 53e0965c4ba340b17a6a8e34f462994ed2e48be1 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Mon, 1 Jun 2026 09:05:53 +0000 Subject: [PATCH] fix(live): exit a orizzonte per strategie senza TP/SL (SH01) Lo StrategyWorker onorava max_bars (orizzonte del Signal) solo nel ramo `if self.tp and self.sl`. SH01 (shape-ML, H=12) non porta TP/SL, quindi cadeva sul fallback legacy hold_bars=3 e chiudeva a 3 barre invece delle 12 validate: l'edge (asimmetria sull'orizzonte, non frequenza) non aveva tempo di realizzarsi -> accuratezza live falsata (33%), tutti exit "hold_limit" a bars_held=3. Aggiunto un ramo `elif self.max_bars` che esce a "time_limit" quando bars_held>=max_bars, prima del fallback hold_bars. Tocca solo le strategie horizon-only (SH01); le fade con tp+sl+max_bars sono invariate. Test: tests/portfolio/test_horizon_exit.py (resta in posizione a 3 barre con max_bars=12; esce a 12 con reason time_limit). Suite: 43 passed. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/live/strategy_worker.py | 5 +++ tests/portfolio/test_horizon_exit.py | 59 ++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 tests/portfolio/test_horizon_exit.py diff --git a/src/live/strategy_worker.py b/src/live/strategy_worker.py index c5f9580..8b269f5 100644 --- a/src/live/strategy_worker.py +++ b/src/live/strategy_worker.py @@ -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: diff --git a/tests/portfolio/test_horizon_exit.py b/tests/portfolio/test_horizon_exit.py new file mode 100644 index 0000000..8d91980 --- /dev/null +++ b/tests/portfolio/test_horizon_exit.py @@ -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