"""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()