From 49039ac28666ac41158ab7253a299f2f0dcb2e34 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 29 May 2026 18:00:12 +0200 Subject: [PATCH] fix(live): StrategyWorker esce intrabar su TP/SL (high/low, al livello) come il backtest Chiude il gap live-vs-backtest delle fade/DIP01: prima il worker controllava solo il close, ora controlla high/low della barra ed esce AL LIVELLO tp/sl (SL prioritario), identico alla semantica intrabar del backtest. +4 test. Pairs/rotation/tsmom invariati. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 3 +- src/live/strategy_worker.py | 21 +++++----- tests/portfolio/test_intrabar_exit.py | 56 +++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 10 deletions(-) create mode 100644 tests/portfolio/test_intrabar_exit.py diff --git a/CLAUDE.md b/CLAUDE.md index fffa2be..9aa953a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -259,7 +259,8 @@ queste fade, ma va confermato col paper trader live prima di rischiare capitale - **Default `portfolios.yml`:** PORT06 (master+shape), `weighting=cap pairs 0.33`, leva 2x, ribilancio 1D. Backtest PORT06: FULL Sharpe 6.07 / OOS Sharpe 8.19, DD 4.9% full / 2.3% OOS. - **Data layer Cerbero v2:** `get_historical_v2` unificato + `get_instruments` (naming robusto) + `get_ticker_batch`. Trading su Deribit. - **SCOPE LIVE (fase 2 completata):** il runner esegue TUTTI gli sleeve di PORT06. Worker: single `StrategyWorker` (fade MR01/02/07, DIP01), `PairsWorker` (PR01 2 gambe), `MLWorkerWrapper` (SH01 retraining), e i multi-asset dedicati `BasketTrendWorker` (TR01 4h), `RotationWorker` (ROT02 1d), `TsmomWorker` (TSM01 1d). Il runner fetcha 1h da Cerbero v2 e **resampla a 4h/1d** (lookback dimensionato sui daily: TSM01 usa 252g). Validazione: runner pool/ribilancio/ledger == backtest (`validate_portfolio_runner.py`, identico); worker multi-asset == reference (`validate_honest_workers.py`: TSM01 esatto, ROT02 +1303% canonico, TR01 stesso ordine — differenza di convenzione capitale-unico vs media-equity). -- **Limite noto:** al ribilancio le posizioni APERTE restano sul loro notional (non travasate). Gap live-vs-backtest noto per gli sleeve con TP/SL intrabar (fade, DIP01): il backtest è intrabar (high/low), il `StrategyWorker` live esce sul close → differenza strutturale, non un bug del runner. Pairs e tsmom/rotation non ne soffrono (exit a chiusura barra). +- **Exit intrabar (fase 3, risolto):** lo `StrategyWorker` ora esce sui TP/SL toccati INTRABAR (high/low della barra, al livello, SL prioritario) come il backtest — non più solo sul close. Allinea fade/DIP01 live al backtest intrabar (`tests/portfolio/test_intrabar_exit.py`). Caveat residuo onesto: nel paper trading l'high/low usato è quello della barra in corso al poll; su un fill reale conterebbe il momento del tocco. +- **Limite noto:** al ribilancio le posizioni APERTE restano sul loro notional (non travasate); fedele al backtest daily-rebalanced entro il turnover infragiornaliero. ## Multi-Strategy Paper Trader diff --git a/src/live/strategy_worker.py b/src/live/strategy_worker.py index 6557824..c5f9580 100644 --- a/src/live/strategy_worker.py +++ b/src/live/strategy_worker.py @@ -210,6 +210,8 @@ class StrategyWorker: c = df["close"].values current_price = float(c[-1]) + bar_high = float(df["high"].iloc[-1]) + bar_low = float(df["low"].iloc[-1]) current_ts = int(df["timestamp"].iloc[-1]) ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True) @@ -219,19 +221,20 @@ class StrategyWorker: self.last_bar_ts = current_ts if self.tp and self.sl: - # Exit guidati dalla strategia: SL (conservativo, prima), poi TP, poi time-limit + # Exit INTRABAR come il backtest: si controllano high/low della barra (non solo il + # close) e si esce AL LIVELLO tp/sl. SL prima (conservativo), poi TP, poi time-limit. if self.direction == 1: - if current_price <= self.sl: - self._close_position(current_price, "stop_loss") - elif current_price >= self.tp: - self._close_position(current_price, "take_profit") + if bar_low <= self.sl: + self._close_position(self.sl, "stop_loss") + elif bar_high >= self.tp: + 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") else: - if current_price >= self.sl: - self._close_position(current_price, "stop_loss") - elif current_price <= self.tp: - self._close_position(current_price, "take_profit") + if bar_high >= self.sl: + self._close_position(self.sl, "stop_loss") + elif bar_low <= self.tp: + 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.bars_held >= self.hold_bars: diff --git a/tests/portfolio/test_intrabar_exit.py b/tests/portfolio/test_intrabar_exit.py new file mode 100644 index 0000000..07987cc --- /dev/null +++ b/tests/portfolio/test_intrabar_exit.py @@ -0,0 +1,56 @@ +"""Fix gap intrabar: lo StrategyWorker esce su TP/SL toccati INTRABAR (high/low della barra), +al livello, come il backtest — non solo quando il close supera il livello.""" +import pandas as pd +from src.live.strategy_worker import StrategyWorker +from src.live.strategy_loader import load_strategy + + +def _df(last_high, last_low, n=120, price=100.0): + c = [price] * n + h = [price] * n + l = [price] * n + h[-1] = last_high + l[-1] = last_low + ts = (pd.date_range("2024-01-01", periods=n, freq="1h", tz="UTC").astype("int64") // 10**6) + return pd.DataFrame({"timestamp": ts, "open": c, "high": h, "low": l, "close": c, "volume": 1.0}) + + +def _long_worker(tmp): + 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 = 102.0 + w.sl = 98.0 + w.max_bars = 24 + w.bars_held = 1 + w.last_bar_ts = 0 + return w + + +def test_long_exits_at_tp_on_intrabar_high(tmp_path): + w = _long_worker(tmp_path) + # close=100 (dentro la banda) ma high tocca 102.5 -> con il fix esce a TP + w.tick(_df(last_high=102.5, last_low=99.5)) + assert not w.in_position + + +def test_long_exits_at_sl_on_intrabar_low(tmp_path): + w = _long_worker(tmp_path) + w.tick(_df(last_high=100.5, last_low=97.0)) # low sotto SL -> stop + assert not w.in_position + + +def test_long_holds_when_bar_within_band(tmp_path): + w = _long_worker(tmp_path) + w.tick(_df(last_high=101.0, last_low=99.0)) # né TP né SL toccati -> resta in posizione + assert w.in_position + + +def test_sl_has_priority_over_tp_same_bar(tmp_path): + w = _long_worker(tmp_path) + # barra che tocca SIA tp(102) SIA sl(98): conservativo -> SL prima + w.tick(_df(last_high=103.0, last_low=97.0)) + assert not w.in_position # uscito (allo stop, ramo SL valutato per primo)