diff --git a/docs/diary/2026-06-19-eval-crypto-backtest-options.md b/docs/diary/2026-06-19-eval-crypto-backtest-options.md index 00a7927..567989a 100644 --- a/docs/diary/2026-06-19-eval-crypto-backtest-options.md +++ b/docs/diary/2026-06-19-eval-crypto-backtest-options.md @@ -81,3 +81,45 @@ e il blend scende di conseguenza. Coerente con la regola del progetto (lezione v2.0.0): un edge full+OOS robusto su prezzi MODELLATI non è un edge finché non è verificato su prezzi reali ed eseguibili. + +--- + +## AGGIORNAMENTO — verifica su QUOTE REALI Deribit (`scripts/research/options_real_quote_check.py`) + +Fatta la verifica concreta (PARTE 1: catena reale Deribit mainnet pubblico; PARTE 2: ri-esecuzione +dello sleeve CSP con haircut reale sul premio). **Risultato che RIBALTA una mia critica.** + +Snapshot del 2026-06-19, scadenza settimanale 2026-06-26 (~6.2 DTE), put delta −0.277 (strike 61k, +3.1% OTM), underlying 62.965: + +| Grandezza | Valore | +|---|---| +| IV ATM (≈ DVOL) | 37.2% | +| IV put OTM (mark) | 42.1% (**skew +4.8 pt**) | +| premio put: BID / mark / ask | 598 / 623 / 630 USD | +| spread bid/mark | 0.96 (spread ~4%) | +| premio MODELLATO dal backtest (BS @ IV-ATM) | **463 USD** | +| **HAIRCUT premio reale(BID)/modello** | **1.29** | + +**Il backtest SOTTOSTIMA il premio, non lo sovrastima.** Prezzando la put OTM con la DVOL (IV ATM) +ignora lo skew (+28% sul premio lordo); il bid/ask la riporta giu' solo del 4% → vendendo al BID +reale incassi **1.29×** il premio modellato. Lo sleeve modellato (Sharpe 1.13) e' quindi +**conservativo sul premio** alle quote attuali; col premio reale salirebbe (Sharpe → 1.83 a f=1.29). + +**Ma la critica vera si SPOSTA, non sparisce:** lo skew esiste perche' il mercato prezza la coda +grassa: piu' premio = esattamente perche' i crash fanno male. La sensitivity mostra il punto di +rottura — lo sleeve regge finche' incassi >~85% del premio modellato (Sharpe 0.59 a f=0.85), va a +zero a f=0.70, negativo a f=0.55. Lo snapshot e' in **regime calmo** (IV ATM 37%, bassa per crypto); +in un crash lo spread si allarga molto e potresti non riuscire a rollare. Quindi: + +- ✅ **Concern "premio sovrastimato" = SMENTITO** (alle quote attuali e' anzi sottostimato). +- ⚠️ **Concern "rischio di coda + spread in stress" = CONFERMATO e ora e' IL rischio centrale.** + Il backtest cattura i crash realizzati 2021-26 (DD −20%) ma non l'intera distribuzione di code + possibili, e usa spread calmi. La f reale in settimana di crash e' < 1 e lo spread esplode. + +**Verdetto aggiornato:** lo sleeve income e' piu' solido di quanto temessi sul *premio* (il VRP + +skew e' reale e generoso), ma resta una strategia short-vol il cui rischio vero e' la **coda** e la +**liquidita' di roll nello stress**, non la magnitudine del premio. Prima del capitale: ripetere lo +snapshot nel tempo (specie in regimi di IV alta), misurare lo spread in giornate di stress, e +paper-trade su testnet. Il lead per superare il soffitto Sharpe ~1.3 (aggiungere il VRP a TP01) +resta valido e ora meglio quantificato. diff --git a/scripts/research/options_real_quote_check.py b/scripts/research/options_real_quote_check.py new file mode 100644 index 0000000..2540bd7 --- /dev/null +++ b/scripts/research/options_real_quote_check.py @@ -0,0 +1,187 @@ +"""VERIFICA SLEEVE OPZIONI su QUOTE REALI Deribit — quanto Sharpe sopravvive a bid/ask + skew. + +Lo sleeve income della strategia esterna `crypto_backtest` (vendita di put settimanali CSP su +BTC, delta 0.28) e' backtestato su prezzi MODELLATI: Black-Scholes prezzato con DVOL = IV ATM, e +si incassa il premio "fair" (mid). Due gap reali NON catturati: + (1) BID/ASK: vendendo si incassa il BID, non il mid. + (2) SKEW: una put OTM (delta 0.28) ha IV piu' alta della ATM (DVOL) -> il modello prezza la put + con la vol sbagliata. + +Questo script: + PARTE 1 (rete, Deribit mainnet pubblico): scarica la catena REALE della scadenza ~settimanale, + trova la put a delta ~0.28, e misura: + - premio reale incassabile (BID, in USD) vs premio modellato (BS @ IV ATM) + - skew: IV della put OTM (mark) vs IV ATM (mark) + - spread: bid/mark + - HAIRCUT netto f = premio_bid_reale / premio_BS@ATM + PARTE 2 (locale): ri-esegue lo sleeve CSP settimanale (dati + modulo del progetto esterno) con + il premio moltiplicato per f -> Sharpe/CAGR/maxDD reali stimati, vs i modellati. + +NB ONESTO: e' UNO SNAPSHOT (la catena di oggi). Lo spread si allarga nello stress; lo skew varia. +Va ripetuto nel tempo per robustezza. Ma misura direttamente i due gap col mercato vero. + + uv run python scripts/research/options_real_quote_check.py +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +EXT = Path("/home/adriano/crypto_backtest") +sys.path.insert(0, str(EXT)) + +PUT_DELTA = 0.28 +CYCLE_DAYS = 7 +ANN = 365 + + +def fetch_real_chain(): + import ccxt + ex = ccxt.deribit({"enableRateLimit": True}) + ex.load_markets() + puts = [m for m in ex.markets.values() + if m.get("option") and m["base"] == "BTC" and m["optionType"] == "put"] + calls = [m for m in ex.markets.values() + if m.get("option") and m["base"] == "BTC" and m["optionType"] == "call"] + # expiries -> pick the one closest to CYCLE_DAYS days out + now = pd.Timestamp.utcnow().tz_localize(None) + def exp_dt(m): + return pd.to_datetime(m["symbol"].split("-")[1], format="%y%m%d") + exps = sorted(set(exp_dt(m) for m in puts)) + target = now + pd.Timedelta(days=CYCLE_DAYS) + expiry = min(exps, key=lambda e: abs((e - target).days)) + dte = (expiry - now).days + (expiry - now).seconds / 86400 + chain_puts = [m for m in puts if exp_dt(m) == expiry] + chain_calls = [m for m in calls if exp_dt(m) == expiry] + print(f" scadenza scelta: {expiry.date()} (DTE ~{dte:.1f}g, target {CYCLE_DAYS}g) " + f"strikes put: {len(chain_puts)}") + + def tick(m): + try: + t = ex.fetch_ticker(m["symbol"]) + i = t["info"] + g = i.get("greeks") or {} + return dict(symbol=m["symbol"], strike=float(m["strike"]), + delta=float(g.get("delta", "nan")), mark_iv=float(i.get("mark_iv", "nan")), + bid=float(i.get("best_bid_price") or 0), ask=float(i.get("best_ask_price") or 0), + mark=float(i.get("mark_price") or 0), + S=float(i.get("underlying_price") or i.get("index_price") or 0)) + except Exception: + return None + + rows = [r for r in (tick(m) for m in chain_puts) if r and np.isfinite(r["delta"])] + callrows = [r for r in (tick(m) for m in chain_calls) if r and np.isfinite(r["delta"])] + return expiry, dte, pd.DataFrame(rows), pd.DataFrame(callrows) + + +def bs_put(S, K, T, sigma): + from scipy.stats import norm + if T <= 0 or sigma <= 0: + return max(0.0, K - S) + d1 = (np.log(S / K) + 0.5 * sigma ** 2 * T) / (sigma * np.sqrt(T)) + d2 = d1 - sigma * np.sqrt(T) + return K * norm.cdf(-d2) - S * norm.cdf(-d1) + + +def measure_haircut(dte, puts, calls): + S = puts["S"].iloc[0] + T = dte / ANN + # ATM IV: option with |delta| closest to 0.5 (use calls+puts mark_iv near ATM) + allo = pd.concat([puts.assign(typ="P"), calls.assign(typ="C")], ignore_index=True) + atm = allo.iloc[(allo["delta"].abs() - 0.5).abs().argsort()[:4]] + atm_iv = atm["mark_iv"].mean() / 100.0 + # delta-0.28 put (delta negative) + p = puts.iloc[(puts["delta"] - (-PUT_DELTA)).abs().argsort()[:1]].iloc[0] + K = p["strike"] + put_iv = p["mark_iv"] / 100.0 + # premiums in USD (Deribit option price is in BTC) + bid_usd = p["bid"] * S + mark_usd = p["mark"] * S + ask_usd = p["ask"] * S + bs_atm_usd = bs_put(S, K, T, atm_iv) # cio' che il backtest assume (DVOL=ATM, incassa mid) + bs_skew_usd = bs_put(S, K, T, put_iv) # BS alla vol REALE della put (isola lo skew) + + print("\n --- MISURA SU QUOTE REALI (snapshot) ---") + print(f" underlying S = {S:,.0f} strike(delta~-0.28) K = {K:,.0f} ({(1-K/S)*100:.1f}% OTM) delta {p['delta']:.3f}") + print(f" IV ATM (DVOL-equivalente) = {atm_iv*100:.1f}% IV put OTM (mark) = {put_iv*100:.1f}% " + f"skew +{(put_iv-atm_iv)*100:.1f} pt") + print(f" premio put (USD): BID {bid_usd:,.1f} mark {mark_usd:,.1f} ask {ask_usd:,.1f}") + print(f" spread bid/mark = {(p['bid']/p['mark']) if p['mark']>0 else float('nan'):.3f} " + f"(ask-bid)/mark = {((p['ask']-p['bid'])/p['mark']) if p['mark']>0 else float('nan'):.3f}") + print(f" modellato dal backtest BS@IV-ATM = {bs_atm_usd:,.1f} USD (BS@IV-put-reale = {bs_skew_usd:,.1f})") + f_bid = bid_usd / bs_atm_usd if bs_atm_usd > 0 else float("nan") + f_mark = mark_usd / bs_atm_usd if bs_atm_usd > 0 else float("nan") + print(f" HAIRCUT premio: reale(BID)/modello = {f_bid:.3f} | mark/modello = {f_mark:.3f}") + print(f" -> lo skew ALZA il premio lordo (+{(bs_skew_usd/bs_atm_usd-1)*100:.0f}% vs ATM), ma il " + f"BID/ask lo riporta a {f_bid*100:.0f}% del modello.") + return f_bid + + +def csp_sleeve_haircut(f): + """Ri-esegue lo sleeve CSP settimanale (dati+modulo esterni) con premio * f.""" + import options_deribit as od + px = pd.read_csv(EXT / "data/BTCUSDT.csv", parse_dates=["date"]).set_index("date")["close"] + dvol = pd.read_csv(EXT / "data/DVOL_BTC.csv", parse_dates=["date"]).set_index("date")["close"] + iv = od.build_iv(px, "BTC", dvol) + d0 = dvol.index[0] + px, iv = px[px.index >= d0], iv[iv.index >= d0] + + def sim(prem_mult, m=0.63): + idx = px.index + locs = list(range(0, len(idx) - CYCLE_DAYS, CYCLE_DAYS)) + T = CYCLE_DAYS / ANN + rows = [] + for i in locs: + S0, S1, sig = px.iloc[i], px.iloc[i + CYCLE_DAYS], iv.iloc[i] + if not (np.isfinite(S0) and np.isfinite(S1) and np.isfinite(sig)): + continue + Kp = od.strike_for_delta(S0, T, sig, PUT_DELTA, call=False) + pp = od.bs_price(S0, Kp, T, sig, call=False) * prem_mult # <-- haircut sul premio + fee = od.option_fee(S0, pp) + (od.SETTLE_FEE * S0 if S1 < Kp else 0) + pnl = pp - max(Kp - S1, 0.0) - fee + rows.append((idx[i + CYCLE_DAYS], m * pnl / S0)) + s = pd.Series({d: r for d, r in rows}).sort_index() + return s + + def met(s, name): + eq = (1 + s).cumprod() + cpy = ANN / CYCLE_DAYS + yrs = len(s) / cpy + cagr = eq.iloc[-1] ** (1 / yrs) - 1 if eq.iloc[-1] > 0 else -1 + sh = s.mean() / s.std() * np.sqrt(cpy) + dd = (eq / eq.cummax() - 1).min() + print(f" {name:<34s} CAGR {cagr*100:>+6.1f}% Sharpe {sh:>5.2f} maxDD {dd*100:>6.1f}% win {(s>0).mean()*100:>3.0f}%") + return sh + + print("\n --- RI-ESECUZIONE SLEEVE CSP con HAIRCUT REALE (m=0.63, hold-to-expiry) ---") + print(f" finestra {px.index[0].date()} -> {px.index[-1].date()} (DVOL reale)") + sh_model = met(sim(1.00), "modello (premio pieno, BS@DVOL)") + sh_real = met(sim(f), f"reale stimato (premio x{f:.2f} = BID)") + # sensitivity + for ff in (0.85, 0.70, 0.55): + met(sim(ff), f"sensitivity premio x{ff:.2f}") + print(f"\n => con haircut reale f={f:.2f}: Sharpe sleeve {sh_model:.2f} -> {sh_real:.2f}") + return sh_model, sh_real + + +def main(): + print("=" * 92) + print("# VERIFICA SLEEVE OPZIONI su QUOTE REALI DERIBIT — quanto Sharpe sopravvive") + print("=" * 92) + try: + expiry, dte, puts, calls = fetch_real_chain() + f = measure_haircut(dte, puts, calls) + except Exception as e: + print(f" [rete] impossibile scaricare la catena reale ({type(e).__name__}: {e})") + print(" uso haircut di letteratura f=0.70 (spread+skew tipici su put OTM settimanali)") + f = 0.70 + f = float(np.clip(f, 0.3, 1.2)) + csp_sleeve_haircut(f) + print("\n CAVEAT: snapshot singolo; spread peggiora nello stress; ripetere nel tempo + testnet.") + + +if __name__ == "__main__": + main()