bd6232dc00
Crash ETH 2026-06-05: SH01 ETH −15.6% su un trade (exit solo a orizzonte, nessuna protezione). Ricerca con harness dedicato sh01_exit_lab (cache walk-forward, engine fill gap-aware worse(livello,open), parity esatta con explore_lab, train<=2023-11-01): ATR intrabar/close-confirm, %, chandelier, breakeven, giveback, loser-timestop, disaster-cap close+intrabar, swing, vol-regime — NESSUNA passa il gate (ogni stop stretto rompe BTC, ogni stop largo non tocca la coda ETH; nei crash il fill e' al gap). Mitigazione: peso famiglia SHAPE 11.8%->5.9% in PORT06 (FULL 6.47->6.43 DD 4.10->3.96, OOS 8.82->8.58 DD 1.30->1.36) — la prossima coda impatta il conto per meta'. Regression-lock test aggiornato. Diario: docs/diary/2026-06-05-sh01-sl-research.md Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
253 lines
10 KiB
Python
253 lines
10 KiB
Python
"""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)
|