26f8d27a61
Goal: migliorare la strategia short-vol (famiglia Albimarini/VRP01) e proteggerla dai DD. Workflow 26 agenti (7 filoni + 2 lenti avversariali + scettico incrociato). Esito: NON migliorabile; la protezione DD si compra SOLO con la size. - Griglia 288 strutture: nessuna batte VRP01 (DSR 0.000; meta' griglia = 3a occorrenza "0-perdite/Sharpe implausibile" dopo CC01/ALB-A). - 4 overlay DD (exit-spike/SL-MTM/ala-coda/cooldown): 4/4 REFUTED dal null de-levering — la protezione crash vive gia' nel gate IV-rank. - Gate nuovi: 4o fallimento su 4 (l'alpha e' il binario IV-rank>0.30). - Sizing: 12% deploy ~ 0.27 Kelly onesto (anti-rovina); disambiguazione unita' obbligatoria vs 12% peso book (0.014 Kelly, fattore 19x). - Gate term-structure VIX/VXV su SPX (dSh +0.90, DSR 0.992) = confound di modello al 100% -> nuova regola: riprezzare term-structure-consistent prima di credere a un gate vol su strutture BS-flat. - ANCHOR-AUDIT VRP01 CHIUSO: primo sleeve SENZA luck (fase canonica = peggiore delle 7 -> numeri di ammissione conservativi). Audit anchor ora completo su 4/4 sleeve ancorati. Book/pesi INVARIATI. Nessun nuovo sleeve. 168 test verdi. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
324 lines
16 KiB
Python
324 lines
16 KiB
Python
"""R0703 VRPIMP-DDOVERLAY — overlay di protezione DD su VRP01 canonico vs NULL DEL DE-LEVERING.
|
|
|
|
FILONE 2 (ondata 2026-07-03): su VRP01 canonico (put credit spread settimanale -0.28/-0.10,
|
|
gate vrp>0 + ivr>=0.30 + crash-skip 0.90, riprodotto ESATTO dal motore path qui sotto e
|
|
bridge-validato vs options_vrp_v2.vrp_spread_weekly) si testano 4 famiglie di overlay:
|
|
|
|
(a) EXIT INTRA-SETTIMANA su spike: DVOL sale >X% dall'ingresso, oppure RV5 causale supera
|
|
m x DVOL d'ingresso -> chiudi lo spread al close del giorno del trigger (MTM BS).
|
|
(b) STOP-LOSS sul MTM: esci se il valore dello spread raggiunge mult x credito incassato.
|
|
(c) ALA DI CODA comprata: q put molto piu' OTM (delta -0.05/-0.03) finanziate col credito
|
|
(ratio/broken-wing-ish), tenute a scadenza.
|
|
(d) COOLDOWN: salta c settimane di calendario dopo una settimana attiva in perdita.
|
|
|
|
REGOLA VINCOLANTE (lezione TP01xDVOL 2026-06-26): ogni claim di riduzione DD si confronta col
|
|
NULL DEL DE-LEVERING — lo scaling s della size del baseline che produce lo STESSO maxDD. Lo
|
|
Sharpe e' scale-invariante => il de-levered ha SEMPRE lo Sharpe del baseline: un overlay "vale"
|
|
solo se il suo Sharpe batte quello del baseline A PARI maxDD (e regge banda f + multi-cut + DSR).
|
|
|
|
⚠️ LENS DICHIARATA (in testa, non in fondo):
|
|
- Granularita' 1d: i trigger (a)/(b) si valutano al CLOSE giornaliero e si esegue a QUEL close
|
|
-> il gap-through oltre il livello di stop E' DENTRO il fill (onesto), ma lo spike intraday
|
|
peggiore del close NON e' modellato (lezione SKH01: a 1d lo stop e' una lente, non un fill).
|
|
- MTM Black-Scholes FLAT sulla DVOL-30g usata per tenor <=7g: in stress il front-end esplode
|
|
piu' della 30g e lo skew irripidisce -> il MTM avverso e' SOTTOSTIMATO => gli stop scattano
|
|
piu' tardi e costano piu' del modellato. Percio' banda f {0.6,0.8,1.0,1.3} obbligatoria.
|
|
- Booking: il pnl di ogni trade (anche se chiuso prima) e' registrato alla data di SCADENZA
|
|
-> griglia settimanale identica al baseline, Sharpe confrontabile (lente, non timing reale).
|
|
- Equity settimanale compounding (convenzione VRP01): DD/worst-week su quella lente.
|
|
- Banda d'ancora (regola 2026-07-02): loop settimanale ancorato a i0=60 -> 7 offset riportati.
|
|
|
|
Regola standing: niente short-vol da modello in deploy — obiettivo = conoscenza/LEAD.
|
|
Selezione celle SOLO in-sample (pre-2025); hold-out 2025-26 mai usato per scegliere; DSR sul
|
|
numero totale di celle (14).
|
|
|
|
uv run python scripts/research/r0703_vrpimp_ddoverlay.py
|
|
"""
|
|
from __future__ import annotations
|
|
import sys
|
|
from pathlib import Path
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
sys.path.insert(0, str(PROJECT_ROOT))
|
|
sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research" / "alt"))
|
|
import numpy as np, pandas as pd
|
|
from scripts.research.options_vrp_lab import bs_put, strike_from_delta, load_series, m_weekly, per_year
|
|
from scripts.research.options_vrp_v2 import vrp_spread_weekly, _ivrank, _rv30
|
|
|
|
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
|
|
WK = 365.25 / 7.0
|
|
F_SWEEP = (0.6, 0.8, 1.0, 1.3)
|
|
FEE_FRAC = 0.125 # convenzione VRP01: 12.5% del premio netto per il round-trip base
|
|
TENOR = 7
|
|
|
|
|
|
def _rv5(px: np.ndarray, j: int) -> float:
|
|
"""RV annualizzata causale dagli ultimi 5 rendimenti giornalieri (fino a j incluso)."""
|
|
if j < 6:
|
|
return np.nan
|
|
r = np.diff(np.log(px[j - 5:j + 1]))
|
|
return float(np.std(r) * np.sqrt(365.25))
|
|
|
|
|
|
def vrp_path_weekly(asset, f=1.0, overlay=None, i0=60, collect=None):
|
|
"""VRP01 canonico con path MTM giornaliero. overlay=None riproduce vrp_spread_weekly
|
|
(defined_risk, gate vrp>0 + ivr>=0.30 + crash_skip 0.90) ESATTO (bridge sotto).
|
|
|
|
overlay: ('spike_dvol', x) | ('spike_rv', m) | ('sl', mult) | ('tail', delta, q) | ('cool', c)
|
|
Ritorna Series settimanale di rendimenti su capitale = strike corto (convenzione VRP01).
|
|
"""
|
|
J = load_series(asset)
|
|
px = J["px"].values; dv = J["dvol"].values / 100.0; idx = J.index
|
|
n = len(px); T = TENOR / 365.25
|
|
kind = overlay[0] if overlay else None
|
|
rets = {}
|
|
cool_left = 0
|
|
i = i0
|
|
while i + TENOR < n:
|
|
S0 = px[i]; sig = dv[i]
|
|
# --- cooldown (d): decisione causale dal risultato del trade precedente gia' chiuso ---
|
|
if kind == "cool" and cool_left > 0:
|
|
cool_left -= 1
|
|
rets[idx[i + TENOR]] = 0.0
|
|
i += TENOR
|
|
continue
|
|
# --- gate canonici VRP01 (identici a vrp_spread_weekly, NON riottimizzati) ---
|
|
skip = False
|
|
rv = _rv30(px, i)
|
|
if not np.isnan(rv) and (sig - rv) <= 0:
|
|
skip = True
|
|
ivr = _ivrank(dv, i)
|
|
if not np.isnan(ivr) and (ivr < 0.30 or ivr > 0.90):
|
|
skip = True
|
|
if skip:
|
|
rets[idx[i + TENOR]] = 0.0
|
|
i += TENOR
|
|
continue
|
|
# --- struttura canonica ---
|
|
Ks = strike_from_delta(S0, T, sig, -0.28)
|
|
Kl = strike_from_delta(S0, T, sig, -0.10)
|
|
net_prem = (bs_put(S0, Ks, T, sig) - bs_put(S0, Kl, T, sig)) * f
|
|
base_fee = FEE_FRAC * abs(net_prem)
|
|
# --- ala di coda (c): q put extra-OTM a stessa scadenza, pagata col credito ---
|
|
tail_cost = tail_fee = 0.0; K_tail = None; q = 0
|
|
if kind == "tail":
|
|
_, dlt, q = overlay
|
|
K_tail = strike_from_delta(S0, T, sig, dlt)
|
|
prem_t = bs_put(S0, K_tail, T, sig) * f
|
|
tail_cost = q * prem_t
|
|
tail_fee = q * min(0.0003 * S0, 0.125 * max(prem_t, 1e-12))
|
|
if collect is not None:
|
|
collect.setdefault("tail_frac", []).append(tail_cost / max(net_prem, 1e-12))
|
|
# --- path MTM giornaliero: exit intra-settimana (a)/(b) al primo trigger, a QUEL close ---
|
|
exited = False
|
|
if kind in ("spike_dvol", "spike_rv", "sl"):
|
|
for j in range(i + 1, i + TENOR):
|
|
T_rem = (i + TENOR - j) / 365.25
|
|
trig = False
|
|
if kind == "spike_dvol":
|
|
trig = (dv[j] / sig - 1.0) > overlay[1]
|
|
elif kind == "spike_rv":
|
|
r5 = _rv5(px, j)
|
|
trig = (not np.isnan(r5)) and (r5 > overlay[1] * sig)
|
|
elif kind == "sl":
|
|
Vj = (bs_put(px[j], Ks, T_rem, dv[j]) - bs_put(px[j], Kl, T_rem, dv[j])) * f
|
|
trig = Vj >= overlay[1] * net_prem
|
|
if trig:
|
|
Vj = (bs_put(px[j], Ks, T_rem, dv[j]) - bs_put(px[j], Kl, T_rem, dv[j])) * f
|
|
exit_fee = min(0.0006 * px[j], 0.125 * max(Vj, 1e-12)) # 2 gambe buyback
|
|
pnl = net_prem - Vj - base_fee - exit_fee
|
|
rets[idx[i + TENOR]] = pnl / Ks # booking a scadenza (lens dichiarata)
|
|
if collect is not None:
|
|
collect.setdefault("exits", []).append((idx[j], (net_prem - Vj) / Ks))
|
|
exited = True
|
|
break
|
|
if not exited:
|
|
S1 = px[i + TENOR]
|
|
payoff = max(0.0, Ks - S1) - max(0.0, Kl - S1)
|
|
pnl = net_prem - payoff - base_fee
|
|
if kind == "tail":
|
|
pnl += q * max(0.0, K_tail - S1) - tail_cost - tail_fee
|
|
rets[idx[i + TENOR]] = pnl / Ks
|
|
if kind == "cool" and rets[idx[i + TENOR]] < 0:
|
|
cool_left = overlay[1]
|
|
i += TENOR
|
|
return pd.Series(rets)
|
|
|
|
|
|
def book(f=1.0, overlay=None, i0=60, collect=None):
|
|
rB = vrp_path_weekly("BTC", f=f, overlay=overlay, i0=i0, collect=collect)
|
|
rE = vrp_path_weekly("ETH", f=f, overlay=overlay, i0=i0, collect=collect)
|
|
return pd.concat({"B": rB, "E": rE}, axis=1, join="inner").mean(axis=1)
|
|
|
|
|
|
def maxdd(r):
|
|
eq = np.cumprod(1 + r.values); pk = np.maximum.accumulate(eq)
|
|
return float(np.max((pk - eq) / pk)) if len(r) else 0.0
|
|
|
|
|
|
def delever_scale(r_base, dd_target, hi=5.0):
|
|
"""Scaling s della size del baseline che produce maxDD == dd_target (bisezione)."""
|
|
lo = 0.0
|
|
for _ in range(80):
|
|
mid = (lo + hi) / 2
|
|
if maxdd(mid * r_base) > dd_target:
|
|
hi = mid
|
|
else:
|
|
lo = mid
|
|
return (lo + hi) / 2
|
|
|
|
|
|
def mstats(r):
|
|
full = m_weekly(r); ho = m_weekly(r[r.index >= HOLDOUT]); isam = m_weekly(r[r.index < HOLDOUT])
|
|
return dict(sh=full["sh"], sh_h=ho["sh"], sh_is=isam["sh"], cagr=full["cagr"],
|
|
dd=full["dd"], worst=float(r.min()) if len(r) else 0.0)
|
|
|
|
|
|
def fmt(name, m, extra=""):
|
|
print(f" {name:<34} ShF {m['sh']:>5.2f} ShH {m['sh_h']:>5.2f} Sh-IS {m['sh_is']:>5.2f} "
|
|
f"CAGR {m['cagr']*100:>+5.1f}% DD {m['dd']*100:>4.1f}% worst {m['worst']*100:>+5.1f}%{extra}")
|
|
|
|
|
|
OVERLAYS = {
|
|
"a-spike": [("spike_dvol", 0.15), ("spike_dvol", 0.25), ("spike_dvol", 0.40),
|
|
("spike_rv", 1.00), ("spike_rv", 1.25)],
|
|
"b-stoploss": [("sl", 1.5), ("sl", 2.0), ("sl", 3.0)],
|
|
"c-tailwing": [("tail", -0.05, 1), ("tail", -0.05, 2), ("tail", -0.03, 1), ("tail", -0.03, 2)],
|
|
"d-cooldown": [("cool", 1), ("cool", 2)],
|
|
}
|
|
|
|
|
|
def olabel(ov):
|
|
k = ov[0]
|
|
if k == "spike_dvol":
|
|
return f"exit DVOL +{ov[1]*100:.0f}% da entry"
|
|
if k == "spike_rv":
|
|
return f"exit RV5 > {ov[1]:.2f}x DVOL entry"
|
|
if k == "sl":
|
|
return f"stop-loss MTM {ov[1]:.1f}x credito"
|
|
if k == "tail":
|
|
return f"tail put d={ov[1]:+.2f} q={ov[2]}"
|
|
return f"cooldown {ov[1]} settimana/e"
|
|
|
|
|
|
def main():
|
|
print("=" * 112)
|
|
print(" R0703 VRPIMP-DDOVERLAY — overlay di protezione DD su VRP01 vs NULL DEL DE-LEVERING")
|
|
print(" Lens: MTM BS flat su DVOL-30g, trigger e fill al close 1d (gap-through nel fill, spike")
|
|
print(" intraday e term-structure di stress NON modellati -> stop piu' lenti/cari del reale).")
|
|
print(" Booking a scadenza su griglia settimanale. Selezione celle SOLO in-sample (pre-2025).")
|
|
print("=" * 112)
|
|
for a in ("BTC", "ETH"):
|
|
J = load_series(a)
|
|
print(f" {a}: dati {J.index[0].date()} -> {J.index[-1].date()} ({len(J)} giorni)")
|
|
print(" Eventi chiave nel campione: 2021-05 (crash Cina), 2022-05 (LUNA), 2022-11 (FTX).")
|
|
|
|
# ------------------------------------------------------------- (0) bridge di validazione
|
|
print("\n (0) BRIDGE: motore path (overlay=None) vs options_vrp_v2.vrp_spread_weekly (VRP01)")
|
|
base = {}
|
|
for f in F_SWEEP:
|
|
canon = pd.concat({"B": vrp_spread_weekly("BTC", defined_risk=True, f=f, gate_vrp=True,
|
|
gate_ivr=0.30, crash_skip=0.90),
|
|
"E": vrp_spread_weekly("ETH", defined_risk=True, f=f, gate_vrp=True,
|
|
gate_ivr=0.30, crash_skip=0.90)},
|
|
axis=1, join="inner").mean(axis=1)
|
|
mine = book(f=f)
|
|
Jn = pd.concat({"c": canon, "m": mine}, axis=1, join="inner")
|
|
dmax = float((Jn["c"] - Jn["m"]).abs().max())
|
|
base[f] = mine
|
|
if f == 1.0:
|
|
print(f" f={f}: max|diff| = {dmax:.2e} su {len(Jn)} settimane "
|
|
f"{'OK (riproduzione esatta)' if dmax < 1e-12 else '⚠️ MISMATCH'}")
|
|
|
|
print("\n (1) BASELINE VRP01 canonico — banda f")
|
|
for f in F_SWEEP:
|
|
fmt(f"VRP01 f={f}", mstats(base[f]))
|
|
pyb = per_year(base[1.0])
|
|
print(" per-anno f=1.0: " + " ".join(f"{y}:{v*100:+5.1f}%" for y, v in sorted(pyb.items())))
|
|
anchor_sh = [m_weekly(book(f=1.0, i0=60 + o))["sh"] for o in range(7)]
|
|
print(f" banda d'ancora (7 offset del loop settimanale): ShF in "
|
|
f"[{min(anchor_sh):.2f}, {max(anchor_sh):.2f}] mediana {np.median(anchor_sh):.2f}")
|
|
|
|
# ------------------------------------------------------------- (2) griglia overlay f=1.0
|
|
print(f"\n (2) GRIGLIA OVERLAY (f=1.0) — 14 celle, selezione per-famiglia su Sh-IS (pre-2025)")
|
|
m0 = mstats(base[1.0])
|
|
all_full_sh = [m0["sh"]]
|
|
cells = {}
|
|
best = {}
|
|
for fam, ovs in OVERLAYS.items():
|
|
for ov in ovs:
|
|
col = {}
|
|
r = book(f=1.0, overlay=ov, collect=col)
|
|
m = mstats(r)
|
|
cells[ov] = (r, m, col)
|
|
all_full_sh.append(m["sh"])
|
|
n_ex = len(col.get("exits", []))
|
|
extra = f" dSh {m['sh']-m0['sh']:+.2f}"
|
|
if n_ex:
|
|
extra += f" early-exit n={n_ex}"
|
|
if col.get("tail_frac"):
|
|
extra += f" costo ala = {np.mean(col['tail_frac'])*100:.0f}% del credito"
|
|
fmt(f"[{fam}] {olabel(ov)}", m, extra)
|
|
if fam not in best or m["sh_is"] > cells[best[fam]][1]["sh_is"]:
|
|
best[fam] = ov
|
|
print()
|
|
|
|
# ------------------------------------------------------------- (3) best-IS per famiglia: banda f + NULL de-levering
|
|
print(" (3) CELLA BEST-IN-SAMPLE PER FAMIGLIA — banda f + CONFRONTO OBBLIGATORIO col null de-levering")
|
|
print(" (de-lever = s x baseline con lo STESSO maxDD; lo Sharpe del de-levered = Sharpe baseline,")
|
|
print(" scale-invariante: l'overlay vale SOLO se ShF_overlay > ShF_base a pari DD)")
|
|
import altlib as al
|
|
verdicts = {}
|
|
for fam, ov in best.items():
|
|
print(f"\n --- {fam}: {olabel(ov)} ---")
|
|
print(f" {'f':>4} | {'overlay: ShF':>12} {'ShH':>6} {'DD':>6} {'worst':>7} | "
|
|
f"{'de-lever s':>10} {'ShF':>6} {'CAGR':>7} {'worst':>7} | {'dSh a pari DD':>13}")
|
|
wins = 0
|
|
for f in F_SWEEP:
|
|
r = book(f=f, overlay=ov) if f != 1.0 else cells[ov][0]
|
|
m = mstats(r); mb = mstats(base[f])
|
|
s = delever_scale(base[f], m["dd"])
|
|
rd = s * base[f]; md = mstats(rd)
|
|
dsh = m["sh"] - md["sh"]
|
|
wins += dsh > 0.05
|
|
print(f" {f:>4} | {m['sh']:>12.2f} {m['sh_h']:>6.2f} {m['dd']*100:>5.1f}% {m['worst']*100:>+6.1f}% | "
|
|
f"{s:>10.2f} {md['sh']:>6.2f} {md['cagr']*100:>+6.1f}% {md['worst']*100:>+6.1f}% | {dsh:>+13.2f}")
|
|
# multi-cut: dSh per finestra annuale disgiunta (f=1.0) — persistenza, non finestra fortunata
|
|
r1 = cells[ov][0]
|
|
dcut = {}
|
|
for y in (2021, 2022, 2023, 2024, 2025):
|
|
sel = r1.index.year == y if y < 2025 else r1.index >= HOLDOUT
|
|
selb = base[1.0].index.year == y if y < 2025 else base[1.0].index >= HOLDOUT
|
|
dcut[y] = m_weekly(r1[sel])["sh"] - m_weekly(base[1.0][selb])["sh"]
|
|
npos = sum(v > 0 for y, v in dcut.items() if y < 2025)
|
|
print(" multi-cut dSh: " + " ".join(f"{y}:{v:+.2f}" for y, v in dcut.items())
|
|
+ f" ({npos}/4 finestre IS positive)")
|
|
dsr, null_max = al.deflated_sharpe(cells[ov][1]["sh"], all_full_sh,
|
|
cells[ov][0].values, dpy=WK)
|
|
print(f" deflated-Sharpe (14 celle + base): DSR {dsr:.3f} (null-max {null_max:.2f}) "
|
|
f"{'PASS' if dsr >= 0.95 else 'FAIL'}")
|
|
aband = [m_weekly(book(f=1.0, overlay=ov, i0=60 + o))["sh"]
|
|
- m_weekly(book(f=1.0, i0=60 + o))["sh"] for o in range(7)]
|
|
print(f" banda d'ancora dSh (7 offset): [{min(aband):+.2f}, {max(aband):+.2f}] "
|
|
f"mediana {np.median(aband):+.2f}")
|
|
survives = (wins == len(F_SWEEP)) and npos >= 3 and dsr >= 0.95 and np.median(aband) > 0
|
|
verdicts[fam] = dict(ov=ov, survives=survives, wins_f=int(wins), multicut=dcut,
|
|
dsr=float(dsr), anchor=(min(aband), max(aband)))
|
|
print(f" => {'SOPRAVVIVE al null de-levering' if survives else 'NON batte il de-levering'} "
|
|
f"(pari-DD win {wins}/4 f, multicut {npos}/4, DSR {dsr:.2f})")
|
|
|
|
# ------------------------------------------------------------- (4) per-anno
|
|
print("\n (4) PER-ANNO (f=1.0) — 2021 (crash 5/2021) e 2022 (LUNA+FTX) sono il banco di prova")
|
|
rows = [("VRP01 base", base[1.0])] + [(f"{fam} best", cells[best[fam]][0]) for fam in OVERLAYS]
|
|
for tag, r in rows:
|
|
py = per_year(r)
|
|
print(f" {tag:<14} " + " ".join(f"{y}:{v*100:+5.1f}%" for y, v in sorted(py.items())))
|
|
|
|
print("\n VERDETTO: " + "; ".join(
|
|
f"{fam} {'PASS' if v['survives'] else 'FAIL'}" for fam, v in verdicts.items()))
|
|
print(" Se tutti FAIL: la protezione DD su VRP01 si compra meglio con la SIZE (de-levering),")
|
|
print(" coerente con TP01xDVOL 2026-06-26. Regola standing: niente short-vol da modello in deploy.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|