"""SH01 EXIT LAB — harness onesto e CONDIVISO per la ricerca di STOP-LOSS su SH01. SH01 (shape-ML, logit walk-forward W24 H12 th0.58) NON ha TP/SL: esce SOLO a orizzonte H=12 barre. Live (2026-06-05) si è preso il crash ETH intero: −15.6% in un trade (long 1727.8 → 1594.35, leva 2x). Domanda di ricerca: esiste uno SL che taglia le code SENZA distruggere l'edge (che vive nell'asimmetria dei winner, win-rate ~50%)? CONTRATTO ANTI-LOOK-AHEAD (vincolante, verificato da agenti avversari): - i livelli attivi nel bar j (`levels(..., j)`) possono usare SOLO dati <= j-1 (il worker live fissa i livelli al close del bar precedente; il bar j li tocca); - `after_bar(..., j)` decide sul CLOSE del bar j (eseguibile al poll del tick); - indicatori causali: usare l'indice j-1 (es. ctx["atr14"][j-1]). FILL GAP-AWARE (lezione exit-lab 2026-06-04 + crash live 2026-06-05): lo stop intrabar NON filla "al livello" se il bar apre già oltre → fill = worse(level, open[j]). Senza questo il backtest ha un bias PRO stop-stretti (54% dei fill era ottimista). Il crash di oggi (feed flat 2h → gap 1655→1600) è il caso reale. PROTOCOLLO ANTI-OVERFIT (vincolante, = exit_lab): - TRAIN = storico fino al 2023-11-01, OOS = dopo. SELEZIONE parametri SOLO sul train; OOS guardato una volta per il verdetto. - gate: miglioramento su ENTRAMBI gli asset (BTC e ETH), train E oos, con plateau sulla griglia (non una cella isolata). Metrica primaria: Sharpe e DD; il return non deve crollare (>= ~80% del baseline). - fee 0.10% RT × leva su tutto il notional. Baseline = exit a orizzonte puro (max_bars=H, nessun TP/SL): parità ESATTA con `explore_lab.simulate` verificata da `parity_check()`. uv run python scripts/analysis/sh01_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)) LEV, POS, FEE_RT = 3.0, 0.15, 0.001 OOS_START_MS = int(pd.Timestamp("2023-11-01", tz="UTC").value // 1e6) ASSETS = ("BTC", "ETH") CACHE = PROJECT_ROOT / "data" / "cache" / "sh01_exit_lab.pkl" # ----------------------------------------------------------------------------- cache def build_cache() -> dict: """Walk-forward SH01 (lento, ~minuti) → entries cache su disco.""" from scripts.analysis.explore_lab import get_df # noqa: E402 from scripts.analysis.shape_ml_research import ml_wf_entries, atr # noqa: E402 from scripts.strategies.SH01_shape_ml import CONFIG # noqa: E402 out = {} for a in ASSETS: df = get_df(a, "1h") ents = ml_wf_entries(df, **CONFIG) out[a] = { "entries": [(int(e["i"]), int(e["d"]), int(e["max_bars"])) for e in ents], "open": df["open"].values.astype(float), "high": df["high"].values.astype(float), "low": df["low"].values.astype(float), "close": df["close"].values.astype(float), "ts_ms": df["timestamp"].values.astype("int64"), "atr14": atr(df, 14), } print(f" {a}: {len(ents)} entries, {len(df)} bars", flush=True) CACHE.parent.mkdir(parents=True, exist_ok=True) with open(CACHE, "wb") as f: pickle.dump(out, f) return out def load_sleeves(refresh: bool = False) -> dict: """{asset: ctx}. ctx = {entries, open, high, low, close, ts_ms, atr14}.""" if CACHE.exists() and not refresh: with open(CACHE, "rb") as f: return pickle.load(f) return build_cache() # ----------------------------------------------------------------------------- policy class ExitPolicy: """Contratto per le policy di stop su SH01 (solo SL/uscite anticipate: il TP non esiste e l'exit a orizzonte max_bars resta SEMPRE il bound). open_trade(ctx, i, d) -> state : livelli iniziali, SOLO dati <= i levels(ctx, i, d, j, st) -> (sl, mode) attivi nel bar j, SOLO dati <= j-1. sl=None → nessuno stop nel bar. mode: "intrabar" (tocco high/low, fill gap-aware worse(sl, open[j])) o "close" (stop solo se il CLOSE sfonda sl, uscita al close — stile EXIT-16). after_bar(ctx, i, d, j, st) -> bool : uscita discrezionale al CLOSE del bar j (dati <= j). Per giveback/time-stop/regime. Lo state è un dict mutabile per-trade (trailing ecc.).""" name = "base" def open_trade(self, ctx: dict, i: int, d: int) -> dict: return {} def levels(self, ctx: dict, i: int, d: int, j: int, st: dict): return None, "intrabar" def after_bar(self, ctx: dict, i: int, d: int, j: int, st: dict) -> bool: return False # ----------------------------------------------------------------------------- engine def simulate(ctx: dict, policy: ExitPolicy, fee_rt: float = FEE_RT, lev: float = LEV, pos: float = POS, t_lo: int | None = None, t_hi: int | None = None, gap_fill: bool = True, lag_close_exit: bool = False) -> dict: """Engine intrabar con policy di stop. Entries non sovrapposte (come explore_lab.simulate). t_lo/t_hi: filtro ms-epoch sull'ENTRY (train/oos). gap_fill: fill stop intrabar a worse(sl, open[j]) — tenere True. lag_close_exit: stress — le uscite "al close" fillano al close del bar successivo (poll in ritardo).""" o, h, l, c = ctx["open"], ctx["high"], ctx["low"], ctx["close"] ts = ctx["ts_ms"] n = len(c) cap = peak = 1000.0 max_dd = 0.0 fee = fee_rt * lev trades = wins = stops = 0 bars_in = 0 last_exit = -1 yearly: dict[int, float] = {} rets: list[float] = [] trade_rows: list[dict] = [] for (i, d, mb) in ctx["entries"]: if i <= last_exit or i + 1 >= n: continue if t_lo is not None and ts[i] < t_lo: continue if t_hi is not None and ts[i] >= t_hi: continue entry = c[i] st = policy.open_trade(ctx, i, d) exit_p, j, reason = c[min(i + mb, n - 1)], min(i + mb, n - 1), "time" for k in range(1, mb + 1): j = i + k if j >= n: j, exit_p, reason = n - 1, c[n - 1], "eod" break sl, mode = policy.levels(ctx, i, d, j, st) if sl is not None and mode == "intrabar": hit = (l[j] <= sl) if d == 1 else (h[j] >= sl) if hit: if gap_fill: exit_p = min(sl, o[j]) if d == 1 else max(sl, o[j]) else: exit_p = sl reason = "stop" break if sl is not None and mode == "close": brk = (c[j] < sl) if d == 1 else (c[j] > sl) if brk: jj = min(j + 1, n - 1) if lag_close_exit else j exit_p, j, reason = c[jj], jj, "stop" break if policy.after_bar(ctx, i, d, j, st): jj = min(j + 1, n - 1) if lag_close_exit else j exit_p, j, reason = c[jj], jj, "policy" break if k == mb: exit_p, reason = c[j], "time" ret = (exit_p - entry) / entry * d * lev - fee cb = cap cap = max(cb + cb * pos * ret, 10.0) peak = max(peak, cap) max_dd = max(max_dd, (peak - cap) / peak) trades += 1 wins += ret > 0 stops += reason == "stop" bars_in += (j - i) last_exit = j rets.append(ret * pos) yr = pd.Timestamp(ts[i], unit="ms", tz="UTC").year yearly[yr] = yearly.get(yr, 0.0) + ret * 100 trade_rows.append({"i": i, "j": j, "d": d, "ret": ret, "reason": reason}) sharpe = (float(np.mean(rets) / np.std(rets) * np.sqrt(len(rets))) if len(rets) > 1 and np.std(rets) > 0 else 0.0) return { "trades": trades, "win": wins / trades * 100 if trades else 0.0, "stop_rate": stops / trades * 100 if trades else 0.0, "ret": (cap / 1000 - 1) * 100, "dd": max_dd * 100, "sharpe": sharpe, "worst": min(rets) * 100 if rets else 0.0, # peggior trade, % equity (ret*pos) "yearly": yearly, "_trades": trade_rows, } def evaluate(policy: ExitPolicy, sleeves: dict | None = None, **kw) -> dict: """train (fino al 2023-11-01) e oos (dopo) per BTC e ETH. Stampa sintesi.""" sleeves = sleeves or load_sleeves() out = {} for a in ASSETS: ctx = sleeves[a] tr = simulate(ctx, policy, t_hi=OOS_START_MS, **kw) oo = simulate(ctx, policy, t_lo=OOS_START_MS, **kw) out[a] = {"train": tr, "oos": oo} print(f" {policy.name:<28s} {a}: " f"TRAIN ret={tr['ret']:>+7.0f}% dd={tr['dd']:>4.0f}% shrp={tr['sharpe']:>5.2f} " f"worst={tr['worst']:>+5.1f}% stop={tr['stop_rate']:>4.1f}% | " f"OOS ret={oo['ret']:>+6.0f}% dd={oo['dd']:>4.0f}% shrp={oo['sharpe']:>5.2f} " f"worst={oo['worst']:>+5.1f}%", flush=True) return out # ----------------------------------------------------------------------------- parity def parity_check() -> bool: """Baseline (nessuno stop) == explore_lab.simulate sugli stessi entries.""" from scripts.analysis.explore_lab import get_df, simulate as ref_sim # noqa: E402 sleeves = load_sleeves() ok = True for a in ASSETS: ctx = sleeves[a] mine = simulate(ctx, ExitPolicy()) df = get_df(a, "1h") ents = [{"i": i, "d": d, "max_bars": mb, "tp": None, "sl": None} for (i, d, mb) in ctx["entries"]] ref = ref_sim(ents, df) same = (abs(mine["ret"] - ref["ret"]) < 1e-6 and mine["trades"] == ref["trades"] and abs(mine["dd"] - ref["dd"]) < 1e-6) ok &= same print(f" parity {a}: mine ret={mine['ret']:+.2f}% trades={mine['trades']} " f"| ref ret={ref['ret']:+.2f}% trades={ref['trades']} -> {'OK' if same else 'MISMATCH'}") return ok if __name__ == "__main__": print("build cache (walk-forward SH01, puo' richiedere minuti)...") load_sleeves(refresh="--refresh" in sys.argv) print("parity check baseline vs explore_lab.simulate:") ok = parity_check() print("baseline train/oos:") evaluate(ExitPolicy()) sys.exit(0 if ok else 1)