feat(fade): EXIT-16 close-confirm SL — stop solo se il CLOSE sfonda sl∓0.5·ATR14 (immune ai wick)

Param sl_confirm_atr (None = comportamento storico) in FadeStrategy.backtest,
MR01.backtest e StrategyWorker.tick; attivo live a 0.5 sulle 6 fade in _defs.py.
TP intrabar al livello e max_bars invariati; in modalita' confirm il TP ha
priorita' nel bar. Ricerca exit-lab (34 agenti, 3 lenti avversariali + tail
audit): gli stop intrabar da wick sono falsi negativi per le fade. PORT06
canonico: FULL Sharpe 6.47->7.84 DD 4.10->2.60, OOS 8.82->10.06 DD 1.30->1.15.
NB path grezzo non filtrato: ret esplode ma DD per-sleeve sale — la riduzione
DD vive nel config live (trend_max+hurst) + diversificazione, come misurato.
5 test nuovi (59 verdi).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-04 21:25:17 +00:00
parent ad65a0b344
commit a2579d21bc
6 changed files with 171 additions and 12 deletions
+17
View File
@@ -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.
+10 -1
View File
@@ -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.
+17 -5
View File
@@ -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]
+31 -1
View File
@@ -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:
+22 -5
View File
@@ -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]
+74
View File
@@ -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