diff --git a/CLAUDE.md b/CLAUDE.md index 5a50c6e..cc0d242 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -195,6 +195,23 @@ regime tossico, non rendendo sicuro ogni trade). **Monitor live:** `hourly_repor stop-rate fade PRIMA/DOPO l'attivazione (14:34 UTC del 2026-06-02) e dà il verdetto su Telegram quando il campione DOPO ≥30 (già confermato: stop-rate live PRIMA 42% == backtest 42.1%). +**EXIT-16 close-confirm SL (ATTIVO LIVE, 2026-06-04).** Le fade accettano `sl_confirm_atr` (live: +0.5 in `_defs.py`): lo SL **intrabar è disattivato** e lo stop scatta solo se il **CLOSE** della barra +sfonda `sl ∓ 0.5·ATR14`, con uscita al close (TP intrabar al livello e max_bars invariati; in modalità +confirm il TP ha priorità nel bar). Scoperta della ricerca exit-lab (34 agenti, 23 famiglie esplorate + +10 verifiche avversariali + test PORT06): **gli stop intrabar da wick sono falsi negativi** — l'overshoot +che buca lo stop e rientra è esattamente il movimento che la fade fada. Verificato: indipendente dal +loss-guard Hurst, plateau buffer 0.4-1.0, regge fee 2x/lag/slippage, coda ≈ base nei crash veri (FTX: ++2.4% vs −39% del no-SL puro). **PORT06: FULL Sharpe 6.47→7.84 DD 4.10→2.60, OOS Sharpe 8.82→10.06 DD +1.30→1.15.** La famiglia "cavalca il prezzo" (trailing/ride/partial-runner, 15 varianti) è invece tutta +SCARTATA: oltre il TP=media non c'è coda (4ª conferma). Collaterali: l'engine intrabar filla gli stop +"al livello" anche su gap-through (54% dei casi per stop tight) → bias PRO stop-stretti nelle ricerche +future; mai deployare strategie con `sl=0` (il fallback −2% del worker non si applicherebbe). Harness +riusabile `scripts/analysis/exit_lab.py` + policy in `exit_policies/`. Implementazione: `fade_base.backtest` ++ `StrategyWorker.tick` (param `sl_confirm_atr`, None = comportamento storico; il backtest canonico +`build_everything` resta NON filtrato → il live farà meglio del backtest, come per il loss-guard). +Diario `docs/diary/2026-06-04-exit-lab.md`. + **Portafoglio.** Diversificare su sotto-conti indipendenti equipesati (le 4 strategie × BTC/ETH, pos 0.15 ciascuno) abbatte il DD aggregato: ~14% full / ~10% OOS sul paniere di 8 sleeve, contro il 20-70% del singolo. È la vera leva anti-drawdown. diff --git a/scripts/portfolios/_defs.py b/scripts/portfolios/_defs.py index b8931b3..2583677 100644 --- a/scripts/portfolios/_defs.py +++ b/scripts/portfolios/_defs.py @@ -24,8 +24,17 @@ MIN_TP_FRAC = 0.0015 # Validato 2026-06-02, vedi docs/diary/2026-06-02-fade-lossguard.md. HURST_MAX = 0.55 +# EXIT-16 close-confirm SL (live, 2026-06-04): lo SL intrabar fisso veniva triggerato dai +# wick transitori (falsi negativi: l'overshoot che buca lo stop rientra — e' il movimento +# che la fade fada). Lo stop scatta solo se il CLOSE sfonda sl ∓ 0.5*ATR14. PORT06: +# FULL Sharpe 6.47->7.84 DD 4.10->2.60, OOS 8.82->10.06 DD 1.30->1.15 (gate del loss-guard +# superato con margine; 34 agenti, 3 verifiche avversariali + tail-risk: coda ~= base, +# esce nei crash veri tipo FTX). Vedi docs/diary/2026-06-04-exit-lab.md. +SL_CONFIRM_ATR = 0.5 + FADE = [SleeveSpec(kind="single", name=c, sid=f"{c}_{a}", asset=a, cluster=f"{a}-rev", - params={"min_tp_frac": MIN_TP_FRAC, "hurst_max": HURST_MAX}) + params={"min_tp_frac": MIN_TP_FRAC, "hurst_max": HURST_MAX, + "sl_confirm_atr": SL_CONFIRM_ATR}) for a in ("BTC", "ETH") for c in ("MR01", "MR02", "MR07")] HONEST = [ # DIP01: single-asset 1h -> StrategyWorker (Strategy DIP01_dip_buy). TR01/ROT02: multi-asset. diff --git a/scripts/strategies/MR01_bollinger_fade.py b/scripts/strategies/MR01_bollinger_fade.py index 489ca36..0b20294 100644 --- a/scripts/strategies/MR01_bollinger_fade.py +++ b/scripts/strategies/MR01_bollinger_fade.py @@ -106,6 +106,10 @@ class BollingerFade(Strategy): h, l, c = df["high"].values, df["low"].values, df["close"].values n = len(c) + # EXIT-16 close-confirm SL (2026-06-04): come in fade_base.FadeStrategy.backtest. + # None = comportamento storico. Vedi docs/diary/2026-06-04-exit-lab.md. + sl_confirm = params.get("sl_confirm_atr") + a14 = _atr(df, 14) if sl_confirm else None fee = self.fee_rt * self.leverage capital = peak = float(self.initial_capital) max_dd = 0.0 @@ -125,12 +129,20 @@ class BollingerFade(Strategy): j = i + step if j >= n: j = n - 1; exit_p = c[j]; break - hit_sl = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl) hit_tp = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp) - if hit_sl: - exit_p = sl; break - if hit_tp: - exit_p = tp; break + if sl_confirm is None: + hit_sl = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl) + if hit_sl: + exit_p = sl; break + if hit_tp: + exit_p = tp; break + else: + # close-confirm: TP intrabar al livello; SL valutato sul CLOSE + if hit_tp: + exit_p = tp; break + buf = sl_confirm * a14[j] + if (d == 1 and c[j] < sl - buf) or (d == -1 and c[j] > sl + buf): + exit_p = c[j]; break if step == mb: exit_p = c[j] diff --git a/src/live/strategy_worker.py b/src/live/strategy_worker.py index 53b40d8..19786c3 100644 --- a/src/live/strategy_worker.py +++ b/src/live/strategy_worker.py @@ -10,6 +10,7 @@ import numpy as np import pandas as pd from src.strategies.base import Strategy, Signal +from src.strategies.fade_base import atr as _atr from src.live.telegram_notifier import notify_event from src.live.execution import ExecutionClient @@ -80,6 +81,12 @@ class StrategyWorker: self.tp: float = 0.0 self.sl: float = 0.0 self.max_bars: int = 0 + # EXIT-16 close-confirm SL (2026-06-04, fade): se settato nei params dello + # sleeve, lo SL intrabar e' disattivato e lo stop scatta solo se il CLOSE + # sfonda sl di sl_confirm_atr*ATR14 (immune ai wick). TP intrabar invariato. + self.sl_confirm_atr: float | None = ( + float(self.params["sl_confirm_atr"]) + if self.params.get("sl_confirm_atr") else None) # Fee dalla strategia (MR01 = 0.001 realistico Deribit), fallback al default modulo self.fee_rt: float = float(getattr(strategy, "fee_rt", FEE_RT)) @@ -419,7 +426,30 @@ class StrategyWorker: self.bars_held += 1 self.last_bar_ts = current_ts - if self.tp and self.sl: + if self.tp and self.sl and self.sl_confirm_atr: + # EXIT-16 close-confirm (2026-06-04): TP intrabar al livello come il + # backtest; lo SL scatta SOLO se il close sfonda sl ∓ buf*ATR14 — i + # wick che bucano lo stop e rientrano (l'overshoot che la fade fada) + # non stoppano piu'. Uscita stop al CLOSE (prezzo corrente), non al + # livello. PORT06: OOS Sharpe 8.82->10.06 (exit-lab, 34 agenti). + buf = self.sl_confirm_atr * float(_atr(df, 14)[-1]) + if not np.isfinite(buf): + buf = 0.0 + if self.direction == 1: + if bar_high >= self.tp: + self._close_position(self.tp, "take_profit") + elif current_price < self.sl - buf: + self._close_position(current_price, "stop_loss") + elif self.max_bars and self.bars_held >= self.max_bars: + self._close_position(current_price, "time_limit") + else: + if bar_low <= self.tp: + self._close_position(self.tp, "take_profit") + elif current_price > self.sl + buf: + self._close_position(current_price, "stop_loss") + elif self.max_bars and self.bars_held >= self.max_bars: + self._close_position(current_price, "time_limit") + elif self.tp and self.sl: # 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: diff --git a/src/strategies/fade_base.py b/src/strategies/fade_base.py index 095ebf4..12377c7 100644 --- a/src/strategies/fade_base.py +++ b/src/strategies/fade_base.py @@ -74,6 +74,15 @@ class FadeStrategy(Strategy): if not signals: return None + # EXIT-16 close-confirm SL (2026-06-04): se settato, lo SL intrabar e' + # DISATTIVATO e lo stop scatta solo se il CLOSE sfonda sl ∓ buf*ATR14 + # (immune ai wick: l'overshoot che buca lo stop e rientra e' esattamente + # il movimento che la fade vuole fadare). TP intrabar e max_bars invariati. + # PORT06: FULL Sharpe 6.47->7.84 DD 4.10->2.60, OOS 8.82->10.06 (exit-lab, + # 34 agenti). None = comportamento storico. Diario 2026-06-04-exit-lab.md. + sl_confirm = params.get("sl_confirm_atr") + a14 = atr(df, 14) if sl_confirm else None + h, l, c = df["high"].values, df["low"].values, df["close"].values n = len(c) fee = self.fee_rt * self.leverage @@ -95,12 +104,20 @@ class FadeStrategy(Strategy): j = i + step if j >= n: j = n - 1; exit_p = c[j]; break - hit_sl = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl) hit_tp = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp) - if hit_sl: # conservativo: SL prima del TP nello stesso bar - exit_p = sl; break - if hit_tp: - exit_p = tp; break + if sl_confirm is None: + hit_sl = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl) + if hit_sl: # conservativo: SL prima del TP nello stesso bar + exit_p = sl; break + if hit_tp: + exit_p = tp; break + else: + # close-confirm: TP intrabar al livello; SL valutato sul CLOSE + if hit_tp: + exit_p = tp; break + buf = sl_confirm * a14[j] + if (d == 1 and c[j] < sl - buf) or (d == -1 and c[j] > sl + buf): + exit_p = c[j]; break if step == mb: exit_p = c[j] diff --git a/tests/portfolio/test_close_confirm_sl.py b/tests/portfolio/test_close_confirm_sl.py new file mode 100644 index 0000000..fe8e1c0 --- /dev/null +++ b/tests/portfolio/test_close_confirm_sl.py @@ -0,0 +1,74 @@ +"""EXIT-16 close-confirm SL (2026-06-04): col param `sl_confirm_atr` il wick che +buca lo SL NON stoppa piu' (immune ai wick); lo stop scatta solo se il CLOSE +sfonda sl ∓ buf*ATR14, con uscita al close. TP intrabar invariato. +Senza param il comportamento resta quello storico (regressione).""" +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, last_close, n=120, price=100.0): + c = [price] * n + h = [price] * n + l = [price] * n + h[-1] = last_high + l[-1] = last_low + c[-1] = last_close + ts = (pd.date_range("2024-01-01", periods=n, freq="1h", tz="UTC").astype("int64") // 10**6) + return pd.DataFrame({"timestamp": ts, "open": [price] * n, "high": h, "low": l, + "close": c, "volume": 1.0}) + + +def _long_worker(tmp, confirm=0.5): + params = {"sl_confirm_atr": confirm} if confirm else {} + w = StrategyWorker(strategy=load_strategy("MR01_bollinger_fade"), asset="BTC", tf="1h", + capital=1000.0, params=params, 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_wick_below_sl_does_not_stop(tmp_path): + # low 97 buca lo SL(98) ma il CLOSE rientra a 100 -> resta in posizione + w = _long_worker(tmp_path) + w.tick(_df(last_high=100.5, last_low=97.0, last_close=100.0)) + assert w.in_position + + +def test_same_wick_stops_without_param(tmp_path): + # regressione: senza sl_confirm_atr lo stesso wick stoppa intrabar al livello + w = _long_worker(tmp_path, confirm=None) + w.tick(_df(last_high=100.5, last_low=97.0, last_close=100.0)) + assert not w.in_position + + +def test_close_breach_stops_at_close(tmp_path): + # close 96.5 sfonda sl-buf (ATR del df flat ~ TR ultimo bar /14 -> buf piccolo) -> stop + w = _long_worker(tmp_path) + cap_before = w.capital + w.tick(_df(last_high=100.5, last_low=96.0, last_close=96.5)) + assert not w.in_position + assert w.capital < cap_before # uscito al CLOSE (in perdita), non al livello + + +def test_tp_intrabar_still_first(tmp_path): + # high tocca il TP(102) intrabar -> esce al TP anche in modalita' close-confirm + w = _long_worker(tmp_path) + w.tick(_df(last_high=102.5, last_low=99.5, last_close=100.0)) + assert not w.in_position + assert w.capital > 1000.0 # uscito al TP in profitto + + +def test_small_breach_within_buffer_holds(tmp_path): + # close appena sotto SL ma DENTRO il buffer ATR -> resta in posizione + w = _long_worker(tmp_path) + df = _df(last_high=100.0, last_low=94.0, last_close=97.95) # TR=6 -> ATR~0.43 -> buf~0.21 + w.tick(df) + assert w.in_position