ad65a0b344
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>
336 lines
14 KiB
Python
336 lines
14 KiB
Python
"""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()
|