Files
PythagorasGoal/scripts/analysis/sh01_exit_lab.py
T
Adriano Dal Pastro bd6232dc00 config(PORT06): cap SHAPE 0.0588 — SH01 resta senza SL (ricerca multi-agente: 11 famiglie di stop, 0 sopravvissute)
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>
2026-06-05 17:56:16 +00:00

253 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)