diff --git a/docs/diary/2026-06-19-options-vrp-lab.md b/docs/diary/2026-06-19-options-vrp-lab.md new file mode 100644 index 0000000..e97f2fb --- /dev/null +++ b/docs/diary/2026-06-19-options-vrp-lab.md @@ -0,0 +1,60 @@ +# 2026-06-19 — Options VRP sleeve: infrastruttura + prima validazione onesta + +Impostata la ricerca dello sleeve income opzioni (vendita put settimanali, incassa il volatility +risk premium IV>RV). Lead identificato dalla valutazione di `crypto_backtest` come la via per +superare il soffitto Sharpe ~1.3 (fonte di rendimento DIVERSA, scorrelata al trend). + +## Infrastruttura costruita +- `scripts/research/fetch_dvol.py`: storia DVOL (IV 30d Deribit) BTC/ETH **2021-03 → 2026-06** + (1914g) → `data/raw/dvol_*.parquet`. È l'input IV. +- `scripts/research/options_vrp_lab.py`: motore backtest CSP settimanale. Prezzo put BS su DVOL + reale + **calibrazione f** (skew/spread vs quote reali), strike a delta target, payoff sul path + realizzato dei prezzi certificati. Causale (decisione a sell-date, payoff a scadenza). Gauntlet: + VRP context, sweep f/delta, per-anno, worst-weeks (coda), correlazione + contributo vs TP01. +- `scripts/research/options_real_quote_check.py` (dal branch): verifica premio su quote reali. + +## VRP reale (contesto) +BTC DVOL 61% vs RV 53% → **VRP +7.8 pt, positivo 78% del tempo**; ETH +3.7 pt, 67%. Il premio di +volatilità esiste ed è più ricco su BTC. + +## Risultati (book 50/50 BTC+ETH, put settimanali delta -0.28) + +**Tutto dipende dalla CALIBRAZIONE f del premio:** +| f | Sharpe | CAGR | maxDD | worst-week | +|---|---|---|---|---| +| 0.70 | −0.32 | −12% | 51% | −26% | +| 0.85 | 0.20 | +1% | 35% | −26% | +| **1.00 (conservativo, IV-ATM)** | **0.71** | +16% | 33% | −26% | +| 1.15 | 1.22 | +34% | 32% | −25% | +| **1.29 (reale calm, con skew)** | **1.70** | +52% | 31% | −25% | + +- A f=1.0 (ignora il bonus skew): Sharpe **0.71** — SOTTO TP01. A f=1.29 (skew reale misurato in + regime calmo): **1.70**. La verità sta in mezzo E f varia col regime (skew più alto nello stress). +- **Delta**: più ATM = più premio + più rischio (−0.15→Sh 0.25, −0.28→0.71, −0.40→0.95). + +**La CODA è severa (è short-vol):** maxDD standalone **30-33%**, singole settimane **−15..−26%** +(2021-05 crash, 2022-05/06 LUNA, 2026-02/06). Per-anno (f=1.0): 2022 **−9%**, 2026-YTD **−14%** — +sanguina negli anni di crash. HOLD-OUT 2025-26: Sharpe **0.04** a f=1.0 (piatto), 0.94 a f=1.29. + +**Diversificazione (reale):** corr settimanale a TP01 **+0.07** (scorrelato). Contributo (f=1.0): +TP01 70% + OPT 30% → Sharpe settimanale 0.71→**0.97**, DD basso (11%). Anche al premio conservativo +migliora il portafoglio per pura decorrelazione. + +## Verdetto — LEAD reale, NON deploy-ready +- ✅ Il VRP è reale (IV>RV 78%), lo sleeve è **genuinamente scorrelato** al trend (+0.07) e + **migliora il portafoglio** anche a premio conservativo. È la fonte di rendimento DIVERSA che + cercavamo per superare il soffitto ~1.3. +- ⚠️ MA: (a) le metriche headline dipendono da una calibrazione **ottimistica** (f=1.29); + conservativo (f=1.0) → Sharpe 0.71 con **DD 33%**. (b) Premio **MODELLATO** (BS su DVOL), non un + backtest su catena reale; la verifica su quote reali è UN solo snapshot calmo. (c) Il **rischio di + coda** (roll/assignment/gap nello stress, skew che esplode) NON è pienamente catturato. +- Regola del progetto: **mai deployare uno short-vol prezzato da un modello.** → NON aggiunto al + portafoglio. Portafoglio attivo invariato: TP01 70% + XS01 30%. + +## Prossimi passi per graduare il lead a sleeve deployabile +1. **Accumulo forward di quote reali** (bid/ask + skew della put settimanale delta-0.28, ogni giorno, + su più regimi) → sostituire il premio modellato con quello reale e misurare f nello stress. +2. **Stress crash-week con spread reali** (rollabilità, assignment, gap inverso/coin-settled). +3. **Daily-MTM** dello short put per l'integrazione nel portafoglio giornaliero (ora è settimanale). +4. **Paper-trade su Deribit testnet** prima di qualsiasi capitale. +Solo dopo, se regge a premi reali multi-regime, aggiungerlo come 3º sleeve (scorrelato, income). diff --git a/scripts/research/fetch_dvol.py b/scripts/research/fetch_dvol.py new file mode 100644 index 0000000..5939dfc --- /dev/null +++ b/scripts/research/fetch_dvol.py @@ -0,0 +1,58 @@ +"""FETCH storia DVOL (Deribit Volatility Index) — input IV per lo sleeve opzioni VRP. + +DVOL = vol implicita 30d annualizzata di Deribit (l'IV "ATM" del mercato). Public API, no auth. +Limite 1000 punti/richiesta -> paginazione all'indietro. Salva data/raw/dvol_.parquet +(colonne: timestamp ms, close = DVOL%). Usato come IV per prezzare BS le opzioni nel backtest VRP; +la RV viene dai nostri prezzi certificati. VRP = IV - RV. + + uv run python scripts/research/fetch_dvol.py +""" +from __future__ import annotations +import sys, time +from pathlib import Path +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) +import requests, pandas as pd + +URL = "https://www.deribit.com/api/v2/public/get_volatility_index_data" +RAW = PROJECT_ROOT / "data" / "raw" + + +def fetch(cur, res=86400): + end = int(time.time() * 1000) + floor = int(pd.Timestamp("2020-06-01", tz="UTC").timestamp() * 1000) + rows = {} + guard = 0 + while end > floor and guard < 60: + guard += 1 + r = requests.get(URL, params={"currency": cur, "start_timestamp": floor, + "end_timestamp": end, "resolution": res}, timeout=40) + data = r.json().get("result", {}).get("data", []) + if not data: + break + for ts, o, h, l, c in data: + rows[int(ts)] = float(c) + earliest = min(int(x[0]) for x in data) + if earliest >= end: + break + end = earliest - 1 + if not rows: + return pd.DataFrame() + df = pd.DataFrame(sorted(rows.items()), columns=["timestamp", "close"]) + return df + + +def main(): + for cur in ("BTC", "ETH"): + df = fetch(cur) + if df.empty: + print(f"{cur}: VUOTO"); continue + ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True) + df.to_parquet(RAW / f"dvol_{cur.lower()}.parquet", index=False) + print(f"{cur}: {len(df)} giorni [{ts.iloc[0].date()} -> {ts.iloc[-1].date()}] " + f"DVOL media {df['close'].mean():.1f} range [{df['close'].min():.1f}, {df['close'].max():.1f}] " + f"-> data/raw/dvol_{cur.lower()}.parquet") + + +if __name__ == "__main__": + main() diff --git a/scripts/research/options_vrp_lab.py b/scripts/research/options_vrp_lab.py new file mode 100644 index 0000000..321fec1 --- /dev/null +++ b/scripts/research/options_vrp_lab.py @@ -0,0 +1,150 @@ +"""OPTIONS VRP LAB — sleeve income: vendita put settimanali (CSP) che incassa il VRP (IV>RV). + +Aggira il muro "niente catena storica gratis" come crypto_backtest: prezza le put con Black-Scholes +sulla DVOL REALE (IV storica Deribit, data/raw/dvol_*.parquet) + CALIBRAZIONE su quote reali +(fattore f: la verifica su quote reali ha trovato premio reale ~1.29x il modellato a IV-ATM per via +dello skew, al netto dello spread). Payoff sul path REALIZZATO dei prezzi certificati. Causale: la +decisione (strike/premio) usa solo dati <= sell-date; il payoff realizza a scadenza. + +Onesto: e' SHORT-VOL, il rischio vero e' la CODA (crash). Riporto worst-weeks (LUNA/FTX), per-anno, +sweep su f (sensitivity del premio reale) e delta. NON e' un deploy: e' la prima validazione del lead. + + uv run python scripts/research/options_vrp_lab.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)) +import numpy as np, pandas as pd +from scipy.stats import norm +from scripts.analysis.research_lab import load_tf + +HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC") +WK_PER_YEAR = 365.25 / 7.0 + + +def bs_put(S, K, T, sig): + if T <= 0 or sig <= 0: + return max(K - S, 0.0) + d1 = (np.log(S / K) + 0.5 * sig ** 2 * T) / (sig * np.sqrt(T)) + d2 = d1 - sig * np.sqrt(T) + return K * norm.cdf(-d2) - S * norm.cdf(-d1) # r=0 + + +def strike_from_delta(S, T, sig, target_delta=-0.28): + # delta_put = -N(-d1) = target -> d1 = -N^{-1}(-target) + d1 = -norm.ppf(-target_delta) + return S * np.exp(0.5 * sig ** 2 * T - d1 * sig * np.sqrt(T)) + + +def load_series(asset): + px = load_tf(asset, "1d") + s = pd.Series(px["close"].values.astype(float), index=pd.to_datetime(px["timestamp"], unit="ms", utc=True)) + dv = pd.read_parquet(PROJECT_ROOT / "data" / "raw" / f"dvol_{asset.lower()}.parquet") + d = pd.Series(dv["close"].values.astype(float), index=pd.to_datetime(dv["timestamp"], unit="ms", utc=True)) + J = pd.concat({"px": s, "dvol": d}, axis=1, join="inner").sort_index().dropna() + return J + + +def put_sell_weekly(asset, delta=-0.28, f=1.0, tenor_d=7): + """Vendita CSP settimanale. Ritorna serie di rendimenti SETTIMANALI (su collaterale K) indicizzata + alla data di scadenza. Causale: strike/premio da DVOL e prezzo a sell-date; payoff a scadenza.""" + J = load_series(asset) + px = J["px"].values; dv = J["dvol"].values / 100.0; idx = J.index + n = len(px); T = tenor_d / 365.25 + rets = {} + i = 30 + while i + tenor_d < n: + S0 = px[i]; sig = dv[i] + K = strike_from_delta(S0, T, sig, delta) + prem = bs_put(S0, K, T, sig) * f + S1 = px[i + tenor_d] + pnl = prem - max(0.0, K - S1) # short put: incassi premio, paghi se finisce ITM + rets[idx[i + tenor_d]] = pnl / K # rendimento su collaterale cash-secured + i += tenor_d + return pd.Series(rets) + + +def m_weekly(r): + r = r.dropna() + if len(r) < 3 or r.std() == 0: + return dict(sh=0, cagr=0, dd=0, n=len(r)) + eq = np.cumprod(1 + r.values); pk = np.maximum.accumulate(eq) + yrs = len(r) / WK_PER_YEAR + return dict(sh=float(r.mean() / r.std() * np.sqrt(WK_PER_YEAR)), + cagr=float(eq[-1] ** (1 / yrs) - 1) if yrs > 0 and eq[-1] > 0 else 0, + dd=float(np.max((pk - eq) / pk)), n=len(r)) + + +def per_year(r): + out = {} + for y, g in r.groupby(r.index.year): + eq = np.cumprod(1 + g.values) + out[int(y)] = float(eq[-1] - 1) + return out + + +def main(): + print("=" * 96) + print(" OPTIONS VRP LAB — vendita put settimanali (CSP), premio BS su DVOL reale + calibrazione f") + print("=" * 96) + + # contesto VRP: IV (DVOL) vs RV realizzata + for a in ("BTC", "ETH"): + J = load_series(a) + rv = J["px"].pct_change().rolling(30).std() * np.sqrt(365.25) * 100 + vrp = (J["dvol"] - rv).dropna() + print(f" {a}: DVOL media {J['dvol'].mean():.0f}% | RV30 media {rv.mean():.0f}% | VRP media {vrp.mean():+.1f} pt, >0 nel {100*(vrp>0).mean():.0f}% del tempo") + + print("\n (1) SWEEP CALIBRAZIONE f (delta -0.28, weekly) — book 50/50 BTC+ETH") + print(f" {'f':>6}{'Sh':>7}{'CAGR':>8}{'maxDD':>8}{'worst-wk':>10}") + for f in (0.70, 0.85, 1.0, 1.15, 1.29): + rB = put_sell_weekly("BTC", f=f); rE = put_sell_weekly("ETH", f=f) + book = pd.concat({"B": rB, "E": rE}, axis=1, join="inner").mean(axis=1) + mm = m_weekly(book); worst = book.min() + tag = " <- reale(calm)" if f == 1.29 else (" <- conservativo" if f == 1.0 else "") + print(f" {f:>6.2f}{mm['sh']:>7.2f}{mm['cagr']*100:>+7.0f}%{mm['dd']*100:>7.1f}%{worst*100:>+9.1f}%{tag}") + + print("\n (2) SWEEP DELTA (f=1.0 conservativo) — book 50/50") + print(f" {'delta':>7}{'Sh':>7}{'CAGR':>8}{'maxDD':>8}") + for dl in (-0.15, -0.28, -0.40): + rB = put_sell_weekly("BTC", delta=dl); rE = put_sell_weekly("ETH", delta=dl) + book = pd.concat({"B": rB, "E": rE}, axis=1, join="inner").mean(axis=1) + mm = m_weekly(book) + print(f" {dl:>7.2f}{mm['sh']:>7.2f}{mm['cagr']*100:>+7.0f}%{mm['dd']*100:>7.1f}%") + + # config centrale: delta -0.28, f=1.0 (conservativo) e f=1.29 (reale misurato) + print("\n (3) PER ANNO + WORST WEEKS (delta -0.28, book 50/50) — il rischio e' la CODA") + for f in (1.0, 1.29): + rB = put_sell_weekly("BTC", f=f); rE = put_sell_weekly("ETH", f=f) + book = pd.concat({"B": rB, "E": rE}, axis=1, join="inner").mean(axis=1) + py = per_year(book) + worst = book.nsmallest(5) + print(f"\n f={f}: per-anno " + " ".join(f"{y}:{v*100:+.0f}%" for y, v in py.items())) + print(f" worst weeks: " + " ".join(f"{d.date()}:{v*100:.0f}%" for d, v in worst.items())) + full = m_weekly(book); ho = m_weekly(book[book.index >= HOLDOUT]) + print(f" FULL Sh {full['sh']:.2f} CAGR {full['cagr']*100:+.0f}% DD {full['dd']*100:.0f}% | HOLD-OUT Sh {ho['sh']:.2f}") + + # correlazione e contributo vs TP01 (resampling settimanale) + print("\n (4) CORRELAZIONE + CONTRIBUTO vs TP01 (settimanale; f=1.0 conservativo)") + from src.portfolio.sleeves import tp01_sleeve + tp = tp01_sleeve().daily() + tp_wk = (1 + tp).resample("7D").prod() - 1 + rB = put_sell_weekly("BTC"); rE = put_sell_weekly("ETH") + opt = pd.concat({"B": rB, "E": rE}, axis=1, join="inner").mean(axis=1) + opt_wk = opt.copy(); opt_wk.index = opt_wk.index.to_period("W").to_timestamp() + tp_wk2 = tp_wk.copy(); tp_wk2.index = tp_wk2.index.to_period("W").to_timestamp() + Jc = pd.concat({"tp": tp_wk2, "opt": opt_wk}, axis=1, join="inner").dropna() + corr = float(Jc["tp"].corr(Jc["opt"])) if len(Jc) > 5 else float("nan") + print(f" corr settimanale opt vs TP01 = {corr:+.2f} (atteso ~0.2)") + for w in (0.3, 0.5): + comb = (1 - w) * Jc["tp"] + w * Jc["opt"] + mt = m_weekly(Jc["tp"]); mc = m_weekly(comb) + print(f" TP01 {1-w:.0%} + OPT {w:.0%}: Sh {mc['sh']:.2f} (TP01-solo {mt['sh']:.2f}) DD {mc['dd']*100:.0f}%") + print("\n NB onesto: short-vol -> guarda i worst-weeks e gli anni di crash. Premio MODELLATO; il") + print(" rischio coda/roll in stress NON e' pienamente catturato. Lead, non deploy.") + + +if __name__ == "__main__": + main()