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>
This commit is contained in:
Adriano Dal Pastro
2026-06-05 17:56:16 +00:00
parent 6f86c644bf
commit bd6232dc00
16 changed files with 2413 additions and 3 deletions
+252
View File
@@ -0,0 +1,252 @@
"""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)