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) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-01 09:05:53 +00:00
parent aaf0221957
commit 53e0965c4b
2 changed files with 64 additions and 0 deletions
+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