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:
@@ -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
|
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%).
|
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
|
**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
|
× 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.
|
paniere di 8 sleeve, contro il 20-70% del singolo. È la vera leva anti-drawdown.
|
||||||
|
|||||||
@@ -24,8 +24,17 @@ MIN_TP_FRAC = 0.0015
|
|||||||
# Validato 2026-06-02, vedi docs/diary/2026-06-02-fade-lossguard.md.
|
# Validato 2026-06-02, vedi docs/diary/2026-06-02-fade-lossguard.md.
|
||||||
HURST_MAX = 0.55
|
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",
|
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")]
|
for a in ("BTC", "ETH") for c in ("MR01", "MR02", "MR07")]
|
||||||
HONEST = [
|
HONEST = [
|
||||||
# DIP01: single-asset 1h -> StrategyWorker (Strategy DIP01_dip_buy). TR01/ROT02: multi-asset.
|
# DIP01: single-asset 1h -> StrategyWorker (Strategy DIP01_dip_buy). TR01/ROT02: multi-asset.
|
||||||
|
|||||||
@@ -106,6 +106,10 @@ class BollingerFade(Strategy):
|
|||||||
|
|
||||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||||
n = len(c)
|
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
|
fee = self.fee_rt * self.leverage
|
||||||
capital = peak = float(self.initial_capital)
|
capital = peak = float(self.initial_capital)
|
||||||
max_dd = 0.0
|
max_dd = 0.0
|
||||||
@@ -125,12 +129,20 @@ class BollingerFade(Strategy):
|
|||||||
j = i + step
|
j = i + step
|
||||||
if j >= n:
|
if j >= n:
|
||||||
j = n - 1; exit_p = c[j]; break
|
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)
|
hit_tp = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
|
||||||
|
if sl_confirm is None:
|
||||||
|
hit_sl = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl)
|
||||||
if hit_sl:
|
if hit_sl:
|
||||||
exit_p = sl; break
|
exit_p = sl; break
|
||||||
if hit_tp:
|
if hit_tp:
|
||||||
exit_p = tp; break
|
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:
|
if step == mb:
|
||||||
exit_p = c[j]
|
exit_p = c[j]
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import numpy as np
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from src.strategies.base import Strategy, Signal
|
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.telegram_notifier import notify_event
|
||||||
from src.live.execution import ExecutionClient
|
from src.live.execution import ExecutionClient
|
||||||
|
|
||||||
@@ -80,6 +81,12 @@ class StrategyWorker:
|
|||||||
self.tp: float = 0.0
|
self.tp: float = 0.0
|
||||||
self.sl: float = 0.0
|
self.sl: float = 0.0
|
||||||
self.max_bars: int = 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
|
# Fee dalla strategia (MR01 = 0.001 realistico Deribit), fallback al default modulo
|
||||||
self.fee_rt: float = float(getattr(strategy, "fee_rt", FEE_RT))
|
self.fee_rt: float = float(getattr(strategy, "fee_rt", FEE_RT))
|
||||||
|
|
||||||
@@ -419,7 +426,30 @@ class StrategyWorker:
|
|||||||
self.bars_held += 1
|
self.bars_held += 1
|
||||||
self.last_bar_ts = current_ts
|
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
|
# 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.
|
# close) e si esce AL LIVELLO tp/sl. SL prima (conservativo), poi TP, poi time-limit.
|
||||||
if self.direction == 1:
|
if self.direction == 1:
|
||||||
|
|||||||
@@ -74,6 +74,15 @@ class FadeStrategy(Strategy):
|
|||||||
if not signals:
|
if not signals:
|
||||||
return None
|
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
|
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||||
n = len(c)
|
n = len(c)
|
||||||
fee = self.fee_rt * self.leverage
|
fee = self.fee_rt * self.leverage
|
||||||
@@ -95,12 +104,20 @@ class FadeStrategy(Strategy):
|
|||||||
j = i + step
|
j = i + step
|
||||||
if j >= n:
|
if j >= n:
|
||||||
j = n - 1; exit_p = c[j]; break
|
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)
|
hit_tp = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
|
||||||
|
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
|
if hit_sl: # conservativo: SL prima del TP nello stesso bar
|
||||||
exit_p = sl; break
|
exit_p = sl; break
|
||||||
if hit_tp:
|
if hit_tp:
|
||||||
exit_p = tp; break
|
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:
|
if step == mb:
|
||||||
exit_p = c[j]
|
exit_p = c[j]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user