Files
PythagorasGoal/scripts/research/r0822_skew.py
T

619 lines
33 KiB
Python

"""r0822_skew.py — la FORMA della superficie di volatilita' (skew), mai usata dal progetto.
Il progetto ha usato molte volte il LIVELLO della vol implicita (DVOL/ATM): VRP01 lo vende sotto
gate IV-rank, DVOLSPREAD ne fa relative-value BTC/ETH, TP01xDVOL lo prova come denominatore del
vol-target. Non ha MAI usato la FORMA: il risk reversal 25-delta (RR), il butterfly (BF) e la loro
dinamica. La catena per-strike esiste da 2026-05-01 (`data/raw/cb_chain/`), quindi la domanda e'
finalmente misurabile.
Tre domande, in ordine di valore dichiarato nel brief:
Q1 lo skew ANTICIPA lo spot? ("il posizionamento in opzioni anticipa il prezzo")
Q2 lo skew e' un GATE DI RISCHIO sopra il libro TP01?
Q3 lo skew spiega l'errore di PREZZATURA di VRP01 (f=0.73 misurato il 30/07)?
Onesta' strutturale, dichiarata PRIMA dei numeri:
* La finestra utile e' 2026-06-09 -> oggi, cioe' ~75 giorni, NON i 113 giorni di righe presenti.
Prima del 09/06 cerbero-bite raccoglieva UNA SOLA scadenza per ora (verificato: `exps=1`),
quindi non esiste ne' struttura a termine ne' maturita' costante. Righe presenti != dato
presente (regola 8 del brief), e qui il conto va fatto in ORE DI SUPERFICIE RICOSTRUIBILE.
* HOLDOUT del progetto = 2025-01-01: l'INTERO campione e' posteriore. Non esiste hold-out,
non esiste in-sample -> `study_family_honest` e `select_cell_insample` NON sono eseguibili
(l'in-sample e' vuoto). Il verdetto massimo ottenibile e' LEAD con gate pre-registrato.
* Con ~75 osservazioni giornaliere SE(Sharpe annualizzato) ~ sqrt(365/75) ~ 2.2: qualunque
Sharpe giornaliero misurato qui e' indistinguibile da zero. Percio' il peso della sessione
sta dove la POTENZA c'e' davvero: la relazione oraria skew<->prezzo (N~1.800) e la
decomposizione di prezzo di VRP01 (deterministica, non statistica).
Convenzioni di costruzione (sono SCELTE, non dettagli — vedi sezione 1):
* "quotato" = bid>0 AND ask>0 AND ask>=bid AND iv/delta non nulli. Si contano le quote, non le righe.
* smile per (asset, ora, scadenza): IV interpolata LINEARMENTE in |delta| a 0.25 e 0.50,
senza MAI estrapolare (se la catena non abbraccia il delta bersaglio, la cella si scarta).
* maturita' costante: RR/BF/ATM interpolati fra le due scadenze che abbracciano T (7g e 30g).
Il roll e' CONTINUO per costruzione: non c'e' una data di roll, quindi non c'e' il dente di
sega di tenore che un "scadenza piu' vicina a T" iniettera' nel segnale.
* ogni ora porta `ts_max` = il timestamp piu' recente fra le quote usate. I rendimenti in avanti
partono STRETTAMENTE dopo `ts_max`: senza questo l'ancora e' sbagliata di ~30 minuti e nasce
un falso "lo skew anticipa di 15 minuti" (misurato in sessione: vedi sezione 2).
Uso: nice -n 19 timeout 900 uv run python scripts/research/r0822_skew.py
"""
from __future__ import annotations
import glob
import os
import sys
import time
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
import altlib as A # noqa: E402
import cblib as CB # noqa: E402
CHAIN = ROOT / "data" / "raw" / "cb_chain"
SCRATCH = Path(os.environ.get("SKEW_SCRATCH", "/tmp/claude-1001/-opt-docker-PythagorasGoal/"
"b6cc75e7-14f8-4c32-bd07-ab8a0d2aaee6/scratchpad"))
ASSETS = ("BTC", "ETH")
COLS = ["ts", "asset", "instrument_name", "option_type", "strike", "exp",
"bid", "ask", "mid", "iv", "delta", "open_interest", "quote_status"]
# tick MISURATO sui dati (non assunto): 0.0001 sotto 0.005 di premio, 0.0005 sopra.
def tick_of(prezzo: np.ndarray) -> np.ndarray:
return np.where(np.asarray(prezzo, float) < 0.005, 0.0001, 0.0005)
def rule(t: str) -> None:
print("\n" + "=" * 78)
print(t)
print("=" * 78)
# =============================================================================
# 0. DATO
# =============================================================================
def load_quoted() -> tuple[pd.DataFrame, dict]:
"""Catena filtrata alle QUOTE vere. Ritorna anche la contabilita' righe-vs-quote."""
parts, n_righe, n_status = [], 0, {}
for f in sorted(glob.glob(str(CHAIN / "*.parquet"))):
d = pd.read_parquet(f, columns=COLS)
n_righe += len(d)
for k, v in d["quote_status"].value_counts(dropna=False).items():
n_status[str(k)] = n_status.get(str(k), 0) + int(v)
d["ts"] = pd.to_datetime(d["ts"], utc=True).dt.tz_convert("UTC")
d["exp"] = pd.to_datetime(d["exp"], utc=True).dt.tz_convert("UTC")
d["strike"] = d["strike"].astype(float)
parts.append(d)
d = pd.concat(parts, ignore_index=True)
d = d.drop_duplicates(subset=["ts", "instrument_name"], keep="last")
d["ts"] = pd.DatetimeIndex(d["ts"]).as_unit("ns")
d["exp"] = pd.DatetimeIndex(d["exp"]).as_unit("ns")
d["dte"] = (d["exp"] - d["ts"]).dt.total_seconds() / 86400.0
ok = (d["bid"] > 0) & (d["ask"] > 0) & (d["ask"] >= d["bid"]) & d["iv"].notna() & d["delta"].notna()
cont = dict(righe=n_righe, dedup=int(len(d)), quotate=int(ok.sum()),
frac_quotate=round(float(ok.mean()), 4), quote_status=n_status)
q = d[ok & (d["dte"] > 0.5) & (d["dte"] < 95)].copy()
q["hr"] = q["ts"].dt.floor("h")
q["ad"] = q["delta"].abs()
# una riga per (ora, strumento): se in un'ora ci sono piu' giri, vale l'ULTIMO
q = q.sort_values("ts").drop_duplicates(subset=["hr", "asset", "instrument_name"], keep="last")
return q, cont
def conta_tick(q: pd.DataFrame) -> pd.DataFrame:
"""Quanti tick vale lo spread, e quanti tick vale l'ask? (regola: prima di credere a un
rapporto estremo su un prezzo piccolo, contare i tick)."""
out = []
for a in ASSETS:
for nome, m in (("ala |d|~0.10", (q["ad"] > 0.06) & (q["ad"] < 0.14)),
("25-delta", (q["ad"] > 0.20) & (q["ad"] < 0.30)),
("ATM |d|~0.50", (q["ad"] > 0.45) & (q["ad"] < 0.55))):
g = q[(q["asset"] == a) & m]
if g.empty:
continue
tk = tick_of(g["ask"].to_numpy())
spread_tick = (g["ask"].to_numpy() - g["bid"].to_numpy()) / tk
ask_tick = g["ask"].to_numpy() / tk
out.append(dict(asset=a, banda=nome, n=len(g),
ask_tick_med=round(float(np.median(ask_tick)), 1),
ask_tick_p5=round(float(np.percentile(ask_tick, 5)), 1),
frac_ask_le2tick=round(float((ask_tick <= 2).mean()), 4),
spread_tick_med=round(float(np.median(spread_tick)), 1),
spread_su_mid_med=round(float(np.median(
(g["ask"] - g["bid"]) / g["mid"].replace(0, np.nan))), 3)))
return pd.DataFrame(out)
# =============================================================================
# 1. SUPERFICIE: smile -> RR/BF a maturita' costante
# =============================================================================
def _iv_at(ad: np.ndarray, iv: np.ndarray, tgt: float) -> float:
o = np.argsort(ad)
ad, iv = ad[o], iv[o]
if len(ad) < 2 or tgt < ad[0] or tgt > ad[-1]:
return np.nan # nessuna estrapolazione, mai
return float(np.interp(tgt, ad, iv))
def build_smile(q: pd.DataFrame) -> pd.DataFrame:
"""Per (asset, ora, scadenza): IV a |delta|=0.25 su entrambe le ali e ATM a |delta|=0.50."""
cache = SCRATCH / "r0822_smile.parquet"
if cache.exists():
try:
return pd.read_parquet(cache)
except Exception:
pass
rows = []
for (asset, hr, exp), g in q.groupby(["asset", "hr", "exp"], sort=False):
c = g[g["option_type"] == "C"]
p = g[g["option_type"] == "P"]
if len(c) < 2 or len(p) < 2:
continue
c25 = _iv_at(c["ad"].to_numpy(), c["iv"].to_numpy(), 0.25)
p25 = _iv_at(p["ad"].to_numpy(), p["iv"].to_numpy(), 0.25)
if not (np.isfinite(c25) and np.isfinite(p25)):
continue
c50 = _iv_at(c["ad"].to_numpy(), c["iv"].to_numpy(), 0.50)
p50 = _iv_at(p["ad"].to_numpy(), p["iv"].to_numpy(), 0.50)
atm = np.nanmean([x for x in (c50, p50) if np.isfinite(x)]) if (
np.isfinite(c50) or np.isfinite(p50)) else np.nan
rows.append((asset, hr, exp, float(g["dte"].iloc[0]), c25, p25, atm,
g["ts"].max(), int(len(g))))
R = pd.DataFrame(rows, columns=["asset", "hr", "exp", "dte", "ivc25", "ivp25",
"ivatm", "ts_max", "n_gambe"])
R["rr"] = R["ivc25"] - R["ivp25"]
R["bf"] = 0.5 * (R["ivc25"] + R["ivp25"]) - R["ivatm"]
try:
SCRATCH.mkdir(parents=True, exist_ok=True)
R.to_parquet(cache)
except Exception:
pass
return R
def const_maturity(R: pd.DataFrame, asset: str, T: float) -> pd.DataFrame:
"""RR/BF/ATM a maturita' costante T giorni. Interpolazione lineare in DTE fra le due scadenze
che ABBRACCIANO T (mai estrapolazione) -> il roll e' continuo, non c'e' data di roll."""
g = R[(R["asset"] == asset)].dropna(subset=["rr", "ivatm"])
out = {}
for hr, h in g.groupby("hr"):
h = h.sort_values("dte")
dt = h["dte"].to_numpy()
if len(h) < 2 or T < dt[0] or T > dt[-1]:
continue
out[hr] = dict(rr=float(np.interp(T, dt, h["rr"].to_numpy())),
bf=float(np.interp(T, dt, h["bf"].to_numpy())),
atm=float(np.interp(T, dt, h["ivatm"].to_numpy())),
ts_max=h["ts_max"].max())
S = pd.DataFrame.from_dict(out, orient="index").sort_index()
S.index.name = "hr"
if len(S):
S["rr_n"] = S["rr"] / S["atm"] # skew normalizzato dal livello (toglie il DVOL)
return S
# =============================================================================
# 2. Q1 — lo skew ANTICIPA lo spot?
# =============================================================================
def px5m(asset: str, t0: pd.Timestamp) -> pd.Series:
p = pd.read_parquet(ROOT / "data" / "raw" / f"{asset.lower()}_5m.parquet",
columns=["timestamp", "close"])
s = pd.Series(p["close"].to_numpy(float),
index=pd.DatetimeIndex(pd.to_datetime(p["timestamp"], unit="ms", utc=True)))
return s.sort_index().loc[t0 - pd.Timedelta("3h"):]
def leadlag(S: pd.DataFrame, asset: str) -> pd.DataFrame:
"""Correlazione fra la variazione oraria dello skew e i rendimenti PRIMA e DOPO l'istante
esatto in cui le quote sono state osservate (`ts_max`). L'ancora conta: con un'ancora
assunta a :30 invece del `ts_max` vero compare un falso lead a +15m."""
S = S.sort_values("ts_max")
px = px5m(asset, S.index.min())
anc = pd.Series(px.reindex(pd.DatetimeIndex(S["ts_max"]), method="ffill").to_numpy(),
index=S.index)
drr = S["rr"].diff()
lvl = S["rr"]
rows = []
for mins in (-60, -30, -15, -5, 5, 15, 30, 60, 120, 240, 1440, 4320):
tgt = pd.DatetimeIndex(S["ts_max"]) + pd.Timedelta(minutes=int(mins))
p2 = pd.Series(px.reindex(tgt, method="ffill").to_numpy(), index=S.index)
r = np.log(p2 / anc) if mins > 0 else np.log(anc / p2) # sempre "rendimento nel verso del tempo"
j = pd.concat({"d": drr, "l": lvl, "r": r}, axis=1).dropna()
n = len(j)
rows.append(dict(asset=asset, minuti=mins, n=n,
corr_dRR=round(float(j["d"].corr(j["r"])), 3),
corr_RR=round(float(j["l"].corr(j["r"])), 3),
se=round(1 / np.sqrt(max(n, 2)), 3),
t_dRR=round(float(j["d"].corr(j["r"]) * np.sqrt(n)), 2)))
return pd.DataFrame(rows)
def leadlag_ancora_sbagliata(S: pd.DataFrame, asset: str) -> pd.DataFrame:
"""Controllo POSITIVO del punto precedente: la stessa misura con l'ancora ASSUNTA a :30
dell'ora (invece del `ts_max` osservato) deve mostrare il falso lead. Se non lo mostra,
la correzione non stava correggendo niente."""
px = px5m(asset, S.index.min())
fake = pd.DatetimeIndex(S.index) + pd.Timedelta("30min")
anc = pd.Series(px.reindex(fake, method="ffill").to_numpy(), index=S.index)
drr = S["rr"].diff()
rows = []
for mins in (5, 15, 30, 60):
p2 = pd.Series(px.reindex(fake + pd.Timedelta(minutes=mins), method="ffill").to_numpy(),
index=S.index)
j = pd.concat({"d": drr, "r": np.log(p2 / anc)}, axis=1).dropna()
rows.append(dict(asset=asset, minuti=mins, corr_dRR_ancora_finta=round(float(j["d"].corr(j["r"])), 3)))
return pd.DataFrame(rows)
def daily_signal(S: pd.DataFrame, df: pd.DataFrame) -> pd.DataFrame:
"""Allinea la superficie alla griglia dei prezzi in modo CAUSALE.
Le barre del progetto sono open-labeled: la barra 1d etichettata D chiude alle 23:00 di D.
Quindi al momento della decisione della barra D e' disponibile l'ultima ora <= D 23:00, e
`eval_weights` la tiene durante D+1. Con merge_asof su epoca esplicita in millisecondi
(lezione 01/07: `.view("int64")` su tz-aware non-ns sbaglia scala e broadcasta)."""
bpd = A.bars_per_day(df)
chiusura = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True))
chiusura = chiusura + (pd.Timedelta("24h") - pd.Timedelta("1h") if bpd == 1 else pd.Timedelta(0))
left = pd.DataFrame({"t_ms": chiusura.astype("datetime64[ms]").astype("int64")})
right = pd.DataFrame({"t_ms": pd.DatetimeIndex(S["ts_max"]).astype("datetime64[ms]").astype("int64"),
"rr": S["rr"].to_numpy(), "rr_n": S["rr_n"].to_numpy(),
"bf": S["bf"].to_numpy(), "atm": S["atm"].to_numpy()}).sort_values("t_ms")
m = pd.merge_asof(left.sort_values("t_ms"), right, on="t_ms", direction="backward",
tolerance=int(pd.Timedelta("6h").total_seconds() * 1000))
return m[["rr", "rr_n", "bf", "atm"]]
def make_target(S: pd.DataFrame, feat: str, win: int, segno: int):
"""target_fn(df, asset) -> posizione continua vol-targeted. Causale per costruzione:
ogni feature usa solo z-score rolling su dati <= barra corrente, e `eval_weights` sposta."""
def fn(df, asset=None):
F = daily_signal(S, df)
x = F["rr_n"] if feat.endswith("_n") else F["rr"]
if feat.startswith("d"):
x = x.diff()
z = (x - x.rolling(win, min_periods=max(5, win // 2)).mean()) / \
x.rolling(win, min_periods=max(5, win // 2)).std()
d = np.tanh(np.nan_to_num(z.to_numpy(), nan=0.0)) * segno
return A.vol_target(d, df, target_vol=0.20, leverage_cap=2.0)
return fn
def sharpe_boot(s: pd.Series, blocco: int = 10, n: int = 500, seed: int = 20260822) -> tuple:
"""IC95% dello Sharpe con block bootstrap circolare (il segnale e' lentissimo: i giorni
NON sono indipendenti, e una SE analitica mentirebbe)."""
r = np.asarray(s.dropna().to_numpy(), float)
T = len(r)
if T < 20:
return (np.nan, np.nan)
rng = np.random.default_rng(seed)
nb = int(np.ceil(T / blocco))
out = []
for _ in range(n):
st = rng.integers(0, T, nb)
idx = (st[:, None] + np.arange(blocco)[None, :]).ravel()[:T] % T
x = r[idx]
out.append(np.mean(x) / np.std(x) * np.sqrt(365.25) if np.std(x) > 0 else 0.0)
return (round(float(np.percentile(out, 2.5)), 2), round(float(np.percentile(out, 97.5)), 2))
def causality_finestra(S: pd.DataFrame, fn, asset: str) -> dict:
"""`A.causality_ok` taglia il prefisso all'80%/92% della storia dei PREZZI (2024/2025):
fuori dalla finestra dello skew, dove il segnale e' zero -> confronterebbe zeri con zeri,
POTENZA NULLA (stessa trappola del self-check di SKH01: si campiona sugli EVENTI).
Qui i tagli cadono DENTRO la finestra."""
df = A.get(asset, "1d")
full = np.nan_to_num(np.asarray(fn(df, asset), float))
n = len(df)
dts = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True))
dentro = np.where(dts >= S.index.min())[0]
peggio, controlli = 0.0, 0
for frac in (0.4, 0.7, 0.9):
cut = int(dentro[0] + frac * (n - dentro[0]))
if cut <= dentro[0] + 5 or cut >= n:
continue
sub = df.iloc[:cut].reset_index(drop=True)
s = np.nan_to_num(np.asarray(fn(sub, asset), float))
coda = min(20, cut - dentro[0])
peggio = max(peggio, float(np.max(np.abs(s[cut - coda:cut] - full[cut - coda:cut]))))
controlli += 1
return dict(ok=bool(peggio <= 1e-6 and controlli > 0), max_diff=round(peggio, 10),
controlli=controlli, non_zero=int((full[dentro] != 0).sum()))
# =============================================================================
# 3. Q2 — lo skew come GATE DI RISCHIO sopra TP01
# =============================================================================
def dd_of(r: np.ndarray) -> float:
eq = np.cumprod(1.0 + np.nan_to_num(r))
pk = np.maximum.accumulate(eq)
return float(np.max((pk - eq) / pk)) if len(eq) else 0.0
def sh_of(r: np.ndarray) -> float:
r = np.asarray(r, float)
return float(np.mean(r) / np.std(r) * np.sqrt(365.25)) if np.std(r) > 0 else 0.0
def null_de_levering(base: np.ndarray, overlay: np.ndarray) -> dict:
"""Il primo test di OGNI claim su meno drawdown: esiste k<=1 che, applicato al baseline,
da' lo STESSO DD dell'overlay ma con Sharpe MIGLIORE? Se si', l'overlay e' solo meno leva."""
dd_t = dd_of(overlay)
lo, hi = 0.0, 1.0
for _ in range(60):
k = 0.5 * (lo + hi)
if dd_of(k * base) > dd_t:
hi = k
else:
lo = k
k = 0.5 * (lo + hi)
return dict(k=round(k, 3), dd_overlay=round(dd_t, 4), dd_base_k=round(dd_of(k * base), 4),
sh_overlay=round(sh_of(overlay), 3), sh_base_k=round(sh_of(k * base), 3),
superato=bool(sh_of(overlay) > sh_of(k * base)))
# =============================================================================
# 4. Q3 — lo skew spiega l'errore di prezzo di VRP01?
# =============================================================================
def vrp_decomposizione(q: pd.DataFrame, cm7: dict) -> pd.DataFrame:
"""Per ogni ingresso settimanale di VRP01 (stessa macchina di `cblib`, cosi' i numeri sono
confrontabili con lo 0.718 pubblicato il 30/07), scompone il f in fattori MOLTIPLICATIVI:
f_tot = f_term x f_skew x f_markfit x f_spread
f_term = credito prezzato ad ATM 7g piatta / credito prezzato a DVOL30 piatta
(quanto del difetto e' STRUTTURA A TERMINE: il sleeve prezza un'opzione a 7
giorni con l'indice a 30 giorni)
f_skew = credito prezzato a IV PER GAMBA / credito ad ATM 7g piatta
(quanto e' SKEW: l'ala che si compra non vale la vol ATM)
f_markfit = credito ai MID reali / credito a IV per gamba (controllo: deve stare a ~1,
altrimenti la IV di Deribit non riprezza i propri mid e tutta la catena di
ragionamento poggia su un modello invece che su un prezzo)
f_spread = credito al fill CONSERVATIVO (vendi al bid, compri all'ask) / credito ai mid
"""
righe = []
for a in ASSETS:
S7 = cm7[a]
E = CB.weekly_entries(a, q)
for _, e in E.iterrows():
prima = S7.index[S7.index <= e["ts"]]
if len(prima) == 0 or (e["ts"] - prima[-1]) > pd.Timedelta("6h"):
continue
s = S7.loc[prima[-1]]
T = e["dte"] / 365.25
dvol = e["dvol_pct"] / 100.0
atm7 = s["atm"] / 100.0
c_mod = CB.bs_put(e["spot"], e["k_short"], T, dvol) - CB.bs_put(e["spot"], e["k_long"], T, dvol)
c_atm = CB.bs_put(e["spot"], e["k_short"], T, atm7) - CB.bs_put(e["spot"], e["k_long"], T, atm7)
c_sml = (CB.bs_put(e["spot"], e["k_short"], T, e["iv_short"] / 100.0)
- CB.bs_put(e["spot"], e["k_long"], T, e["iv_long"] / 100.0))
c_mid = e["cred_mid"]
c_real = e["cred_real"]
righe.append(dict(
asset=a, ts=e["ts"], dte=round(e["dte"], 2), ivrank=round(e["ivrank"], 3),
dvol=round(e["dvol_pct"], 2), atm7=round(s["atm"], 2),
rr7=round(s["rr"], 2), bf7=round(s["bf"], 2),
iv_short=round(e["iv_short"], 2), iv_long=round(e["iv_long"], 2),
d_short=round(e["d_short"], 3), d_long=round(e["d_long"], 3),
f_term=c_atm / c_mod if c_mod > 0 else np.nan,
f_skew=c_sml / c_atm if c_atm > 0 else np.nan,
f_markfit=c_mid / c_sml if c_sml > 0 else np.nan,
f_spread=c_real / c_mid if c_mid > 0 else np.nan,
f_tot_mid=c_mid / c_mod if c_mod > 0 else np.nan,
f_tot=c_real / c_mod if c_mod > 0 else np.nan,
quota_ala=CB.bs_put(e["spot"], e["k_long"], T, dvol) /
CB.bs_put(e["spot"], e["k_short"], T, dvol),
))
return pd.DataFrame(righe)
# =============================================================================
def main() -> None:
t_start = time.time()
rule("r0822_skew — SKEW (risk reversal 25-delta): la dimensione mai usata della superficie")
# ---------------------------------------------------------------- 0. DATO
rule("0. IL DATO — quote, non righe")
q, cont = load_quoted()
print(f" righe totali nella catena : {cont['righe']:,}")
print(f" dopo dedup (ts, strumento) : {cont['dedup']:,}")
print(f" QUOTATE (bid>0, ask>0, iv/delta ok) : {cont['quotate']:,} = {cont['frac_quotate']:.1%}")
print(f" quote_status : {cont['quote_status']}")
print(f" usate (0.5<DTE<95, ultimo giro/ora) : {len(q):,}")
print(f" finestra righe : {q['ts'].min()} -> {q['ts'].max()}")
print("\n TICK (regola: prima di credere a un rapporto estremo su un prezzo piccolo, contarli)")
print(conta_tick(q).to_string(index=False))
# ---------------------------------------------------------- 1. SUPERFICIE
rule("1. SUPERFICIE — smile per delta, poi maturita' costante")
t0 = time.time()
R = build_smile(q)
print(f" celle (asset, ora, scadenza) con smile ricostruibile: {len(R):,} [{time.time()-t0:.0f}s]")
perday = R.groupby(["asset", pd.DatetimeIndex(R["hr"]).date]).agg(exps=("exp", "nunique"))
print(f" giorni con >=2 scadenze (maturita' costante possibile): "
f"{int((perday['exps'] >= 2).sum())} / {len(perday)} coppie asset-giorno")
cm7, cm30 = {}, {}
for a in ASSETS:
cm7[a] = const_maturity(R, a, 7.0)
cm30[a] = const_maturity(R, a, 30.0)
s = cm30[a]
print(f" {a}: CM30 ore={len(s):5d} giorni={len(np.unique(pd.DatetimeIndex(s.index).date)):3d}"
f" {s.index.min()} -> {s.index.max()}")
print(f" RR30 media {s['rr'].mean():+.2f} pp sd {s['rr'].std():.2f} "
f"[{s['rr'].min():+.2f}, {s['rr'].max():+.2f}] ATM media {s['atm'].mean():.1f}% "
f"BF30 media {s['bf'].mean():+.2f}")
print(f" CM7 ore={len(cm7[a]):5d} RR7 media {cm7[a]['rr'].mean():+.2f} sd {cm7[a]['rr'].std():.2f}")
print("\n ⚠ La finestra utile parte dal 2026-06-09 e NON dal 2026-05-01: prima di quella data")
print(" cerbero-bite raccoglieva UNA sola scadenza per ora -> nessuna maturita' costante.")
print(" righe presenti != superficie presente. Le ore perse sono ~38 giorni su 113.")
# ------------------------------------------------------------------ 2. Q1
rule("2. Q1 — lo skew ANTICIPA lo spot? (potenza vera: N orario ~1.800)")
LL = pd.concat([leadlag(cm30[a], a) for a in ASSETS], ignore_index=True)
for a in ASSETS:
print(f"\n {a} — corr fra dRR (variazione oraria dello skew) e il rendimento nella")
print(" finestra indicata, ancorata al TIMESTAMP VERO delle quote (ts_max):")
print(LL[LL["asset"] == a].to_string(index=False))
print("\n Controllo POSITIVO — la stessa misura con l'ancora ASSUNTA a :30 dell'ora:")
FF = pd.concat([leadlag_ancora_sbagliata(cm30[a], a) for a in ASSETS], ignore_index=True)
print(FF.to_string(index=False))
# strategia giornaliera
rule("2b. Q1 — lo skew come SEGNALE DIREZIONALE giornaliero (griglia dichiarata)")
feats = ["rr", "rr_n", "drr"]
wins = [10, 20, 40]
segni = [+1, -1]
celle, serie = [], {}
for f in feats:
for w in wins:
for sg in segni:
nome = f"{f}|w{w}|sg{sg:+d}"
per_asset = {}
for a in ASSETS:
df = A.get(a, "1d")
Sx = cm30[a]
m = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)) >= (
Sx.index.min().floor("D"))
sub = df[m].reset_index(drop=True)
tgt = make_target(Sx, f, w, sg)(sub, a)
ev = A.eval_weights(sub, tgt)
per_asset[a] = pd.Series(ev["net"], index=ev["idx"])
J = pd.concat(per_asset, axis=1, join="inner").fillna(0.0)
d = 0.5 * J[ASSETS[0]] + 0.5 * J[ASSETS[1]]
d.index = pd.DatetimeIndex(d.index)
serie[nome] = d
celle.append(dict(cella=nome, sharpe=round(sh_of(d.to_numpy()), 3),
maxdd=round(dd_of(d.to_numpy()), 4),
cagr=round(float((1 + d).prod() ** (365.25 / len(d)) - 1), 4)))
C = pd.DataFrame(celle).sort_values("sharpe", ascending=False)
print(f" griglia: {len(feats)} feature x {len(wins)} finestre-z x {len(segni)} segni = {len(C)} celle")
print(C.head(6).to_string(index=False))
print(" ...")
print(C.tail(3).to_string(index=False))
best = C.iloc[0]["cella"]
bs = serie[best]
lo, hi = sharpe_boot(bs)
print(f"\n MIGLIORE della griglia: {best} Sharpe {C.iloc[0]['sharpe']:+.3f}")
print(f" IC95% block-bootstrap (blocchi 10g, 500 estrazioni): [{lo:+.2f}, {hi:+.2f}] n={len(bs)} giorni")
print(f" ⚠ ampiezza dell'IC = {hi-lo:.2f} di Sharpe: con {len(bs)} giorni la misura non separa")
print(" un edge da zero. Nessuna cella di questa griglia e' distinguibile dal rumore.")
dsr, sr0 = A.deflated_sharpe(C.iloc[0]["sharpe"], C["sharpe"].tolist(), bs)
print(f" deflated Sharpe della cella migliore: DSR={dsr:.3f} (soglia 0.95), "
f"max atteso sotto il nullo {sr0:+.2f}")
print(" NB: e' un deflated-Sharpe BEST-OF-GRID, non in-sample-selected: `select_cell_insample`")
print(" NON e' eseguibile qui (HOLDOUT=2025-01-01, l'in-sample e' VUOTO).")
print("\n marginal_vs_tp01 sulla cella migliore:")
try:
rep = A.marginal_vs_tp01(bs)
for k in ("marginal_verdict", "corr_full", "n_days", "has_insample_edge", "is_hedge",
"beats_noise_null", "robust_oos"):
if k in rep:
print(f" {k:20s} = {rep[k]}")
except Exception as ex:
print(f" non girato: {ex}")
print("\n causalita' (tagli DENTRO la finestra dello skew, non all'80% della storia prezzi):")
for a in ASSETS:
f_, w_, sg_ = best.split("|")
fn = make_target(cm30[a], f_, int(w_[1:]), int(sg_.replace("sg", "")))
print(f" {a}: {causality_finestra(cm30[a], fn, a)}")
# ------------------------------------------------------------------ 3. Q2
rule("3. Q2 — lo skew come GATE DI RISCHIO sopra TP01")
B = A.tp01_baseline_daily()
inizio = min(cm30[a].index.min() for a in ASSETS).floor("D")
Bw = B[B.index >= inizio]
z_all = {}
for a in ASSETS:
df = A.get(a, "1d")
m = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)) >= inizio
sub = df[m].reset_index(drop=True)
F = daily_signal(cm30[a], sub)
x = F["rr_n"]
z = (x - x.rolling(20, min_periods=10).mean()) / x.rolling(20, min_periods=10).std()
z_all[a] = pd.Series(z.to_numpy(), index=pd.DatetimeIndex(pd.to_datetime(sub["datetime"], utc=True)))
Z = pd.concat(z_all, axis=1).mean(axis=1)
Z = Z.reindex(Bw.index).ffill()
print(f" baseline TP01 sulla finestra dello skew: n={len(Bw)} giorni, "
f"Sharpe {sh_of(Bw.to_numpy()):+.2f}, maxDD {dd_of(Bw.to_numpy()):.2%}")
for thr in (-0.5, -1.0, -1.5):
gate = (Z.shift(1) > thr).astype(float) # de-risk quando lo skew si irripidisce (z basso)
ov = (Bw * gate).to_numpy()
n_fire = int((gate == 0).sum())
nul = null_de_levering(Bw.to_numpy(), ov)
gg = Bw[gate == 0]
print(f" soglia z<{thr:+.1f}: giorni de-riskati {n_fire:3d}/{len(Bw)} "
f"({n_fire/len(Bw):.1%}) Sharpe {sh_of(ov):+.2f} (base {sh_of(Bw.to_numpy()):+.2f}) "
f"maxDD {dd_of(ov):.2%}")
print(f" null de-levering: k={nul['k']} -> stesso DD {nul['dd_base_k']:.2%} con "
f"Sharpe {nul['sh_base_k']:+.2f} vs overlay {nul['sh_overlay']:+.2f} "
f"-> {'SUPERATO' if nul['superato'] else 'REFUTED'}")
if n_fire:
print(f" nei giorni de-riskati TP01 faceva in media {gg.mean()*100:+.3f}%/g "
f"(gli altri {Bw[gate==1].mean()*100:+.3f}%/g)")
print("\n ridondanza col trend — corr fra lo z dello skew e il rendimento di TP01: "
f"{float(pd.concat({'z': Z, 'b': Bw}, axis=1).dropna().corr().iloc[0,1]):+.3f}")
print(" POTENZA: con 75 giorni un gate che scatta ~10 volte non e' misurabile. Il numero da")
print(" guardare non e' lo Sharpe dell'overlay ma il conto dei giorni in cui scatta.")
# ------------------------------------------------------------------ 4. Q3
rule("4. Q3 — lo skew SPIEGA l'errore di prezzo di VRP01 (f=0.73 pubblicato il 30/07)")
D = vrp_decomposizione(q, cm7)
print(f" ingressi settimanali ricostruiti: {len(D)} ({D.groupby('asset').size().to_dict()})")
print("\n REPLICA del numero pubblicato (deve tornare ~0.72 sul canonico 7g d-0.28/-0.10):")
print(f" f_tot (fill conservativo) mediana = {D['f_tot'].median():.3f} "
f"media = {D['f_tot'].mean():.3f}")
print(f" f_tot ai MID mediana = {D['f_tot_mid'].median():.3f}")
print("\n IV per gamba contro i due riferimenti (pp di vol):")
print(f" IV(corta) - DVOL30 : {(D['iv_short']-D['dvol']).median():+.2f} pp "
f"IV(lunga) - DVOL30 : {(D['iv_long']-D['dvol']).median():+.2f} pp [pubblicato: +0.8 / +7.5]")
print(f" IV(corta) - ATM7 : {(D['iv_short']-D['atm7']).median():+.2f} pp "
f"IV(lunga) - ATM7 : {(D['iv_long']-D['atm7']).median():+.2f} pp")
print(f" ATM7 - DVOL30 : {(D['atm7']-D['dvol']).median():+.2f} pp <- pura struttura a termine")
print("\n DECOMPOSIZIONE MOLTIPLICATIVA del f (mediane; f_tot = term x skew x markfit x spread)")
for a in ASSETS + ("TUTTI",):
g = D if a == "TUTTI" else D[D["asset"] == a]
if g.empty:
continue
print(f" {a:6s} n={len(g):2d} | f_term {g['f_term'].median():.3f} | "
f"f_skew {g['f_skew'].median():.3f} | f_markfit {g['f_markfit'].median():.3f} | "
f"f_spread {g['f_spread'].median():.3f} || f_tot {g['f_tot'].median():.3f}")
print("\n controllo obbligatorio: f_markfit deve stare a ~1 (la IV di Deribit deve riprezzare")
print(f" i suoi stessi mid). Mediana {D['f_markfit'].median():.3f}, "
f"banda [{D['f_markfit'].quantile(.1):.3f}, {D['f_markfit'].quantile(.9):.3f}].")
print(" Se sta a 1, RR e BF di questa sessione sono un fatto di PREZZO, non un artefatto")
print(" del fit di Deribit -> vale anche come controllo di confound per Q1/Q2.")
print("\n Il f dipende dal REGIME? (la domanda che decide se 0.73 e' conservativo o ottimista)")
D2 = D.dropna(subset=["f_tot_mid", "rr7"])
for var in ("rr7", "atm7", "ivrank", "bf7"):
c = float(D2["f_tot_mid"].corr(D2[var]))
print(f" corr(f_tot_mid, {var:7s}) = {c:+.3f} [n={len(D2)}]")
print(f"\n IV-rank agli ingressi: min {D['ivrank'].min():.3f} mediana {D['ivrank'].median():.3f} "
f"max {D['ivrank'].max():.3f}")
n_gate = int((D["ivrank"] > 0.30).sum())
print(f" ingressi che passerebbero il gate IV-rank>0.30 di VRP01: {n_gate}/{len(D)}")
print(" -> il f di VRP01 e' misurato INTERAMENTE nel regime in cui il sleeve sta FLAT.")
print("\n Tabella per ingresso:")
cols = ["asset", "ts", "dte", "ivrank", "dvol", "atm7", "rr7", "iv_short", "iv_long",
"f_term", "f_skew", "f_markfit", "f_spread", "f_tot_mid", "f_tot"]
P = D[cols].copy()
P["ts"] = pd.DatetimeIndex(P["ts"]).strftime("%Y-%m-%d %H:%M")
for c in ("f_term", "f_skew", "f_markfit", "f_spread", "f_tot_mid", "f_tot"):
P[c] = P[c].round(3)
print(P.to_string(index=False))
rule(f"fine — {time.time()-t_start:.0f}s")
if __name__ == "__main__":
main()