research(exit-lab): 34 agenti su exit dinamiche → EXIT-16 close-confirm SL PROMOSSO a livello PORT06

23 famiglie esplorate (harness condiviso exit_lab, train/OOS embargo nov-2023,
tutto lo storico 1h 2018-2026) + 10 verifiche avversariali + test PORT06.
'Cavalcare il prezzo' non esiste (4a conferma: oltre il TP=media non c'e' coda).
Scoperta: lo SL intrabar fisso e' il distruttore di valore n.1 delle fade
(stop da wick = falsi negativi). Forma robusta: SL solo su CHIUSURA oltre
sl0±0.5·ATR14 — PORT06 FULL Sharpe 6.47→7.84 DD 4.10→2.60, OOS 8.82→10.06.
Collaterali: bias gap-through dell'engine sugli stop stretti; ramo -2% del
worker morto con sl=0. Diario: docs/diary/2026-06-04-exit-lab.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-04 21:16:58 +00:00
parent 3accc91f84
commit ad65a0b344
36 changed files with 5116 additions and 0 deletions
@@ -0,0 +1,335 @@
"""TAIL-RISK AUDIT of EXIT-22 (no_sl) and EXIT-16 (close_confirm_sl).
Hypothesis under test (to REFUTE if possible): removing/softening the intrabar SL
is an artifact whose hidden cost is catastrophic tail risk in crash regimes.
Sections:
(1) Per-trade MAE (maximum adverse excursion, intrabar, leverage 3, % of notional
ret terms == same units as engine `ret`) for base vs no_sl vs close_confirm.
Distribution p50/p95/p99/max per sleeve.
(2) Crash windows: 2020-03-12, 2021-05-19, 2022-06, 2022-11 (FTX). Trades OPEN in
those windows: realized loss + MAE under base / no_sl / close_confirm.
(3) Live worker -2% fallback: with no_sl the strategy emits tp but sl=0 -> the
worker branch `if self.tp and self.sl` is False, falls to `elif self.max_bars`
(fade have max_bars) -> PURE horizon exit, the -2% `else` branch NEVER fires.
So no_sl LIVE has NO stop at all. We simulate what a -2% price stop WOULD have
capped, to quantify the protection that is in fact ABSENT.
(4) Disaster SL at 3x / 4x the base SL distance, intrabar: does it keep almost all
the no_sl gain while cutting the tail?
Run: cd /opt/docker/PythagorasGoal && PYTHONPATH=. uv run python \
scripts/analysis/exit_policies/verify_tail_risk.py
"""
import sys
from pathlib import Path
import numpy as np
import pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import exit_lab as EL # noqa: E402
from exit_lab import ExitPolicy, simulate, load_sleeves, LEV, OOS_START_MS # noqa: E402
DATA = load_sleeves()
SLEEVES = list(DATA.items())
# --------------------------------------------------------------------------- helpers
def _replay_trades(policy_cls, sleeve, params=None, start_ms=None, end_ms=None):
"""Re-run the engine logic but COLLECT per-trade detail incl. MAE.
MAE = worst adverse excursion (in `ret` units == leverage*frac move on notional)
measured on the bars the trade is actually OPEN, from entry bar+1 up to and
INCLUDING the exit bar (the exit bar's wick counts: it's where SL would trigger).
"""
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 = EL.FEE_RT * LEV
last_exit = -1
out = []
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), EL.HARD_CAP)
fills = []
remaining = 1.0
j = i
worst = 0.0 # most negative excursion in ret units
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
# adverse excursion of THIS bar (before any exit): worst intrabar price
adverse = l[j] if d == 1 else h[j]
exc = (adverse - entry) / entry * d * LEV
worst = min(worst, exc)
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:
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:
fills.append((remaining, c[j]))
ret = sum(f * (p - entry) for f, p in fills) / entry * d * LEV - fee
last_exit = j
out.append({
"i": i, "j": j, "d": d, "entry": entry,
"ts_entry": ts[i], "ts_exit": ts[j],
"ret": ret, "mae": worst, "bars": j - i,
})
return out
def _pct(arr, q):
return np.percentile(arr, q) if len(arr) else float("nan")
# --------------------------------------------------------------------------- (1) MAE dist
def section1():
print("=" * 100)
print("(1) MAE DISTRIBUTION per sleeve (ret units = leverage*move on notional; "
"fee NOT included). Negative = adverse.")
print(" MAE is the worst intrabar excursion BEFORE exit. For base, SL caps it; "
"for no_sl/close_confirm it can run.")
print("=" * 100)
policies = _load_policies()
hdr = f"{'sleeve':<11}{'policy':<16}{'n':>5}{'p50':>9}{'p95':>9}{'p99':>9}{'max':>9}{'realP99':>10}{'realMax':>10}"
for (code, asset), sleeve in SLEEVES:
key = f"{code.split('_')[0]} {asset}"
print(f"\n--- {key}")
print(hdr)
for pname, (cls, prm) in policies.items():
tr = _replay_trades(cls, sleeve, prm)
mae = np.array([t["mae"] for t in tr]) * 100 # to %
rets = np.array([t["ret"] for t in tr]) * 100
print(f"{'':<11}{pname:<16}{len(tr):>5}"
f"{_pct(mae,50):>9.2f}{_pct(mae,5):>9.2f}{_pct(mae,1):>9.2f}{mae.min():>9.2f}"
f"{_pct(rets,1):>10.2f}{rets.min():>10.2f}")
def _load_policies():
"""Return {name: (cls, params)} for base, no_sl, close_confirm(buf0)."""
p22 = Path(__file__).resolve().parents[0] / "22_no_sl.py"
p16 = Path(__file__).resolve().parents[0] / "16_close_confirm_sl.py"
import importlib.util
def _load(path, attr):
spec = importlib.util.spec_from_file_location(path.stem, path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return getattr(mod, attr)
NoSl = _load(p22, "NoSl")
CloseConfirm = _load(p16, "CloseConfirmSl")
return {
"base": (ExitPolicy, {}),
"no_sl": (NoSl, {"mode": "none"}),
"close_confirm0": (CloseConfirm, {"buffer": 0.0}),
}
# --------------------------------------------------------------------------- (2) crashes
CRASHES = {
"2020-03-12 covid": ("2020-03-10", "2020-03-16"),
"2021-05-19 leverage": ("2021-05-17", "2021-05-23"),
"2022-06 3AC/Luna": ("2022-06-10", "2022-06-20"),
"2022-11 FTX": ("2022-11-07", "2022-11-12"),
}
def _ms(s):
return int(pd.Timestamp(s, tz="UTC").value // 1e6)
def section2():
print("\n" + "=" * 100)
print("(2) CRASH WINDOWS — trades OPEN (entry inside window) per policy: realized "
"loss (ret%) and MAE%.")
print("=" * 100)
policies = _load_policies()
for label, (a, b) in CRASHES.items():
lo, hi = _ms(a), _ms(b)
print(f"\n### {label} [{a} .. {b}]")
any_trade = False
for (code, asset), sleeve in SLEEVES:
key = f"{code.split('_')[0]} {asset}"
for pname, (cls, prm) in policies.items():
tr = _replay_trades(cls, sleeve, prm)
win = [t for t in tr if lo <= t["ts_entry"] <= hi]
if not win:
continue
any_trade = True
rets = np.array([t["ret"] for t in win]) * 100
mae = np.array([t["mae"] for t in win]) * 100
worst = min(win, key=lambda t: t["ret"])
print(f" {key:<11}{pname:<16}n{len(win):>3} "
f"sumRet{rets.sum():>8.2f}% worstRet{rets.min():>8.2f}% "
f"worstMAE{mae.min():>8.2f}% avgBars{np.mean([t['bars'] for t in win]):>5.1f}")
if not any_trade:
print(" (no trades opened in window across sleeves)")
# --------------------------------------------------------------------------- (3) -2% fallback
def section3():
print("\n" + "=" * 100)
print("(3) LIVE -2% FALLBACK on no_sl. NOTE: with no_sl the worker has NO stop at "
"all (branch analysis in module docstring).")
print(" Below = what a -2% PRICE stop (==-6% ret at lev3) WOULD cap if it WERE "
"wired. Compares no_sl ret vs a synthetic no_sl+2%stop.")
print("=" * 100)
NoSl = _load_policies()["no_sl"][0]
stop_ret = -0.02 * LEV # -2% price move * leverage = -6% on notional in ret units
hdr = f"{'sleeve':<11}{'no_sl ret%':>12}{'+2%stop ret%':>14}{'Δret pp':>10}{'n capped':>10}{'worst no_sl':>13}{'worst +2%':>11}"
print(hdr)
for (code, asset), sleeve in SLEEVES:
key = f"{code.split('_')[0]} {asset}"
tr = _replay_trades(NoSl, sleeve, {"mode": "none"})
# synthetic: if MAE breaches stop_ret, realize at stop_ret (approx; ignores
# that price may recover — that's the point: a -2% stop locks the loss).
base_rets = np.array([t["ret"] for t in tr])
capped = []
n_cap = 0
fee = EL.FEE_RT * LEV
for t in tr:
if t["mae"] <= stop_ret:
capped.append(stop_ret - fee) # stopped at -2% price, pay fee
n_cap += 1
else:
capped.append(t["ret"])
capped = np.array(capped)
def _compound(rets):
cap = 1000.0
for r in rets:
cap = max(cap + cap * EL.POS * r, 10.0)
return (cap / 1000.0 - 1) * 100
r_nosl = _compound(base_rets)
r_stop = _compound(capped)
print(f"{key:<11}{r_nosl:>12.0f}{r_stop:>14.0f}{r_stop - r_nosl:>10.1f}"
f"{n_cap:>10}{base_rets.min()*100:>13.2f}{capped.min()*100:>11.2f}")
# --------------------------------------------------------------------------- (4) disaster SL
class DisasterSl(ExitPolicy):
"""no_sl behaviour + a FAR intrabar disaster stop at `mult` x base SL distance."""
name = "disaster_sl"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
mult = float(params.get("mult", 3.0))
self.sl = entry + mult * (sl0 - entry)
def levels(self, j: int):
return self.tp0, self.sl, 1.0
def _full_metrics(cls, sleeve, prm):
full = simulate(cls, sleeve, prm)
oos = simulate(cls, sleeve, prm, start_ms=OOS_START_MS)
return full, oos
def section4():
print("\n" + "=" * 100)
print("(4) DISASTER SL — no_sl + far intrabar stop at 3x / 4x base SL distance. "
"Keep the gain, cut the tail?")
print("=" * 100)
NoSl = _load_policies()["no_sl"][0]
hdr = (f"{'sleeve':<11}{'policy':<13}"
f"{'FULLret%':>10}{'FULLdd%':>9}{'FULLsh':>8}"
f"{'OOSret%':>9}{'OOSdd%':>8}{'OOSsh':>7}{'worstMAE%':>11}{'nStop':>7}")
print(hdr)
for (code, asset), sleeve in SLEEVES:
key = f"{code.split('_')[0]} {asset}"
rows = [
("base", ExitPolicy, {}),
("no_sl", NoSl, {"mode": "none"}),
("disaster3x", DisasterSl, {"mult": 3.0}),
("disaster4x", DisasterSl, {"mult": 4.0}),
]
print(f"--- {key}")
for pname, cls, prm in rows:
full, oos = _full_metrics(cls, sleeve, prm)
tr = _replay_trades(cls, sleeve, prm)
mae = np.array([t["mae"] for t in tr]) * 100
# count trades that hit the disaster stop (ret near the stop level)
n_stop = 0
if pname.startswith("disaster"):
mult = prm["mult"]
for t, raw in zip(tr, sleeve["signals"]):
pass
# simpler: a stop hit shows up as a large negative ret roughly = stop level
n_stop = int(np.sum(mae <= -2.0 * mult * LEV / LEV * 0)) # placeholder
print(f"{'':<11}{pname:<13}"
f"{full.get('ret_pct',0):>10.0f}{full.get('dd_pct',0):>9.2f}{full.get('sharpe_t',0):>8.2f}"
f"{oos.get('ret_pct',0):>9.0f}{oos.get('dd_pct',0):>8.2f}{oos.get('sharpe_t',0):>7.2f}"
f"{mae.min():>11.2f}{'':>7}")
# --------------------------------------------------------------------------- aggregate
def section5_aggregate():
"""Equal-weight aggregate across the 6 sleeves: does no_sl's tail blow up the
PORTFOLIO worst-trade vs disaster3x? Sum of per-sleeve compounded won't aggregate
DD honestly, so we report the WORST single-trade ret across all sleeves and the
99th pct of the pooled trade distribution."""
print("\n" + "=" * 100)
print("(5) POOLED TRADE DISTRIBUTION across all 6 sleeves (the real tail metric).")
print("=" * 100)
NoSl = _load_policies()["no_sl"][0]
cfgs = [
("base", ExitPolicy, {}),
("no_sl", NoSl, {"mode": "none"}),
("close_confirm0", _load_policies()["close_confirm0"][0], {"buffer": 0.0}),
("disaster3x", DisasterSl, {"mult": 3.0}),
("disaster4x", DisasterSl, {"mult": 4.0}),
]
print(f"{'policy':<16}{'n':>6}{'retP1%':>9}{'retMin%':>9}{'maeP1%':>9}{'maeMin%':>9}{'<-15%cnt':>10}")
for pname, cls, prm in cfgs:
allret, allmae = [], []
for (code, asset), sleeve in SLEEVES:
tr = _replay_trades(cls, sleeve, prm)
allret += [t["ret"] * 100 for t in tr]
allmae += [t["mae"] * 100 for t in tr]
ar = np.array(allret); am = np.array(allmae)
n_bad = int(np.sum(ar < -15.0))
print(f"{pname:<16}{len(ar):>6}{_pct(ar,1):>9.2f}{ar.min():>9.2f}"
f"{_pct(am,1):>9.2f}{am.min():>9.2f}{n_bad:>10}")
if __name__ == "__main__":
section1()
section2()
section3()
section4()
section5_aggregate()