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:
@@ -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()
|
||||
Reference in New Issue
Block a user