Files
PythagorasGoal/scripts/research/r0822_skew.py
T

840 lines
47 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 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"
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 _ms(ix: pd.DatetimeIndex) -> np.ndarray:
"""Epoca esplicita in millisecondi. Lezione 01/07: `.view("int64")` (e `.astype`) su un
DatetimeIndex tz-aware non-ns sbaglia scala o solleva -> merge_asof broadcasta in silenzio."""
return pd.DatetimeIndex(ix).tz_convert("UTC").tz_localize(None).astype("datetime64[ms]").astype("int64").to_numpy()
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.
Vettorizzato per bracketing (nessun ciclo per gruppo): per ogni chiave si prende la riga con
|delta| massimo SOTTO il bersaglio e quella con |delta| minimo SOPRA, poi si interpola fra
le due. Se una delle due manca, la cella si scarta -> non si estrapola MAI (una IV a 25 delta
ricavata estrapolando dall'ala e' un numero inventato, e sarebbe proprio dove il segnale
sembrerebbe piu' forte)."""
KEY = ["asset", "hr", "exp", "option_type"]
def bracket(d: pd.DataFrame, tgt: float) -> pd.DataFrame:
d = d[["asset", "hr", "exp", "option_type", "ad", "iv"]]
lo = d[d["ad"] <= tgt].sort_values("ad").groupby(KEY, sort=False, observed=True).tail(1)
hi = d[d["ad"] >= tgt].sort_values("ad").groupby(KEY, sort=False, observed=True).head(1)
m = lo.merge(hi, on=KEY, suffixes=("_lo", "_hi"))
span = (m["ad_hi"] - m["ad_lo"]).to_numpy()
w = np.where(span > 1e-12, (tgt - m["ad_lo"].to_numpy()) / np.where(span > 1e-12, span, 1.0), 0.0)
m["iv_t"] = m["iv_lo"].to_numpy() * (1 - w) + m["iv_hi"].to_numpy() * w
return m[KEY + ["iv_t"]]
# bastano le scadenze che possono ABBRACCIARE 7 o 30 giorni
d = q[q["dte"] <= 70.0]
b25 = bracket(d, 0.25).pivot_table(index=["asset", "hr", "exp"], columns="option_type",
values="iv_t", observed=True)
b50 = bracket(d, 0.50).pivot_table(index=["asset", "hr", "exp"], columns="option_type",
values="iv_t", observed=True)
R = pd.DataFrame(index=b25.index)
R["ivc25"] = b25.get("C")
R["ivp25"] = b25.get("P")
R = R.dropna(subset=["ivc25", "ivp25"]) # servono ENTRAMBE le ali, sempre
R["ivatm"] = b50.reindex(R.index)[[c for c in ("C", "P") if c in b50.columns]].mean(axis=1)
agg = d.groupby(["asset", "hr", "exp"], sort=False, observed=True).agg(
dte=("dte", "first"), ts_max=("ts", "max"), n_gambe=("iv", "size"))
R = R.join(agg, how="left").reset_index()
R["rr"] = R["ivc25"] - R["ivp25"]
R["bf"] = 0.5 * (R["ivc25"] + R["ivp25"]) - R["ivatm"]
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": _ms(chiusura)})
right = pd.DataFrame({"t_ms": _ms(pd.DatetimeIndex(S["ts_max"])),
"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)
MARKFIT_OK = (0.85, 1.15) # banda del CONTROLLO, dichiarata prima di guardare i f
def qualita(D: pd.DataFrame) -> pd.Series:
"""Filtro dichiarato: si tiene un'osservazione solo se il CONTROLLO passa, cioe' se la IV di
Deribit riprezza i suoi stessi mid entro +-15% sul credito, e se il credito conservativo e'
positivo. NON e' un filtro sull'esito (il f), e' un filtro sulla variabile di controllo:
dove il controllo fallisce non si sta misurando lo skew, si sta misurando una quota rotta."""
return (D["f_markfit"].between(*MARKFIT_OK)) & (D["f_spread"] > 0) & D["f_term"].gt(0)
def attribuzione_log(D: pd.DataFrame) -> pd.DataFrame:
"""Attribuzione ESATTA e additiva: log(f_tot) = log(f_term)+log(f_skew)+log(f_markfit)+
log(f_spread), per costruzione (la catena dei rapporti telescopia). Le MEDIANE dei fattori
non si moltiplicano fra loro; i log si sommano. Da qui la QUOTA del difetto per causa."""
g = D[qualita(D)]
parti = ["f_term", "f_skew", "f_markfit", "f_spread"]
L = np.log(g[parti])
tot = float(np.log(g["f_tot"]).mean())
rows = []
for c in parti:
m = float(L[c].mean())
rows.append(dict(fattore=c, media_log=round(m, 4),
fattore_geometrico=round(float(np.exp(m)), 3),
quota_del_difetto=round(m / tot, 3) if tot != 0 else np.nan))
rows.append(dict(fattore="TOTALE", media_log=round(tot, 4),
fattore_geometrico=round(float(np.exp(tot)), 3), quota_del_difetto=1.0))
return pd.DataFrame(rows)
def panel_f(q: pd.DataFrame, cm7: dict) -> pd.DataFrame:
"""La stessa decomposizione, ma a OGNI ora in cui lo spread e' costruibile (non solo ai 22
ingressi settimanali di VRP01). Serve alla RISOLUZIONE: 22 punti non separano due cause da
~10% l'una. ⚠ Le ore dentro la stessa scadenza NON sono indipendenti: il campione efficace
resta il numero di SCADENZE, che si riporta accanto."""
out = []
for a in ASSETS:
S7 = cm7[a]
S, V = CB.spot_series(a), CB.dvol_series(a)
p = q[(q["asset"] == a) & (q["option_type"] == "P")
& (q["dte"] >= 4.0) & (q["dte"] <= 10.0)]
for (hr, exp), g in p.groupby(["hr", "exp"], sort=False):
if hr not in S7.index:
continue
legs = CB.pick_legs(g)
if legs is None:
continue
ts = g["ts"].max()
dte = float((exp - ts).total_seconds() / 86400.0)
if not (4.0 <= dte <= 10.0):
continue
spot = float(S.asof(ts)); dvol = float(V.asof(ts)) / 100.0
atm7 = float(S7.loc[hr, "atm"]) / 100.0
T = dte / 365.25
c_mod = CB.bs_put(spot, legs["k_short"], T, dvol) - CB.bs_put(spot, legs["k_long"], T, dvol)
c_atm = CB.bs_put(spot, legs["k_short"], T, atm7) - CB.bs_put(spot, legs["k_long"], T, atm7)
c_sml = (CB.bs_put(spot, legs["k_short"], T, legs["iv_short"] / 100.0)
- CB.bs_put(spot, legs["k_long"], T, legs["iv_long"] / 100.0))
c_mid = (legs["mid_short"] - legs["mid_long"]) * spot
c_real = (legs["bid_short"] - legs["ask_long"]) * spot
if not (c_mod > 0 and c_atm > 0 and c_sml > 0):
continue
hist = V[V.index < ts]
ivr = float((hist < dvol * 100).mean()) if len(hist) else np.nan
out.append(dict(asset=a, hr=hr, exp=exp, dte=dte, rr7=float(S7.loc[hr, "rr"]),
atm7=float(S7.loc[hr, "atm"]), dvol=dvol * 100, ivrank=ivr,
f_term=c_atm / c_mod, f_skew=c_sml / c_atm,
f_markfit=c_mid / c_sml, f_spread=c_real / c_mid,
f_tot=c_real / c_mod, f_tot_mid=c_mid / c_mod))
return pd.DataFrame(out)
# =============================================================================
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))
fw = LL[LL["minuti"] > 0]
print(f"\n LAG IN AVANTI: {len(fw)} test ({fw['minuti'].nunique()} orizzonti x {len(ASSETS)} asset).")
print(f" |t| massimo osservato = {fw['t_dRR'].abs().max():.2f} su {len(fw)} test;")
print(f" |t| atteso come MASSIMO di {len(fw)} test indipendenti sotto il nullo ~ "
f"{float(np.abs(np.random.default_rng(0).standard_normal((4000, len(fw)))).max(axis=1).mean()):.2f}.")
print(" -> il massimo in avanti e' quello che il rumore produce da solo. LAG INDIETRO:")
print(f" |t| minimo = {LL[LL['minuti'] < 0]['t_dRR'].abs().min():.2f} su "
f"{int((LL['minuti'] < 0).sum())} test, tutti dello STESSO segno.")
print("\n Robustezza — il COLLETTORE cambia a meta' campione (cerbero-bite: ~10 giri/ora,")
print(" fino al 2026-07-30; raccolta nostra: 1 giro/ora al minuto :25) e il campione contiene")
print(" UN evento enorme (BTC +26% nella settimana al 21/08). Le due cose vanno separate:")
TAGLIO = pd.Timestamp("2026-07-30 20:00", tz="UTC")
RALLY = pd.to_datetime(["2026-08-19", "2026-08-20", "2026-08-21", "2026-08-22"]).tz_localize("UTC")
for a in ASSETS:
S = cm30[a]
gg_rally = pd.DatetimeIndex(S.index).normalize().isin(RALLY)
for nome, m in (("bite ", S.index < TAGLIO),
("nostra ", S.index >= TAGLIO),
("nostra SENZA rally", (S.index >= TAGLIO) & ~gg_rally)):
sub = S[m].sort_values("ts_max")
if len(sub) < 100:
continue
px = px5m(a, sub.index.min())
anc = pd.Series(px.reindex(pd.DatetimeIndex(sub["ts_max"]), method="ffill").to_numpy(),
index=sub.index)
d = sub["rr"].diff()
av = []
for mins in (5, 15, 60):
p2 = pd.Series(px.reindex(pd.DatetimeIndex(sub["ts_max"]) + pd.Timedelta(minutes=mins),
method="ffill").to_numpy(), index=sub.index)
j = pd.concat({"d": d, "r": np.log(p2 / anc)}, axis=1).dropna()
av.append((round(float(j["d"].corr(j["r"])), 3),
round(float(j["d"].corr(j["r"], method="spearman")), 3)))
print(f" {a} {nome} n={len(sub):5d} avanti +5/+15/+60m (pearson,spearman): {av}")
print(" -> l'unica cella con un lead in avanti apparente (BTC, raccolta nostra, +5/+15m)")
print(" PERDE i due terzi togliendo 4 giorni su 24: era il rally, non un meccanismo.")
gio = pd.Series(1, index=pd.DatetimeIndex(cm30[ASSETS[0]].index)).groupby(
pd.DatetimeIndex(cm30[ASSETS[0]].index).date).sum()
print(f" copertura: giorni con meno ore di superficie (BTC) "
f"{ {str(k): int(v) for k, v in gio.nsmallest(4).items()} } su 24 attese "
f"(il guasto quote-vuote del 29-30/07 e' dentro il campione e NON crea un buco: le")
print(" quote assenti erano gia' escluse dal filtro 'quotato').")
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}")
imp = A.implausible_sharpe(bs)
print(f"\n implausible_sharpe sulla cella migliore: implausible={imp['implausible']} "
f"reasons={imp.get('reasons')}")
print(f" (Sharpe {imp.get('sharpe')}, maxDD {imp.get('maxdd')}, "
f"perdite sulle barre ATTIVE {imp.get('loss_frac')}, attive {imp.get('active_frac')})")
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()
att = int((Bw != 0).sum())
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%}")
print(f" ⚠ barre ATTIVE del baseline: {att}/{len(Bw)} ({att/len(Bw):.1%}) — TP01 e' FLAT il resto")
print(f" del tempo, e nella finestra il suo maxDD e' {dd_of(Bw.to_numpy()):.2%}: un gate di")
print(" de-risk non ha NIENTE da proteggere qui. Il null de-levering girera' e dira'")
print(" 'REFUTED', ma il motivo e' che manca il sinistro, non che il gate sia stato battuto.")
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:
gia_flat = int((gg == 0).sum())
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); di quei {n_fire} giorni TP01 era "
f"GIA' FLAT in {gia_flat} ({gia_flat/n_fire:.0%}) -> il gate spegne cio' che era gia' spento")
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 ATTRIBUZIONE ESATTA (in log: i fattori si SOMMANO, le mediane no)")
Q = qualita(D)
print(f" osservazioni che passano il CONTROLLO f_markfit in {MARKFIT_OK} e credito>0: "
f"{int(Q.sum())}/{len(D)}")
scartate = D[~Q][["asset", "ts", "f_markfit", "f_spread"]].copy()
if len(scartate):
scartate["ts"] = pd.DatetimeIndex(scartate["ts"]).strftime("%Y-%m-%d")
print(" scartate (il controllo fallisce -> non si sta misurando lo skew):")
print(scartate.round(3).to_string(index=False))
print(attribuzione_log(D).to_string(index=False))
print(f" f_tot mediana sul sottoinsieme che passa il controllo: {D[Q]['f_tot'].median():.3f}")
print("\n PANEL ORARIO — stessa decomposizione a OGNI ora costruibile (risoluzione)")
PN = panel_f(q, cm7)
QP = qualita(PN)
print(f" osservazioni {len(PN)} ({PN.groupby('asset').size().to_dict()}), di cui passano il "
f"controllo {int(QP.sum())}; scadenze distinte (campione EFFICACE) "
f"{PN.groupby('asset')['exp'].nunique().to_dict()}")
G = PN[QP]
for a in ASSETS + ("TUTTI",):
g = G if a == "TUTTI" else G[G["asset"] == a]
if g.empty:
continue
print(f" {a:6s} n={len(g):5d} | 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(" attribuzione log sul panel:")
print(attribuzione_log(PN).to_string(index=False))
print("\n Il f dipende dal REGIME? (la domanda che decide se 0.73 e' conservativo o ottimista)")
D2 = D[qualita(D)].dropna(subset=["f_tot_mid", "rr7"])
for var in ("rr7", "atm7", "ivrank", "bf7"):
c = float(D2["f_tot_mid"].corr(D2[var]))
cg = float(G["f_tot_mid"].corr(G[var])) if var in G.columns else np.nan
print(f" corr(f_tot_mid, {var:7s}) = {c:+.3f} [ingressi n={len(D2)}]"
+ (f" {cg:+.3f} [panel n={len(G)}]" if np.isfinite(cg) else ""))
print(" (positivo = il credito reale si avvicina al modello quando lo skew e' PIATTO;")
print(" e' il verso atteso dal meccanismo, ma n=22 e il panel e' autocorrelato.)")
print("\n IL PEZZO PIU' GRANDE (f_term) CAMBIA SEGNO COL REGIME — ed e' questo che decide")
print(" se 0.73 e' conservativo o ottimista dove VRP01 tradera' DAVVERO.")
G2 = G.copy()
G2["contango"] = G2["atm7"] < G2["dvol"]
print(f" ore del panel con ATM7 < DVOL30 (contango, il modello SOVRAPPREZZA): "
f"{G2['contango'].mean():.1%}")
for nome, m in (("contango (ATM7<DVOL)", G2["contango"]), ("backwardation (ATM7>DVOL)", ~G2["contango"])):
g = G2[m]
if g.empty:
continue
print(f" {nome:26s} n={len(g):5d} | f_term {g['f_term'].median():.3f} | "
f"f_skew {g['f_skew'].median():.3f} | f_tot {g['f_tot'].median():.3f} | "
f"IV-rank mediano {g['ivrank'].median():.3f}")
qs = pd.qcut(G2["ivrank"], 4, labels=["q1", "q2", "q3", "q4"], duplicates="drop")
st = G2.groupby(qs, observed=True).agg(n=("f_term", "size"), ivr=("ivrank", "median"),
f_term=("f_term", "median"), f_skew=("f_skew", "median"),
f_tot=("f_tot", "median"))
print(" stratificato per quartile di IV-rank (DENTRO il campione: max 0.23, il gate e' 0.30):")
print(st.round(3).to_string())
print(" -> f_term sale con l'IV-rank; f_skew no. L'estrapolazione oltre 0.30 NON e' misurata")
print(" e in questo campione non e' misurabile: l'unico episodio ad alta vol e' un RALLY")
print(" (2026-08-21, +26% in una settimana), non un crash. Manca il regime che conta.")
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))
# ------------------------------------------------------------ 5. SOMMARIO
rule("5. SOMMARIO — cosa regge, cosa no, e cosa non e' misurabile con 75 giorni")
print("""
Q1 lo skew NON anticipa lo spot: e' lo spot che muove lo skew.
Il prezzo precede il RR con t da 3.1 a 9.4 su 8 test su 8 e su entrambi gli asset;
in avanti il |t| massimo su 16 test e' 2.35 contro un massimo atteso dal rumore di 2.08,
non replica sul secondo asset, e l'unica cella apparente perde i due terzi togliendo
4 giorni di rally su 24. La strategia giornaliera migliore su 18 celle fa Sharpe +2.61
con IC95% [-2.90, +5.86] e DSR 0.163 -> SCARTATO.
Q2 gate di rischio: NON MISURABILE su questa finestra, e la ragione e' strutturale.
Nei 75 giorni TP01 e' attivo 5 giorni su 75 e il suo maxDD e' 0.14%: non c'e' sinistro
da prevenire. Cio' che si vede: alle soglie z<-1.0 e z<-1.5 il gate spegne giorni in cui
TP01 era GIA' flat nel 100% dei casi -> 5a conferma della ridondanza col trend, ma su un
campione senza potenza. Il null de-levering gira e dice REFUTED per assenza di DD, non
per merito del baseline.
Q3 il risultato della sessione, e non e' una strategia: e' una CORREZIONE DI PREZZATURA.
Il f=0.73 di VRP01 e' replicato in modo indipendente (0.714 su 22 ingressi, 0.723 su 2.582
ore) e poi SCOMPOSTO. La causa maggiore NON e' lo skew:
struttura a termine 42% | spread denaro-lettera 30% | skew 25% | fit 2%
Il sleeve prezza un'opzione a 7 giorni con il DVOL a 30 giorni, che nel 90% delle ore sta
~3 punti di vol SOPRA l'ATM a 7 giorni -> sovrapprezza entrambe le gambe. Il "+0.8pp sulla
gamba corta" pubblicato il 30/07 (qui +0.20pp) e' la CANCELLAZIONE di due errori da ~3pp:
-2.97pp di termine e +2.79pp di skew. Guardando le gambe contro l'ATM a 7 giorni si vede
che il modello sbaglia su ENTRAMBE, non solo sull'ala.
E il pezzo grande CAMBIA SEGNO col regime: f_term 0.83 nel quartile basso di IV-rank ->
0.93 nel quartile alto, e 1.05 nelle ore in backwardation, mentre f_skew resta piatto
(0.93 -> 0.92). Cioe' l'errore piu' grande si annulla proprio dove il gate IV-rank fa
tradare il sleeve.
""")
rule(f"fine — {time.time()-t_start:.0f}s")
if __name__ == "__main__":
main()