feat(surfer): tail-managed VRP — real second sleeve (with honest haircut)
'Don't sell vol when implied vol rising' (point-in-time, no lookahead verified) takes crypto short-vol baseline +1.03 (skew -8.3, 2025 -0.26/2026 -0.77) to combo +2.89, ALL years positive (2026 +2.26), worst -21sigma->-17sigma. Effect robust across gate variants (rising/level/combo/loss_cap all fix 2025-26) = real economic effect (implied leads realized), not one lucky rule. Confirms the -21sigma tail + recent decay were the same mis-timing. Diversifier: corr -0.04, combined +2.57. HONEST HAIRCUT: +2.9 optimistic (gate-selection best-of-5 + no-Greeks proxy idealization + residual -15sigma tail + 5y sample) -> realistic ~1.0-1.5; but corr+edge robust -> combined ~1.37 > momentum 0.66. Real second sleeve, size SMALL (tail reduced not gone). Best diversifier found. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
118
scripts/surfer/vrp_tail.py
Normal file
118
scripts/surfer/vrp_tail.py
Normal file
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tail-managed crypto VRP — does 'don't sell vol into a storm' fix the −21σ skew AND the 2025-26 decay?
|
||||
|
||||
Pre-registered tail rules (point-in-time, gates use DVOL/return info up to t-1 only):
|
||||
baseline — full short-vol always (the +1.03 / -21σ version)
|
||||
dvol_rising — flat when implied vol is rising over 5d (don't sell into a spike)
|
||||
dvol_level — full size when DVOL < trailing-60d median (calm), half when elevated
|
||||
combo — dvol_level AND not rising
|
||||
loss_cap — idealized: truncate each day's loss at -3σ (stop/protection reference)
|
||||
vol_target — scale exposure to constant risk (inverse trailing P&L vol)
|
||||
Judge: Sharpe, per-year (esp 2025-26), skew, worst-day. Best -> diversifier check vs momentum.
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import pit_sweep # noqa: E402
|
||||
from surfer_poc import compute_weights, CFG # noqa: E402
|
||||
from signal_sweep import sharpe_t # noqa: E402
|
||||
from vrp_diversifier import dvol # noqa: E402 (cached DVOL fetch)
|
||||
import torch # noqa: E402
|
||||
|
||||
DEV = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
|
||||
|
||||
def per_ccy(px, dv):
|
||||
days = np.array(sorted(set(px) & set(dv)))
|
||||
iv = np.array([dv[int(d)] / 100.0 for d in days])
|
||||
r = np.full(len(days), np.nan)
|
||||
for i in range(1, len(days)):
|
||||
if px[int(days[i])] > 0 and px[int(days[i - 1])] > 0:
|
||||
r[i] = math.log(px[int(days[i])] / px[int(days[i - 1])])
|
||||
raw = np.full(len(days), np.nan)
|
||||
raw[1:] = (iv[:-1] ** 2) / 365.0 - r[1:] ** 2 # short-var daily P&L (iv lagged)
|
||||
return days, iv, r, raw
|
||||
|
||||
|
||||
def gates(iv, raw):
|
||||
n = len(iv)
|
||||
rising = np.zeros(n) # 1 if NOT rising (ok to sell)
|
||||
for i in range(1, n):
|
||||
rising[i] = 0.0 if (i >= 6 and iv[i - 1] > iv[i - 6]) else 1.0
|
||||
level = np.zeros(n) # 1 calm, 0.5 elevated (vs trailing median)
|
||||
for i in range(1, n):
|
||||
win = iv[max(0, i - 61):i - 1]
|
||||
level[i] = 1.0 if (len(win) > 10 and iv[i - 1] < np.median(win)) else 0.5
|
||||
vt = np.ones(n) # vol-target on raw P&L
|
||||
for i in range(31, n):
|
||||
s = np.nanstd(raw[i - 30:i])
|
||||
vt[i] = min(1.0, (np.nanstd(raw[~np.isnan(raw)]) / s)) if s > 0 else 1.0
|
||||
return {"baseline": np.ones(n), "dvol_rising": rising, "dvol_level": level,
|
||||
"combo": rising * level, "vol_target": vt}
|
||||
|
||||
|
||||
def main():
|
||||
syms, cdays, cclose, cqv, cfund = pit_sweep.load()
|
||||
idx = {s: j for j, s in enumerate(syms)}
|
||||
|
||||
def closes(sym):
|
||||
j = idx[sym]; c = cclose[:, j]
|
||||
return {int(cdays[t]): c[t] for t in range(len(cdays)) if np.isfinite(c[t])}
|
||||
series = {c: per_ccy(closes(c + "USDT"), dvol(c)) for c in ["BTC", "ETH"]}
|
||||
|
||||
# build per-rule pooled daily P&L across BTC+ETH (aligned by day)
|
||||
def rule_pnl(rule, cap=False):
|
||||
bag = {}
|
||||
for c, (days, iv, r, raw) in series.items():
|
||||
g = gates(iv, raw)[rule]
|
||||
p = g * raw
|
||||
if cap:
|
||||
sd = np.nanstd(raw)
|
||||
p = np.maximum(p, -3 * sd)
|
||||
for i in range(len(days)):
|
||||
bag.setdefault(int(days[i]), []).append(p[i])
|
||||
dd = np.array(sorted(bag))
|
||||
return dd, np.array([np.nanmean(bag[int(d)]) for d in dd])
|
||||
|
||||
rules = ["baseline", "dvol_rising", "dvol_level", "combo", "vol_target"]
|
||||
print("\n===== TAIL-MANAGED CRYPTO VRP =====")
|
||||
print(f"{'rule':>14} {'Sharpe':>7} {'skew':>6} {'worstσ':>7} | per-year 2021..26")
|
||||
results = {}
|
||||
for rule in rules + ["loss_cap"]:
|
||||
dd, p = rule_pnl("baseline" if rule == "loss_cap" else rule, cap=(rule == "loss_cap"))
|
||||
pf = p[np.isfinite(p)]
|
||||
sr = sharpe_t(torch.tensor(p, device=DEV, dtype=torch.float64))
|
||||
sk = float(((pf - pf.mean()) ** 3).mean() / (pf.std() ** 3 + 1e-12))
|
||||
worst = float(np.nanmin(p) / (np.nanstd(p) + 1e-12))
|
||||
yr = (1970 + dd / 365.25).astype(int)
|
||||
py = " ".join(f"{y}:{sharpe_t(torch.tensor(p[yr==y],device=DEV,dtype=torch.float64)):+.2f}" for y in range(2021, 2027) if (yr == y).sum() > 30)
|
||||
print(f"{rule:>14} {sr:>+7.2f} {sk:>+6.2f} {worst:>+7.1f} | {py}")
|
||||
results[rule] = (dd, p, sr, sk)
|
||||
|
||||
# diversifier check for combo (the principled don't-sell-into-storm rule)
|
||||
dd, p, _, _ = results["combo"]
|
||||
cR = np.zeros_like(cclose); cR[1:] = np.log(cclose)[1:] - np.log(cclose)[:-1]; cR = np.where(np.isfinite(cR), cR, 0.0)
|
||||
cf = np.where(np.isfinite(cfund), cfund, 0.0)
|
||||
cw, _ = compute_weights(cclose, cqv, cdays, CFG)
|
||||
mom = np.sum(cw[:-1] * (cR - cf)[1:], axis=1)
|
||||
mbd = {int(cdays[1:][i]): mom[i] for i in range(len(mom))}
|
||||
common = sorted(set(dd.tolist()) & set(mbd))
|
||||
va = np.array([p[np.where(dd == d)[0][0]] for d in common]); ma = np.array([mbd[d] for d in common])
|
||||
m = np.isfinite(va) & np.isfinite(ma)
|
||||
corr = float(np.corrcoef(va[m], ma[m])[0, 1])
|
||||
sv, sm = np.std(va[m]), np.std(ma[m])
|
||||
comb = ((1 / sv) * va[m] + (1 / sm) * ma[m]) / (1 / sv + 1 / sm)
|
||||
cyr = (1970 + np.array(common) / 365.25).astype(int)[m]
|
||||
print(f"\n===== DIVERSIFIER CHECK (combo tail-managed VRP vs momentum, {m.sum()}d) =====")
|
||||
print(f"VRP {sharpe_t(torch.tensor(va[m],device=DEV,dtype=torch.float64)):+.2f} momentum {sharpe_t(torch.tensor(ma[m],device=DEV,dtype=torch.float64)):+.2f} CORR {corr:+.2f} combined {sharpe_t(torch.tensor(comb,device=DEV,dtype=torch.float64)):+.2f}")
|
||||
print("combined per-year: " + " ".join(f"{y}:{sharpe_t(torch.tensor(comb[cyr==y],device=DEV,dtype=torch.float64)):+.2f}" for y in range(2021, 2027) if (cyr == y).sum() > 30))
|
||||
print("\nVERDICT: tail rule restores 2025-26 to positive + skew less extreme + combined>momentum = deployable diversifier.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user