"""EXIT LAB — harness onesto e CONDIVISO per la ricerca di policy di uscita (TP dinamico, SL dinamico/trailing, partial, ride) sulle fade attive. Ricerca 2026-06-04 (>=20 agenti): ogni agente implementa una ExitPolicy in scripts/analysis/exit_policies/_.py e la valuta QUI, sugli STESSI segnali (cache su disco) e con lo stesso engine intrabar di fade_base. CONTRATTO ANTI-LOOK-AHEAD (vincolante, verra' verificato da agenti avversari): - i livelli attivi nel bar j (`levels(j)`) possono usare SOLO dati <= j-1 (il worker live li fissa al close del bar precedente, poi il bar j li tocca); - `after_bar(j)` decide sul CLOSE del bar j (eseguibile al poll del tick); - indicatori: usare l'indice j-1 degli array causali (es. ctx["atr14"][j-1]). PROTOCOLLO ANTI-OVERFIT (vincolante): - TRAIN = storico fino al 2023-11-01, OOS = dopo. La SELEZIONE dei parametri si fa SOLO sul train; l'OOS si guarda una volta, per il verdetto. - gate: il miglioramento deve tenere su ENTRAMBI gli asset e su TUTTE e 3 le strategie (train E oos), con plateau sulla griglia (non una cella isolata). - fee 0.10% RT x leva su tutto il notional; nessuna fee scontata sui limit. Baseline = exit attuale (TP/SL fissi dall'entrata + max_bars): la parita' con `partial_tp_ladder.py --base` e' verificata da `parity_check()`. uv run python scripts/analysis/exit_lab.py # build cache + parity check """ from __future__ import annotations import pickle import sys from pathlib import Path import numpy as np import pandas as pd PROJECT_ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(PROJECT_ROOT)) from src.data.downloader import load_data # noqa: E402 from src.live.strategy_loader import load_strategy # noqa: E402 LIVE_PARAMS = dict(trend_max=3.0, ema_long=200, hurst_max=0.55, min_tp_frac=0.0015) OOS_START_MS = int(pd.Timestamp("2023-11-01", tz="UTC").value // 1e6) LEV, POS, FEE_RT = 3.0, 0.15, 0.001 CODES = ["MR01_bollinger_fade", "MR02_donchian_fade", "MR07_return_reversal"] ASSETS = ("BTC", "ETH") CACHE = PROJECT_ROOT / "data" / "cache" / "exit_lab_signals.pkl" HARD_CAP = 240 # bound assoluto ai bar in posizione (policy "ride" comprese) # ----------------------------------------------------------------------------- dati def _atr14(h: np.ndarray, l: np.ndarray, c: np.ndarray) -> np.ndarray: pc = np.roll(c, 1); pc[0] = c[0] tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc))) return pd.Series(tr).rolling(14).mean().values def load_sleeves(refresh: bool = False) -> dict: """{(code, asset): sleeve} con cache. sleeve = {signals, open, high, low, close, ts_ms, atr14}. signals = [(i, d, tp0, sl0, mb), ...] dai params LIVE.""" if CACHE.exists() and not refresh: with open(CACHE, "rb") as f: return pickle.load(f) out = {} for code in CODES: strat = load_strategy(code) for asset in ASSETS: df = load_data(asset, "1h") ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True) sigs = strat.generate_signals(df, ts, **LIVE_PARAMS) h = df["high"].values.astype(float) l = df["low"].values.astype(float) c = df["close"].values.astype(float) out[(code, asset)] = { "signals": [(int(s.idx), int(s.direction), float(s.metadata["tp"]), float(s.metadata["sl"]), int(s.metadata["max_bars"])) for s in sigs], "open": df["open"].values.astype(float), "high": h, "low": l, "close": c, "ts_ms": df["timestamp"].values.astype(np.int64), "atr14": _atr14(h, l, c), } print(f" cache {code} {asset}: {len(sigs)} segnali, {len(c)} barre " f"({ts.iloc[0].date()} -> {ts.iloc[-1].date()})") CACHE.parent.mkdir(parents=True, exist_ok=True) with open(CACHE, "wb") as f: pickle.dump(out, f) return out # ----------------------------------------------------------------------------- policy class ExitPolicy: """Baseline = exit live attuale. Le sottoclassi ridefinisco levels/after_bar. Una ISTANZA per trade. `ctx` e' il dict sleeve (array completi + indicatori aggiunti da prepare()): per contratto si legge SOLO fino a j-1 in levels(j) e fino a j in after_bar(j)/on_partial(j). """ name = "base" @classmethod def prepare(cls, ctx: dict, **params) -> None: """Pre-calcola array causali per-sleeve (una volta), es. SMA/EMA.""" def __init__(self, ctx: dict, i: int, d: int, entry: float, tp0: float, sl0: float, mb: int, **params): self.ctx, self.i, self.d, self.entry = ctx, i, d, entry self.tp0, self.sl0, self.mb = tp0, sl0, mb self.horizon = mb # le sottoclassi possono estendere (cap HARD_CAP) def levels(self, j: int): """Livelli ATTIVI nel bar j -> (tp, sl, tp_frac). None = livello assente. tp_frac = quota del RESIDUO che esce al tocco del TP (1.0 = tutta).""" return self.tp0, self.sl0, 1.0 def on_partial(self, j: int, price: float, remaining: float) -> None: """Notifica del fill parziale al TP nel bar j (aggiorna lo stato qui).""" def after_bar(self, j: int) -> bool: """True = chiudi il residuo al close[j] (decisione sul close, eseguibile).""" return False # ----------------------------------------------------------------------------- engine def simulate(policy_cls, sleeve: dict, params: dict | None = None, start_ms: int | None = None, end_ms: int | None = None) -> dict: """Replay intrabar dei segnali dello sleeve con la policy. SL prioritario sul TP nello stesso bar (conservativo); fill parziali pesati; max_bars/ horizon esce al close; non-overlap (una posizione per volta).""" params = params or {} h, l, c, ts = sleeve["high"], sleeve["low"], sleeve["close"], sleeve["ts_ms"] n = len(c) ctx = dict(sleeve) policy_cls.prepare(ctx, **params) fee = FEE_RT * LEV capital = peak = 1000.0 max_dd = 0.0 last_exit = -1 trades = wins = 0 bars_tot = 0 rets = [] for (i, d, tp0, sl0, mb) in sleeve["signals"]: if start_ms is not None and ts[i] < start_ms: continue if end_ms is not None and ts[i] >= end_ms: continue if i <= last_exit or i + 1 >= n: continue entry = c[i] pol = policy_cls(ctx, i, d, entry, tp0, sl0, mb, **params) horizon = min(int(pol.horizon), HARD_CAP) fills: list[tuple[float, float]] = [] remaining = 1.0 j = i for step in range(1, horizon + 1): j = i + step if j >= n: j = n - 1 fills.append((remaining, c[j])); remaining = 0.0 break tp, sl, tpfrac = pol.levels(j) hit_sl = sl is not None and ((d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl)) hit_tp = tp is not None and ((d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)) if hit_sl: # conservativo: SL prima del TP fills.append((remaining, sl)); remaining = 0.0 break if hit_tp: f = min(max(tpfrac, 0.0), 1.0) * remaining if f > 0: fills.append((f, tp)); remaining -= f if remaining <= 1e-9: break pol.on_partial(j, tp, remaining) if pol.after_bar(j): fills.append((remaining, c[j])); remaining = 0.0 break if step == horizon: fills.append((remaining, c[j])); remaining = 0.0 if remaining > 1e-9: # safety (non dovrebbe accadere) fills.append((remaining, c[j])) ret = sum(f * (p - entry) for f, p in fills) / entry * d * LEV - fee capital = max(capital + capital * POS * ret, 10.0) peak = max(peak, capital) max_dd = max(max_dd, (peak - capital) / peak) last_exit = j trades += 1 wins += ret > 0 bars_tot += j - i rets.append(ret) if trades == 0: return {} r = np.array(rets) return { "ret_pct": (capital / 1000.0 - 1) * 100, "dd_pct": max_dd * 100, "trades": trades, "win_pct": wins / trades * 100, "avg_ret_bps": r.mean() * 1e4, "sharpe_t": float(r.mean() / r.std() * np.sqrt(len(r))) if r.std() else 0.0, "avg_bars": bars_tot / trades, } # ----------------------------------------------------------------------------- report def evaluate(policy_cls, grid: list[dict], data: dict | None = None, quiet: bool = False) -> dict: """Protocollo train/OOS su tutta la griglia. La selezione dei parametri va fatta SUL TRAIN (l'OOS si riporta, non si ottimizza). Ritorna dict {params_str: {sleeve: {train: {...}, oos: {...}}}} + baseline.""" data = data or load_sleeves() out: dict = {} rows = [("base", ExitPolicy, {})] + [ (", ".join(f"{k}={v}" for k, v in g.items()) or "default", policy_cls, g) for g in grid] for tag, cls, g in rows: out[tag] = {} for (code, asset), sleeve in data.items(): key = f"{code.split('_')[0]} {asset}" tr = simulate(cls, sleeve, g, end_ms=OOS_START_MS) oo = simulate(cls, sleeve, g, start_ms=OOS_START_MS) out[tag][key] = {"train": tr, "oos": oo} if not quiet: print(f"{tag:<28}{key:<10}" f"TRAIN ret{tr.get('ret_pct', 0):>7.0f}% dd{tr.get('dd_pct', 0):>5.1f} " f"sh{tr.get('sharpe_t', 0):>5.2f} n{tr.get('trades', 0):>4} | " f"OOS ret{oo.get('ret_pct', 0):>6.0f}% dd{oo.get('dd_pct', 0):>5.1f} " f"sh{oo.get('sharpe_t', 0):>5.2f} n{oo.get('trades', 0):>4} " f"bars{oo.get('avg_bars', 0):>5.1f}") return out def parity_check() -> None: """La baseline qui deve riprodurre i numeri FULL di partial_tp_ladder (base): MR01 BTC ~92%/13.8dd, MR01 ETH ~194%/16.5dd, MR02 ETH ~2135%/16.2dd...""" data = load_sleeves() print("\nParity check baseline (FULL, atteso = partial_tp_ladder base):") expected = {("MR01_bollinger_fade", "BTC"): 92, ("MR01_bollinger_fade", "ETH"): 194, ("MR02_donchian_fade", "BTC"): 129, ("MR02_donchian_fade", "ETH"): 2135, ("MR07_return_reversal", "BTC"): 78, ("MR07_return_reversal", "ETH"): 115} ok = True for key, sleeve in data.items(): r = simulate(ExitPolicy, sleeve) exp = expected[key] match = abs(r["ret_pct"] - exp) < 1.0 ok &= match print(f" {key[0].split('_')[0]} {key[1]}: ret {r['ret_pct']:.0f}% " f"(atteso ~{exp}) {'OK' if match else 'MISMATCH'}") print("PARITY", "OK" if ok else "FAILED") if __name__ == "__main__": load_sleeves(refresh="--refresh" in sys.argv) parity_check()