Files
PythagorasGoal/scripts/research/r0822_term_structure.py
T

942 lines
53 KiB
Python

"""TERM-STRUCTURE della vol implicita BTC/ETH — pendenza, carry, gate di rischio (ondata 2026-08-22).
DOMANDA. Tre, dichiarate prima di misurare:
Q1 la PENDENZA (backwardation = front > back) PRECEDE i ritorni, o e' contemporanea?
Q2 il CARRY di vol (roll-down lungo la curva) e' INCASSABILE al bid-ask reale?
Q3 vale come GATE DI RISCHIO sopra TP01/SKH01?
IPOTESI A PRIORI (⚠️ da dove vengono: NON da una busta sigillata. Vengono dai precedenti gia'
DOCUMENTATI del progetto — i 4 gate di de-risk risultati ridondanti col trend, il verdetto HEDGE
su VRP-Z del 29/06, l'aritmetica fee/carry gia' fatta su VRP01 e gamma-scalping. Sono state
scritte prima di far girare QUESTO script, ma dopo una fase esplorativa sui dati: si dichiara,
perche' "l'avevo previsto" vale solo se si dice quanto costava prevederlo):
Q1 contemporanea. Il front reagisce allo spot: un tuffo alza la vol a 7g piu' di quella a 30g.
Se e' cosi', la pendenza e' un TERMOMETRO, non un segnale.
Q2 no. Il roll-down fra 7g e 30g vale frazioni di punto-vol al giorno; il bid-ask ATM di
Deribit vale punti-vol INTERI. L'aritmetica dovrebbe chiudere la questione senza backtest.
Q3 ridondante col trend, come i 4 precedenti (DVOL-spike, macro, funding, breadth): un gate di
de-risk lavora nei giorni in cui TP01 e' gia' flat.
IL PRECEDENTE SCOMODO (03/07, `r0703_vrpimp_*`): un gate di term-structure VIX/VXV su SPX valeva
+0.90 di Sharpe (DSR 0.992) ed era un **confound di modello al 100%** — la variabile del gate
coincideva con l'errore di prezzatura BS-flat vs term-structure. Da li' la regola: *riprezzare
term-structure-consistent prima di credere a un gate vol su strutture BS-flat*.
QUI QUEL CONFOUND NON PUO' ESISTERE: non si prezza nessuna opzione. Il sottostante e' il perp
BTC/ETH, i ritorni sono quelli del feed certificato, e la term structure entra solo come
VARIABILE OSSERVATA. Quindi il 03/07 non e' una scusa per non guardare — ed e' esattamente
percio' che serve cercare il confound *di questo* filone, che e' un altro:
⚠️ IL CONFOUND DI QUESTO FILONE: iv(7g) e' in larga parte una funzione della vol REALIZZATA
recente. Se e' cosi', `slope = iv30 - iv7` non e' un'informazione nuova ma un
RICONFEZIONAMENTO di RV — cioe' la stessa variabile di `dvol_directional.py` (VRP-Z), gia'
giudicata **HEDGE, earns_slot=False** su 5 anni il 2026-06-29. Misurato in SEZIONE 2.
IL DATO, e il muro che ci sta dentro. La term structure richiede >=2 scadenze nello STESSO
istante. `bite_archive` prima del **2026-06-09** collezionava **UNA scadenza per giro**: la
finestra utilizzabile NON e' "3,7 mesi di catena", e' **dal 2026-06-09** (~74 giorni).
Con 74 giorni **non esiste un hold-out** e SE(Sharpe) ~ sqrt(365/74) ~ 2.2: qualunque Sharpe
misurato qui e' a una deviazione standard da zero. Verdetto massimo: LEAD con gate e data.
Si conta cio' che e' QUOTATO (bid>0, ask>0, iv presente), non le righe: il guasto 29-30/07 ha
fino al 51% di quote vuote in un giorno.
METODO. ATM IV per scadenza = interpolazione della IV sul DELTA a 0.5 (call) / -0.5 (put), mai
estrapolata. Il delta referenzia il FORWARD, quindi la curva non eredita lo skew via la base
(prendere "lo strike piu' vicino allo spot" lo farebbe: a 90g il forward e' lontano dallo spot).
Interpolazione ai tenori fissi in VARIANZA TOTALE (w = iv^2 * T lineare in T), stile VIX.
nice -n 19 timeout 900 uv run python scripts/research/r0822_term_structure.py
"""
from __future__ import annotations
import os
import sys
import tempfile
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"))
import altlib as A # noqa: E402
RAW = ROOT / "data" / "raw"
STORE = RAW / "cb_chain"
CACHE = Path(os.environ.get("TMPDIR", tempfile.gettempdir())) / "r0822_ts_cache"
ASSETS = ("BTC", "ETH")
# Muro del dato: prima di questa data l'archivio ha UNA scadenza per giro -> nessuna curva.
TS_START = pd.Timestamp("2026-06-09", tz="UTC")
TENORS = (7, 14, 30, 60, 90)
FRONT, BACK = 7, 30 # la pendenza canonica di questo studio
DTE_MIN = 0.5 # sotto mezza giornata l'ATM IV e' rumore di microstruttura
# Vincoli di venue DICHIARATI (misurati il 2026-07-30 su `get_instrument`, non ri-letti qui:
# questo script non tocca la rete). Servono alla sezione di eseguibilita'.
MIN_LOT = {"BTC": 0.1, "ETH": 1.0} # contratti minimi opzioni Deribit
OPT_FEE_UNDERLYING = 0.0003 # 0,03% del sottostante per gamba
OPT_FEE_CAP_PREMIUM = 0.125 # cap: 12,5% del premio della singola opzione
CAPITALE = 635.0 # il conto vero
# Conteggio dei trial, AL RIALZO (sezione 6). Ogni voce e' una configurazione VALUTATA.
TRIALS: list[tuple[str, int]] = []
# I pochi numeri su cui poggia il verdetto, raccolti dalle sezioni che li misurano: il verdetto
# NON li riscrive a mano (un numero ricopiato in una conclusione e' un numero che smette di
# aggiornarsi quando i dati crescono).
FACTS: dict = {}
def hr(t: str = "") -> None:
print("\n" + "=" * 100)
if t:
print(t)
print("=" * 100)
def sub(t: str) -> None:
print(f"\n--- {t} " + "-" * max(0, 96 - len(t)))
# ===========================================================================================
# SEZIONE 0 — IL DATO
# ===========================================================================================
def load_quoted_chain() -> pd.DataFrame:
"""Catena filtrata IN LETTURA (colonne + finestra), tenendo solo cio' che e' QUOTATO.
"Quotato" = bid>0 AND ask>0 AND iv presente AND delta presente. Una riga con bid/ask NULL
esiste (il collettore la persiste) ma non e' un prezzo: contarla come dato e' l'errore che
il progetto ha gia' fatto tre volte (paper_dvolspread, fresh_5m, guasto 29/07).
"""
cols = ["asset", "option_type", "strike", "iv", "delta", "bid", "ask",
"vega", "ts", "exp", "quote_status"]
parts = []
arch = STORE / "bite_archive.parquet"
if arch.exists():
d = pd.read_parquet(arch, columns=cols)
parts.append(d[d["ts"] >= TS_START])
del d
for p in sorted(STORE.glob("2026-*.parquet")):
parts.append(pd.read_parquet(p, columns=cols))
if not parts:
raise FileNotFoundError(f"{STORE}: nessuna catena")
df = pd.concat(parts, ignore_index=True)
del parts
df = df.drop_duplicates(subset=["ts", "asset", "strike", "option_type", "exp"], keep="last")
# ⚠️ le quote di bite sono datetime64[us], il feed certificato e' [ns]: un join fra
# DatetimeIndex di unita' diverse degrada a Index generico e rompe ogni resample a valle.
df["hr"] = df["ts"].dt.floor("h").dt.as_unit("ns")
df["exp"] = df["exp"].dt.as_unit("ns")
df["dte"] = (df["exp"] - df["ts"]).dt.total_seconds() / 86400.0
df["quoted"] = (df["bid"] > 0) & (df["ask"] > 0) & df["iv"].notna() & df["delta"].notna()
return df
def sezione0(chain: pd.DataFrame) -> None:
hr("SEZIONE 0 — IL DATO: quanto ce n'e' davvero, e da quando")
print(f" finestra letta : {chain.ts.min()} -> {chain.ts.max()}")
print(f" righe : {len(chain):,}")
print(f" righe QUOTATE : {int(chain.quoted.sum()):,} ({chain.quoted.mean():.1%})")
print("\n ⚠️ IL MURO: la term structure richiede >=2 scadenze nello STESSO istante.")
arch = STORE / "bite_archive.parquet"
if arch.exists():
pre = pd.read_parquet(arch, columns=["asset", "ts", "exp"])
pre["hr"] = pre["ts"].dt.floor("h")
n_pre = pre[pre.ts < TS_START].groupby(["asset", "hr"])["exp"].nunique()
n_post = pre[pre.ts >= TS_START].groupby(["asset", "hr"])["exp"].nunique()
print(f" archivio PRIMA del {TS_START.date()}: scadenze/ora mediana = "
f"{n_pre.median():.0f} (max {n_pre.max():.0f}) su {len(n_pre)} ore-asset")
print(f" archivio DOPO : mediana = {n_post.median():.0f} "
f"(max {n_post.max():.0f}) su {len(n_post)} ore-asset")
print(" => la catena copre dal 2026-05-01, ma la CURVA esiste solo dal 2026-06-09.")
print(" Non sono 3,7 mesi: sono ~74 giorni. Nessun hold-out e' costruibile.")
del pre
q = chain.groupby([chain.hr.dt.date, "asset"])["quoted"].mean().unstack()
bad = q[(q < 0.70).any(axis=1)]
print(f"\n giorni con quote quotate <70% su almeno un asset: {len(bad)}")
if len(bad):
print(bad.round(3).to_string().replace("\n", "\n "))
print(" (guasto noto 29-30/07: rate-limit per-IP saturato dal collettore di bite.)")
print(f"\n minuto dello snapshot (mediana): archivio bite = "
f"{chain[chain.ts < pd.Timestamp('2026-07-30', tz='UTC')].ts.dt.minute.median():.0f}', "
f"raccolta propria = {chain[chain.ts >= pd.Timestamp('2026-07-31', tz='UTC')].ts.dt.minute.median():.0f}'")
print(" -> lo snapshot dell'ora t cade DENTRO la barra oraria t. Ogni ritorno usato come")
print(" bersaglio parte dalla CHIUSURA della barra t (>=35 min dopo): buffer causale.")
# ===========================================================================================
# SEZIONE 1 — RICOSTRUZIONE DELLA CURVA + CERTIFICAZIONE
# ===========================================================================================
def _interp_at(gid: np.ndarray, x: np.ndarray, y: np.ndarray, ngrp: int,
target: float) -> np.ndarray:
"""Interpolazione lineare di y(x) a `target`, per ogni gruppo, VETTORIALE e senza
estrapolazione (NaN se il gruppo non racchiude il target).
`gid` dev'essere ordinato crescente e (gid, x) lessicograficamente ordinati.
Equivalente a np.interp gruppo-per-gruppo: qui serve solo perche' i gruppi sono ~30.000
e un ciclo python li paga a caro prezzo su una VPS a 2 core condivisa.
"""
n_tot = np.bincount(gid, minlength=ngrp)
start = np.concatenate([[0], np.cumsum(n_tot)[:-1]])
n_below = np.bincount(gid[x <= target], minlength=ngrp)
ok = (n_below > 0) & (n_below < n_tot)
out = np.full(ngrp, np.nan)
if not ok.any():
return out
lo = (start + n_below - 1)[ok]
hi = lo + 1
x0, x1, y0, y1 = x[lo], x[hi], y[lo], y[hi]
w = np.where(x1 != x0, (target - x0) / np.where(x1 != x0, x1 - x0, 1.0), 0.0)
out[ok] = y0 + w * (y1 - y0)
return out
def build_atm_by_expiry(chain: pd.DataFrame) -> pd.DataFrame:
"""ATM IV per (asset, ora, scadenza) = IV interpolata sul DELTA a 0.5 (call) / -0.5 (put).
Perche' il delta e non "lo strike piu' vicino allo spot": il delta referenzia il FORWARD.
A 90 giorni il forward sta lontano dallo spot, quindi lo strike ATM-spot e' un'opzione OTM
e la sua IV porta dentro lo SKEW — che e' un altro filone e falserebbe la pendenza in modo
sistematico e crescente col tenore. Nessuna estrapolazione: se il delta 0.5 non e' racchiuso
dai quotati, quella scadenza a quell'ora non ha ATM e viene scartata.
"""
f = CACHE / "atm_by_expiry.parquet"
if f.exists():
return pd.read_parquet(f)
q = chain[chain.quoted & (chain.dte > DTE_MIN)].copy()
keys = ["asset", "hr", "exp"]
q["gid"] = q.groupby(keys, sort=True).ngroup()
ngrp = int(q["gid"].max()) + 1
ivs = []
for typ, tgt in (("C", 0.5), ("P", -0.5)):
s = q[q.option_type == typ].sort_values(["gid", "delta"])
v = np.full(ngrp, np.nan)
if len(s):
g2 = s["gid"].to_numpy()
uniq, inv = np.unique(g2, return_inverse=True)
v[uniq] = _interp_at(inv, s["delta"].to_numpy(), s["iv"].to_numpy(), len(uniq), tgt)
ivs.append(v)
M = np.vstack(ivs)
with np.errstate(invalid="ignore"):
cnt = np.sum(np.isfinite(M), axis=0)
iv = np.where(cnt > 0, np.nansum(np.where(np.isfinite(M), M, 0.0), axis=0) / np.maximum(cnt, 1), np.nan)
out = (q.groupby(keys, sort=True)["dte"].median().reset_index())
out["iv"] = iv
out = out.dropna(subset=["iv"]).reset_index(drop=True)
CACHE.mkdir(parents=True, exist_ok=True)
out.to_parquet(f)
return out
def _curve_row(g: pd.DataFrame) -> pd.Series:
g = g.sort_values("dte")
t = g["dte"].to_numpy()
w = (g["iv"].to_numpy() / 100.0) ** 2 * t # varianza totale
o = {}
for T in TENORS:
o[f"iv_{T}"] = (np.sqrt(np.interp(T, t, w) / T) * 100.0
if len(t) >= 2 and t.min() <= T <= t.max() else np.nan)
o["n_exp"] = len(t)
return pd.Series(o)
def build_curve(atm: pd.DataFrame) -> pd.DataFrame:
f = CACHE / "curve.parquet"
if f.exists():
return pd.read_parquet(f)
c = atm.groupby(["asset", "hr"]).apply(_curve_row, include_groups=False).reset_index()
CACHE.mkdir(parents=True, exist_ok=True)
c.to_parquet(f)
return c
def sezione1(curve: pd.DataFrame) -> dict:
hr("SEZIONE 1 — RICOSTRUZIONE DELLA CURVA E SUA CERTIFICAZIONE")
print(" ATM = IV interpolata sul delta 0.5/-0.5 (mai estrapolata); tenori fissi in varianza totale.")
cov = curve[[f"iv_{T}" for T in TENORS]].notna().mean()
print("\n copertura per tenore (frazione delle ore-asset ricostruibili):")
for T in TENORS:
print(f" iv_{T:<3d} {cov[f'iv_{T}']:.1%}")
print(f" -> iv_90 e' sotto il 10%: SCARTATO dallo studio. La pendenza canonica e' "
f"iv_{BACK} - iv_{FRONT}.")
sub("controllo 1 — contro il logger indipendente `vol_term_*.parquet` (altro codice, altra fonte)")
print(" (`log_vol_termstructure.py`: mark_iv da book_summary, ATM = strike piu' vicino allo")
print(" spot, interpolazione diversa. Se le due strade coincidono, la mia non e' un artefatto.)")
agree = {}
for a in ASSETS:
p = RAW / f"vol_term_{a.lower()}.parquet"
if not p.exists():
print(f" {a}: {p.name} assente — controllo NON GIRATO")
continue
vt = pd.read_parquet(p).set_index("date")
mine = curve[curve.asset == a].set_index("hr")
j = vt.join(mine[[f"iv_{T}" for T in TENORS]], how="inner")
for T in (7, 30, 60):
x, y = j[f"iv_{T}d"], j[f"iv_{T}"]
m = x.notna() & y.notna()
if m.sum() >= 10:
print(f" {a} iv_{T:<3d} n={m.sum():3d} corr={x[m].corr(y[m]):+.4f} "
f"scarto medio={(y[m] - x[m]).mean():+.3f} pt-vol")
agree[(a, T)] = float(x[m].corr(y[m]))
sub("controllo 2 — contro il DVOL (indice Deribit a 30g, storia 2021+)")
for a in ASSETS:
dv = pd.read_parquet(RAW / f"dvol_{a.lower()}.parquet")
s = pd.Series(dv["close"].astype(float).values,
index=pd.to_datetime(dv["timestamp"], unit="ms", utc=True)).sort_index()
mine = curve[curve.asset == a].set_index("hr")["iv_30"].dropna()
j = pd.DataFrame({"mine": mine})
j["dvol"] = s.reindex(j.index, method="ffill")
j = j.dropna()
print(f" {a}: n={len(j)} corr={j.mine.corr(j.dvol):+.4f} "
f"bias={(j.mine - j.dvol).mean():+.3f} pt-vol sd={(j.mine - j.dvol).std():.3f}")
print(" Il bias NEGATIVO e' atteso e conferma la ricostruzione: il DVOL e' un indice tipo")
print(" variance-swap (integra tutto lo smile) e sta STRUTTURALMENTE sopra l'ATM per lo skew.")
print(" Un bias ~0 sarebbe stato il segnale d'allarme, non questo.")
return agree
# ===========================================================================================
# SEZIONE 2 — Q1: LEAD-LAG. La pendenza precede o segue?
# ===========================================================================================
def slope_hourly(curve: pd.DataFrame, a: str) -> pd.Series:
s = curve[curve.asset == a].copy()
s["hr"] = pd.to_datetime(s["hr"], utc=True).dt.as_unit("ns")
s = s.set_index("hr")
return (s[f"iv_{BACK}"] - s[f"iv_{FRONT}"]).dropna().sort_index()
def px_hourly(a: str) -> pd.Series:
d = A.get(a, "1h")
return pd.Series(d["close"].astype(float).values,
index=pd.DatetimeIndex(pd.to_datetime(d["datetime"], utc=True)))
def _block_pctl(x: np.ndarray, y: np.ndarray, stat: float, L: int, n: int = 400,
seed: int = 20260822) -> float:
"""Percentile della correlazione osservata contro il null a BLOCCHI (y ricampionata da
blocchi non allineati a x): conserva l'autocorrelazione e rompe solo l'accoppiamento."""
rng = np.random.default_rng(seed)
m = len(x)
if m <= L + 5:
return float("nan")
nb = max(1, m // L)
out = np.empty(n)
for i in range(n):
ii = np.concatenate([np.arange(s, s + L) for s in rng.integers(0, m - L, nb)])
jj = np.concatenate([np.arange(s, s + L) for s in rng.integers(0, m - L, nb)])
out[i] = np.corrcoef(x[ii], y[jj])[0, 1]
return float((out < stat).mean())
def sezione2(curve: pd.DataFrame) -> dict:
hr("SEZIONE 2 — Q1: la pendenza PRECEDE i ritorni o li SEGUE?")
res = {}
for a in ASSETS:
sl = slope_hourly(curve, a)
px = px_hourly(a)
lp = np.log(px)
j = pd.DataFrame({"slope": sl}).join(px.rename("px"), how="inner").dropna()
idx = j.index
r1 = lp.diff()
sub(f"{a} — n ore = {len(j)} ({idx.min()} -> {idx.max()})")
FACTS[f"back_sev_{a}"] = float((j.slope < -5).mean())
FACTS[f"back_{a}"] = float((j.slope < 0).mean())
print(f" pendenza iv_{BACK}-iv_{FRONT}: media {j.slope.mean():+.2f} pt-vol, "
f"sd {j.slope.std():.2f}, backwardation nel {100*(j.slope<0).mean():.1f}% delle ore, "
f"SEVERA (< -5 pt-vol) nel {100*(j.slope<-5).mean():.1f}%")
print("\n (a) CROSS-CORRELAZIONE fra la VARIAZIONE oraria di pendenza e il ritorno orario.")
print(" lag<0 = ritorno PRIMA della variazione (il prezzo guida) | lag>0 = DOPO (la")
print(" pendenza guiderebbe). La barra t va da t a t+1h e lo snapshot cade dentro:")
print(" il lag -1 e' quindi in gran parte SOVRAPPOSTO, non un vero anticipo.")
ds = j.slope.diff()
row = []
for k in (-3, -2, -1, 0, 1, 2, 3, 6, 12, 24):
row.append((k, float(ds.corr(r1.shift(-k).reindex(idx)))))
print(" " + " ".join(f"{k:+d}h:{v:+.3f}" for k, v in row))
best = max(row, key=lambda t: abs(t[1]))
print(f" picco |corr| al lag {best[0]:+d}h ({best[1]:+.3f})")
print("\n (b) LIVELLO della pendenza vs ritorni STRETTAMENTE FUTURI (ingresso alla")
print(" chiusura della barra t) e vs ritorni PASSATI.")
for h in (1, 3, 6, 24, 72, 168):
fwd = (lp.shift(-h) - lp).reindex(idx)
pst = (lp - lp.shift(h)).reindex(idx)
m = j.slope.notna() & fwd.notna() & pst.notna()
print(f" h={h:4d}h corr(pendenza, FUTURO) {j.slope[m].corr(fwd[m]):+.3f} "
f"corr(pendenza, PASSATO) {j.slope[m].corr(pst[m]):+.3f}")
TRIALS.append((f"Q1 {a}: 6 orizzonti x (futuro,passato) x (livello,variazione)", 24))
print("\n (c) IL CONFOUND DI QUESTO FILONE — la pendenza e' informazione NUOVA o RV riciclata?")
rvp = (r1.rolling(24).std() * np.sqrt(24 * 365) * 100).reindex(idx)
# RV su (t, t+24h]: rolling(24) etichettata a t+24 e riportata indietro a t. (Una prima
# stesura aveva uno shift di troppo e misurava la finestra (+24h, +47h): corretto in
# sessione confrontando con una seconda implementazione a serie rovesciata.)
rvf = (r1.rolling(24).std().shift(-24) * np.sqrt(24 * 365) * 100).reindex(idx)
cf = curve[curve.asset == a].copy()
cf["hr"] = pd.to_datetime(cf["hr"], utc=True).dt.as_unit("ns")
ivf = cf.set_index("hr")[f"iv_{FRONT}"].reindex(idx)
c_pp = float(j.slope.corr(rvp))
c_pf = float(j.slope.corr(rvf))
print(f" corr(pendenza, RV 24h PASSATA) {c_pp:+.3f} <-- quanto e' termometro")
print(f" corr(pendenza, RV 24h FUTURA) {c_pf:+.3f} <-- quanto e' previsione")
print(f" corr(iv_{FRONT}, RV 24h passata) {float(ivf.corr(rvp)):+.3f} "
f"corr(iv_{FRONT}, RV futura) {float(ivf.corr(rvf)):+.3f}")
res[(a, "conf_past")], res[(a, "conf_fwd")] = c_pp, c_pf
FACTS[f"conf_past_{a}"], FACTS[f"conf_fwd_{a}"] = c_pp, c_pf
print("\n (d) La correlazione col futuro SOPRAVVIVE se si controlla per il PASSATO?")
print(" (regressione del ritorno futuro su pendenza + ritorno passato + RV passata;")
print(" se il coefficiente della pendenza evapora, il segnale era 'e' appena sceso')")
for h in (24, 72, 120):
fwd = (lp.shift(-h) - lp).reindex(idx)
pst = (lp - lp.shift(h)).reindex(idx)
m = j.slope.notna() & fwd.notna() & pst.notna() & rvp.notna()
X = np.column_stack([np.ones(m.sum()), j.slope[m], pst[m], rvp[m]])
y = fwd[m].to_numpy()
b, *_ = np.linalg.lstsq(X, y, rcond=None)
# errori standard Newey-West (lag = h, finestre sovrapposte)
e = y - X @ b
XtXi = np.linalg.pinv(X.T @ X)
S = (X * e[:, None]).T @ (X * e[:, None])
for L in range(1, h + 1):
w = 1.0 - L / (h + 1.0)
G = (X[L:] * e[L:, None]).T @ (X[:-L] * e[:-L, None])
S += w * (G + G.T)
se = np.sqrt(np.diag(XtXi @ S @ XtXi))
simple = float(j.slope[m].corr(fwd[m]))
print(f" h={h:4d}h corr semplice {simple:+.3f} | beta_pendenza={b[1]:+.5f} "
f"t(NW)={b[1]/se[1]:+.2f} | beta_ret_passato t={b[2]/se[2]:+.2f}")
TRIALS.append((f"Q1 {a}: 3 regressioni controllate", 3))
print("\n (e) Test giornaliero con null a BLOCCHI (la finestra e' di 74 giorni: la")
print(" correlazione va confrontata con la sua dispersione, non con zero).")
sld = j.slope.resample("1D").last().dropna()
pxd = px.resample("1D").last()
lpd = np.log(pxd)
for h in (1, 3, 5):
fwd = (lpd.shift(-h) - lpd).reindex(sld.index)
m = sld.notna() & fwd.notna()
x, y = sld[m].to_numpy(), fwd[m].to_numpy()
c = float(np.corrcoef(x, y)[0, 1])
p = _block_pctl(x, y, c, L=max(3 * h, 7))
c7 = float(np.corrcoef(x[:-7], y[:-7])[0, 1]) if len(x) > 20 else np.nan
c7b = float(np.corrcoef(x[7:], y[7:])[0, 1]) if len(x) > 20 else np.nan
if h == 5:
FACTS[f"c5_{a}"], FACTS[f"c5_no7_{a}"] = c, c7
print(f" h={h}g n={m.sum():3d} corr={c:+.3f} pctl vs null a blocchi={p:.3f}"
f" | senza ultima settimana {c7:+.3f} | senza prima settimana {c7b:+.3f}")
print(" ^ le due colonne di destra sono il test di RISOLUZIONE: se togliere 7")
print(" giorni su 74 sposta la correlazione di piu' di quanto la correlazione")
print(" valga, il campione non sta misurando, sta oscillando.")
TRIALS.append((f"Q1 {a}: 3 orizzonti giornalieri x 3 sottocampioni", 9))
return res
# ===========================================================================================
# SEZIONE 3 — Q2: il carry di vol e' incassabile?
# ===========================================================================================
def build_atm_cost(chain: pd.DataFrame) -> pd.DataFrame:
"""Costo REALE di attraversare lo spread su un'opzione ATM, in PUNTI DI VOL.
(ask-bid) e' quotato nel sottostante -> x spot = USD; / vega = punti di vol, che e' l'unita'
in cui si misura il roll-down. Cosi' costo e carry sono confrontabili senza modello.
"""
f = CACHE / "atm_cost.parquet"
if f.exists():
return pd.read_parquet(f)
q = chain[chain.quoted & (chain.dte > DTE_MIN) & (chain.option_type == "C") & (chain.vega > 0)].copy()
q["ad"] = (q["delta"] - 0.5).abs()
atm = q.sort_values("ad").groupby(["asset", "hr", "exp"], as_index=False).first()
atm = atm[atm.ad < 0.08]
for a in ASSETS:
p = px_hourly(a)
m = atm.asset == a
atm.loc[m, "spot"] = p.reindex(atm.loc[m, "hr"]).ffill().to_numpy()
atm = atm.dropna(subset=["spot"])
atm["spr_usd"] = (atm.ask - atm.bid) * atm.spot
atm["spr_vol"] = atm.spr_usd / atm.vega
atm["prem_usd"] = (atm.bid + atm.ask) / 2.0 * atm.spot
CACHE.mkdir(parents=True, exist_ok=True)
atm.to_parquet(f)
return atm
def _roll_table(r: pd.DataFrame, H: int) -> pd.DataFrame:
"""Roll IMPLICITO vs REALIZZATO su orizzonte H ore, appaiato per (scadenza, ISTANTE D'INGRESSO).
Appaiato sull'INGRESSO e non sull'uscita: e' l'uscita che le due letture spostano, e un
inner-join sulla data d'uscita terrebbe solo i casi in cui non e' successo niente (trappola
incontrata 3 volte nel progetto: venue-watch, GTAA, VRP profit-take).
impl = iv_curva(T-H) al tempo t - iv(T) al tempo t (se la curva non si muovesse)
real = iv(T-H) al tempo t+H - iv(T) al tempo t (cio' che e' successo davvero)
"""
r = r[["hr", "exp", "iv", "dte"]].dropna()
nxt = r[["hr", "exp", "iv"]].copy()
nxt["hr"] = nxt["hr"] - pd.Timedelta(hours=H)
j = r.merge(nxt.rename(columns={"iv": "iv_fut"}), on=["hr", "exp"], how="inner")
j["T2"] = j["dte"] - H / 24.0
j = j[j["T2"] > DTE_MIN]
if j.empty:
return pd.DataFrame(columns=["dte", "impl", "real"])
# curva dell'ora t, ordinata: chiave monotona globale gid*1000 + dte -> searchsorted unico
c = r.sort_values(["hr", "dte"]).copy()
c["gid"] = c.groupby("hr", sort=True).ngroup()
key = c["gid"].to_numpy() * 1000.0 + c["dte"].to_numpy()
w = (c["iv"].to_numpy() / 100.0) ** 2 * c["dte"].to_numpy() # varianza totale
gmap = dict(zip(c["hr"].to_numpy(), c["gid"].to_numpy()))
n_tot = np.bincount(c["gid"].to_numpy())
start = np.concatenate([[0], np.cumsum(n_tot)[:-1]])
gq = np.array([gmap[h] for h in j["hr"].to_numpy()])
pos = np.searchsorted(key, gq * 1000.0 + j["T2"].to_numpy(), side="left")
lo, hi = pos - 1, pos
ok = (lo >= start[gq]) & (hi < start[gq] + n_tot[gq])
ivT2 = np.full(len(j), np.nan)
if ok.any():
l, h2 = lo[ok], hi[ok]
x0 = key[l] - gq[ok] * 1000.0
x1 = key[h2] - gq[ok] * 1000.0
t2 = j["T2"].to_numpy()[ok]
ww = np.where(x1 != x0, (t2 - x0) / np.where(x1 != x0, x1 - x0, 1.0), 0.0)
var = w[l] + ww * (w[h2] - w[l])
ivT2[ok] = np.sqrt(np.maximum(var, 0.0) / t2) * 100.0
j = j.assign(ivT2=ivT2).dropna(subset=["ivT2"])
return pd.DataFrame({"dte": j["dte"].to_numpy(),
"impl": j["ivT2"].to_numpy() - j["iv"].to_numpy(),
"real": j["iv_fut"].to_numpy() - j["iv"].to_numpy()})
def sezione3(chain: pd.DataFrame, atm_exp: pd.DataFrame, curve: pd.DataFrame) -> None:
hr("SEZIONE 3 — Q2: il CARRY di vol (roll-down) e' incassabile al bid-ask reale?")
print(" Definizione senza modello: un'opzione a tenore T, dopo dt, sta a T-dt. Se la curva non")
print(" si muove la sua IV diventa iv(T-dt). Chi e' CORTO guadagna iv(T)-iv(T-dt) punti di vol.")
print(" Il costo di entrare e uscire e' lo spread bid-ask, misurabile negli STESSI punti di vol.")
sub("(a) quanto vale il roll-down, e viene DAVVERO incassato?")
print(" Confronto appaiato per SCADENZA e per ISTANTE D'INGRESSO (mai allineato sull'uscita):")
print(" roll IMPLICITO = iv_curva(T-dt) - iv(T) al tempo t ; roll REALIZZATO = iv(T-dt) a t+dt - iv(T) a t")
roll_day = {}
for H in (24, 168):
for a in ASSETS:
d = _roll_table(atm_exp[atm_exp.asset == a], H)
d = d[(d.dte >= 4) & (d.dte <= 45)]
if len(d) < 50:
print(f" {a} dt={H}h: campione insufficiente ({len(d)})")
continue
b = np.polyfit(d.impl, d.real, 1)[0]
diff = d.real - d.impl
nblk = max(1, len(d) // max(1, H)) # osservazioni ~indipendenti
t = diff.mean() / diff.std() * np.sqrt(nblk) if diff.std() > 0 else 0.0
print(f" {a} dt={H:3d}h n={len(d):5d} roll implicito medio {d.impl.mean():+.4f} pt-vol "
f"realizzato {d.real.mean():+.4f} beta(real~impl)={b:+.2f} "
f"corr={d.impl.corr(d.real):+.3f} (real-impl) t={t:+.2f}")
if H == 24:
roll_day[a] = -float(d.impl.mean()) # guadagno giornaliero di CHI E' CORTO
print("\n Lettura: se il roll-down fosse incassato, beta(real~impl) sarebbe ~+1. E' NEGATIVO")
print(" (~-1 a una settimana): sul campione la FORMA della curva torna indietro piu' di")
print(" quanto la curva rotoli. Il segno del bias da errore-di-misura sarebbe POSITIVO")
print(" (iv(T) compare in entrambi con segno opposto), quindi non spiega questo.")
TRIALS.append(("Q2: 2 orizzonti x 2 asset (roll implicito vs realizzato)", 4))
sub("(b) il costo reale: bid-ask ATM in PUNTI DI VOL, per tenore")
cost = build_atm_cost(chain)
cost["bucket"] = pd.cut(cost.dte, [DTE_MIN, 3, 10, 20, 45, 95],
labels=["1-3g", "3-10g", "10-20g", "20-45g", "45-95g"])
tab = cost.groupby(["asset", "bucket"], observed=True).agg(
n=("spr_vol", "size"), spread_ptvol=("spr_vol", "median"),
spread_pct_premio=("spr_usd", lambda s: np.nan),
premio_usd=("prem_usd", "median"), vega_usd_per_ptvol=("vega", "median"))
tab["spread_pct_premio"] = (cost.assign(x=cost.spr_usd / cost.prem_usd * 100)
.groupby(["asset", "bucket"], observed=True)["x"].median())
print(tab.round(3).to_string().replace("\n", "\n "))
sub("(c) l'aritmetica che chiude la domanda")
med = cost.groupby(["asset", "bucket"], observed=True)["spr_vol"].median()
for a in ASSETS:
rd = roll_day.get(a, np.nan)
sp = float(med.loc[(a, "10-20g")])
print(f" {a}: roll-down incassabile da CORTO = {rd:+.4f} pt-vol/giorno | "
f"un round-trip ATM 10-20g costa {sp:.3f} pt-vol")
print(f" => servono {sp/rd:.1f} GIORNI di carry per pagare UN solo giro, "
f"e nel frattempo si porta il vega.")
FACTS[f"giorni_carry_{a}"] = sp / rd
TRIALS.append(("Q2: 5 secchi di tenore x 2 asset (costo)", 10))
sub("(d) e a $635 la domanda non e' nemmeno questa: il LOTTO MINIMO")
for a in ASSETS:
c = cost[(cost.asset == a) & (cost.bucket == "10-20g")]
if c.empty:
continue
prem = float(c.prem_usd.median()) * MIN_LOT[a]
vega = float(c.vega.median()) * MIN_LOT[a]
spot = float(c.spot.median())
fee = min(OPT_FEE_UNDERLYING * spot, OPT_FEE_CAP_PREMIUM * float(c.prem_usd.median())) * MIN_LOT[a]
rd = roll_day.get(a, np.nan)
carry_g = rd * vega
friz = float(c.spr_vol.median()) * vega + 4 * fee # 1 spread pieno + 4 gambe di fee
print(f" {a}: lotto minimo {MIN_LOT[a]} -> premio ATM 10-20g = ${prem:,.2f} "
f"({prem/CAPITALE:.0%} del conto), vega ${vega:.2f}/pt-vol, fee ${fee:.2f}/gamba")
print(f" carry lordo ${carry_g:+.3f}/giorno per struttura; attrito per giro "
f"${friz:.2f} => {friz/carry_g:.0f} giorni per andare in pari")
print("\n Un calendar spread e' DUE gambe. Cio' che la tabella mostra e' il PREMIO di UNA")
print(" gamba al lotto minimo: BTC 26% del conto, ETH 10%. Il premio non e' tutto il")
print(" fabbisogno — la gamba CORTA vuole margine, e la misura del 2026-07-30 su una")
print(" struttura BTC a rischio definito dava $6.210 di collaterale per lotto. Quindi:")
print(" BTC resta fuori per il margine, ETH entra ma una sola struttura impegna il")
print(" 10-25% del conto per un carry LORDO di centesimi al giorno.")
print(" Il bersaglio dichiarato e' 50 EUR/giorno: qui siamo 2-3 ordini di grandezza sotto,")
print(" e il conto non regge la SECONDA struttura che servirebbe per diversificare.")
TRIALS.append(("Q2: 2 asset x eseguibilita' al lotto minimo", 2))
# ===========================================================================================
# SEZIONE 4 — Q3: gate di rischio sopra TP01. RIDONDANZA PRIMA DELL'UPLIFT.
# ===========================================================================================
def tp01_positions() -> dict:
from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio
tp = TrendPortfolio(**CANONICAL)
out = {}
for a in ASSETS:
df = A.get(a, "1d")
out[a] = pd.Series(tp.target_series(df),
index=pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)))
return out
def _anchored(sl_h: pd.Series, px_h: pd.Series, off: int):
"""Serie giornaliere (pendenza, ritorno) ancorate all'ora `off`."""
m = sl_h.index.hour == off
s = sl_h[m]
p = px_h.reindex(s.index).ffill()
r = p.pct_change()
return s, r
def sezione4(curve: pd.DataFrame) -> None:
hr("SEZIONE 4 — Q3: la pendenza come GATE DI RISCHIO sopra TP01. RIDONDANZA PRIMA DELL'UPLIFT")
print(" Regola del progetto: 4 gate di de-risk su 4 (DVOL-spike, macro, funding, breadth) sono")
print(" risultati RIDONDANTI col trend. Percio' la ridondanza si misura PRIMA di rivendicare")
print(" qualunque uplift: quanti dei giorni che il gate spegne sono giorni in cui TP01 e' gia' flat?")
pos = tp01_positions()
sub("(a) il fatto che decide da solo la sezione")
W = pd.concat(pos, axis=1).loc[TS_START:]
flat = (W.abs() < 0.05).mean()
FACTS["tp01_flat"] = float(flat.mean())
FACTS["n_giorni"] = int(len(W))
print(f" Nella finestra {TS_START.date()} -> oggi ({len(W)} giorni), TP01 e' FLAT nel "
f"{flat['BTC']:.1%} (BTC) / {flat['ETH']:.1%} (ETH) dei giorni.")
print(f" Esposizione media: BTC {W['BTC'].mean():.3f}, ETH {W['ETH'].mean():.3f} "
f"(target TSMOM risk-off, coerente col libro live dichiarato flat).")
print(" ⚠️ Un gate di de-risk su un libro gia' flat il 93% del tempo NON E' MISURABILE su")
print(" questa finestra: non c'e' rischio da togliere. Questo non e' un difetto del")
print(" gate, e' un difetto del CAMPIONE — e va detto prima di qualunque numero.")
sub("(b) ridondanza, per quel che il campione consente")
for a in ASSETS:
sl = slope_hourly(curve, a).resample("1D").last().dropna()
sl.index = pd.DatetimeIndex(sl.index).as_unit("ns")
j = pd.concat({"slope": sl, "tp": pos[a]}, axis=1, join="inner").dropna()
for qq in (0.10, 0.25, 0.40):
th = j.slope.quantile(qq)
off = j.slope < th
already = float((j.tp[off].abs() < 0.05).mean()) if off.any() else np.nan
print(f" {a} gate OFF sotto p{int(qq*100)} ({th:+.2f} pt-vol): spegne il "
f"{off.mean():.1%} dei giorni; di questi TP01 era GIA' flat nel {already:.1%} "
f"| corr(pendenza, esposizione TP01) = {j.slope.corr(j.tp):+.3f}")
TRIALS.append((f"Q3 {a}: 3 soglie di gate", 3))
print("\n Il segno di corr(pendenza, esposizione) e' NEGATIVO: la backwardation arriva")
print(" quando TP01 e' gia' uscito. E' la firma esatta dei 4 precedenti.")
sub("(c) uplift sulla finestra, su TUTTE le 24 ancore (mai su una sola)")
print(" Candidato DIREZIONALE (non gate): contango -> long, backwardation -> flat; vol-target")
print(" 14g, cap 2x, fee 5 bps/lato. Soglia a ZERO = a priori, nessun parametro tarato.")
for a in ASSETS:
sl_h, px_h = slope_hourly(curve, a), px_hourly(a)
def cand(off, s=sl_h, p=px_h):
s_d, r_d = _anchored(s, p, off)
d = pd.DataFrame({"slope": s_d, "r": r_d}).dropna()
if len(d) < 30:
return pd.Series(dtype=float)
vol = d["r"].rolling(14).std() * np.sqrt(365)
dirn = (d["slope"] > 0).astype(float)
tgt = np.clip(dirn * (0.20 / vol.replace(0, np.nan)), 0, 2.0).fillna(0.0)
hold = tgt.shift(1).fillna(0.0)
return (hold * d["r"] - A.FEE_SIDE * hold.diff().abs().fillna(0.0)).dropna()
def base(off, p=px_h, s=sl_h):
s_d, r_d = _anchored(s, p, off)
d = pd.DataFrame({"r": r_d}).dropna()
return d["r"]
band = A.anchor_luck_band(cand, list(range(24)), canonical=0)
print(f" {a}: Sharpe candidato — canonico(00:00) {band['canonical']:+.2f} "
f"(pctl {band['canonical_pctl']:.2f}) | MEDIANA ONESTA {band['median']:+.2f} | "
f"banda [{band['lo']:+.2f}, {band['hi']:+.2f}] | positivo in {band['frac_positive']:.0%} "
f"delle 24 ancore | gate_pass={band['gate_pass']}")
bh = A.anchor_luck_band(base, list(range(24)), canonical=0)
print(f" buy&hold sulla stessa finestra/ancore: mediana {bh['median']:+.2f} "
f"[{bh['lo']:+.2f}, {bh['hi']:+.2f}] <-- il vero termine di paragone in un toro")
dlt = A.anchor_luck_delta(cand, base, list(range(24)))
FACTS[f"vs_bh_{a}"] = dlt["median_paired"]
FACTS[f"vs_bh_n_{a}"] = (dlt["n_positive"], dlt["n_anchors"])
print(f" mediana delle DIFFERENZE APPAIATE candidato-B&H: {dlt['median_paired']:+.2f} "
f"(positiva in {dlt['n_positive']}/{dlt['n_anchors']} ancore)")
TRIALS.append((f"Q3 {a}: 24 ancore x 2 varianti", 48))
sub("(d) null del DE-LEVERING — obbligatorio su ogni claim di minor rischio")
for a in ASSETS:
sl_h, px_h = slope_hourly(curve, a), px_hourly(a)
s_d, r_d = _anchored(sl_h, px_h, 0)
d = pd.DataFrame({"slope": s_d, "r": r_d}).dropna()
vol = d["r"].rolling(14).std() * np.sqrt(365)
tgt = np.clip((d["slope"] > 0).astype(float) * (0.20 / vol.replace(0, np.nan)), 0, 2.0).fillna(0.0)
hold = tgt.shift(1).fillna(0.0)
net = (hold * d["r"] - A.FEE_SIDE * hold.diff().abs().fillna(0.0)).dropna()
dd_c = A._dd_ret(net)
# ⚠️ il k giusto e' il PIU' GRANDE che sta sotto il DD del candidato, non il primo che
# trovi salendo da 0.05: partendo dal basso si confronta il candidato con un baseline
# quasi spento, che ha un DD ridicolo e uno Sharpe gonfiato dal rapporto. (Errore
# commesso e corretto in sessione: dava "candidato sopra il null" con k=0.05.)
ks = np.arange(0.05, 3.01, 0.05)
best = None
for k in ks[::-1]:
b = k * d["r"].reindex(net.index)
if A._dd_ret(b) <= dd_c:
best = (float(k), A._sh(b), A._dd_ret(b))
break
sh_c = A._sh(net)
if best is None:
print(f" {a}: candidato Sharpe {sh_c:+.2f} maxDD {dd_c:.2%} | nessun k in "
f"[0.05,3.00] raggiunge quel DD")
else:
verdict = ("IL CANDIDATO NON SOPRAVVIVE (il de-levering fa meglio)"
if best[1] >= sh_c else "candidato sopra il null di de-levering")
FACTS[f"delev_{a}"] = bool(best[1] >= sh_c)
print(f" {a}: candidato Sharpe {sh_c:+.2f} maxDD {dd_c:.2%} | buy&hold a leva "
f"k={best[0]:.2f} -> stesso o minor DD ({best[2]:.2%}) con Sharpe {best[1]:+.2f}"
f" => {verdict}")
imp = A.implausible_sharpe(net)
FACTS[f"implaus_{a}"] = bool(imp["implausible"])
print(f" implausible_sharpe sul candidato di finestra: implausible="
f"{imp['implausible']} {imp['reasons']}")
TRIALS.append((f"Q3 {a}: 60 valori di k nel null de-levering", 60))
# ===========================================================================================
# SEZIONE 5 — IL TEST LUNGO. La pendenza ha un PROXY con 5 anni di storia?
# ===========================================================================================
def _proxy_slope(df: pd.DataFrame, asset: str, rvwin: int = 7) -> np.ndarray:
"""Proxy A PRIORI della pendenza su storia lunga: DVOL(30g) - RV(rvwin), tutto causale.
Motivazione, non scelta a posteriori: iv_30 ~ DVOL (corr 0.99, SEZIONE 1) e iv_7 e' in larga
parte una funzione della RV recente (SEZIONE 2c). Il proxy e' quindi la pendenza con la gamba
corta sostituita dalla sua determinante. NON e' la stessa variabile: l'attenuazione e'
misurata sotto e va portata dietro a ogni conclusione.
"""
c = df["close"].to_numpy(float)
r = A.simple_returns(c)
bpd = A.bars_per_day(df)
rv = A.realized_vol(r, max(2, rvwin * bpd), bpd * 365.25) * 100.0
return A.dvol(df, asset) - rv
def make_proxy_dir(long_only: bool = True, thr: float = 0.0):
def fn(df, asset):
p = _proxy_slope(df, asset)
d = np.where(np.isfinite(p), np.where(p > thr, 1.0, 0.0 if long_only else -1.0), 0.0)
return A.vol_target(d, df, target_vol=0.20, vol_win_days=30, leverage_cap=2.0)
return fn
def sezione5(curve: pd.DataFrame) -> None:
hr("SEZIONE 5 — IL TEST LUNGO: la pendenza ha un proxy con 5 anni di storia?")
print(" Con 74 giorni nessun gate statistico ha potenza. L'unica via per DIRE qualcosa e' un")
print(" proxy a priori su storia lunga — e dichiararne l'attenuazione.")
sub("(a) quanto e' buono il proxy (misurato sulla finestra vera, non assunto)")
for a in ASSETS:
sl = slope_hourly(curve, a).resample("1D").last().dropna()
sl.index = pd.DatetimeIndex(sl.index).as_unit("ns")
df = A.get(a, "1d")
p = pd.Series(_proxy_slope(df, a),
index=pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True))).dropna()
j = pd.concat({"vero": sl, "proxy": p}, axis=1, join="inner").dropna()
c = float(j.vero.corr(j.proxy))
print(f" {a}: corr(pendenza VERA, DVOL-RV7) = {c:+.3f} su n={len(j)} giorni "
f"-> attenuazione: il proxy spiega il {c**2:.0%} della varianza della pendenza")
print(" ⚠️ Un proxy a corr ~0.5-0.7 e' una lente SFOCATA: un risultato NEGATIVO sul proxy")
print(" e' evidenza piu' debole di un negativo sulla variabile vera. Va detto, non nascosto.")
sub("(b) il candidato direzionale sul proxy, 5 anni, coi gate veri")
fn = make_proxy_dir(long_only=True)
cau = A.causality_ok(fn, tf="1d")
print(f" causality_ok: {cau}")
cd = A.candidate_daily(fn, tf="1d")
era = cd[cd.index >= pd.Timestamp("2021-10-01", tz="UTC")]
print(f" era DVOL (2021-10 -> oggi, {len(era)} giorni): Sharpe {A._sh(era):+.3f} "
f"maxDD {A._dd_ret(era):.2%}")
rep = A.marginal_vs_tp01(cd)
FACTS["proxy_marginal"] = rep.get("marginal_verdict")
FACTS["proxy_sharpe"] = A._sh(era)
FACTS["proxy_uplift"] = rep["blends"]["w25"]["uplift_full"]
print(f" marginal_vs_tp01 -> {rep.get('marginal_verdict')} | corr a TP01 "
f"{rep.get('corr_full')} | uplift w25 full {rep['blends']['w25']['uplift_full']:+.3f} "
f"hold {rep['blends']['w25']['uplift_hold']}")
for k in ("is_hedge", "has_insample_edge", "robust_oos", "beats_noise_null", "earns_slot"):
if k in rep:
print(f" {k:20s} = {rep[k]}")
imp = A.implausible_sharpe(era)
print(f" implausible_sharpe (proxy, era DVOL): implausible={imp['implausible']} "
f"{imp['reasons']}")
print(f" earns_slot = (assoluto non-FAIL) AND (marginale ADDS): qui il marginale e' "
f"{rep.get('marginal_verdict')} -> **earns_slot = False**.")
TRIALS.append(("Sez.5: 2 varianti proxy (long-only, L/S) x 1 soglia", 2))
sub("(c) il precedente che questo replica")
print(" `dvol_directional.py` (2026-06-29) testo' VRP-Z = z-score causale di (IV-RV) come")
print(" segnale DIREZIONALE su 5 anni, con la griglia completa: verdetto **HEDGE,")
print(" earns_slot=False**. Qui, con la variante a SOGLIA BINARIA invece che a z-score,")
print(" il verdetto misurato e' NEUTRAL con uplift NEGATIVO — un verdetto diverso, la")
print(" stessa conclusione (earns_slot=False in entrambi). La differenza sta nella")
print(" variante, non nell'esito, e va riportata cosi': non sto dichiarando una replica")
print(" che non ho fatto.")
print(" Il punto strutturale resta: la pendenza e' il premio di vol con la gamba")
print(" REALIZZATA sostituita da una IMPLICITA (SEZIONE 2c: corr(pendenza, RV passata)")
print(" = -0.50/-0.57). Non e' una variabile nuova per questo progetto: e' quella, con")
print(" un rumore di misura piu' basso e 5 anni di storia in meno.")
# ===========================================================================================
# SEZIONE 6 — CONTI, GATE, VERDETTO
# ===========================================================================================
def sezione6() -> None:
hr("SEZIONE 6 — GRIGLIA DICHIARATA E DEFLATED SHARPE")
tot = sum(n for _, n in TRIALS)
for name, n in TRIALS:
print(f" {n:5d} {name}")
print(f" {'-'*70}\n {tot:5d} TOTALE (contato al RIALZO: ogni variante valutata, anche scartata)")
print("\n Non e' stata ridotta nessuna griglia per il budget: il vincolo di questo filone")
print(" non e' il tempo di calcolo, e' che il campione dura 74 giorni.")
print("\n deflated_sharpe: NON GIRATO su un candidato di questo studio, e il motivo e' una")
print(" scelta, non una dimenticanza: il DSR e' una correzione per multiple-testing su UNA")
print(" serie di ritorni; qui il candidato migliore ha 74 osservazioni e la sua incertezza")
print(" di CAMPIONE (SE(Sharpe) ~ 2.2) domina di un ordine di grandezza quella da selezione.")
print(f" Deflazionare {tot} trial su 74 giorni darebbe un numero preciso e privo di senso.")
print(" Sul proxy a 5 anni il DSR e' invece pertinente ed e' riportato dal gate del 29/06")
print(" su cui questo filone ricade (VRP-Z: earns_slot=False).")
# ===========================================================================================
# SEZIONE 7 — VERDETTO
# ===========================================================================================
def sezione7() -> None:
F = FACTS
hr("SEZIONE 7 — VERDETTO")
print(f"""
Q1 LA PENDENZA E' UN TERMOMETRO, NON UN SEGNALE — e la finestra non ha risoluzione.
· La cross-correlazione oraria ha il picco a lag -1h/0h (il prezzo si muove PRIMA); da
+1h in poi e' rumore (|corr| <= 0.05 su ogni lag fino a 24h).
· corr(pendenza, RV 24h PASSATA) = {F.get('conf_past_BTC', float('nan')):+.2f} (BTC) / {F.get('conf_past_ETH', float('nan')):+.2f} (ETH)
corr(pendenza, RV 24h FUTURA) = {F.get('conf_fwd_BTC', float('nan')):+.2f} (BTC) / {F.get('conf_fwd_ETH', float('nan')):+.2f} (ETH)
La pendenza SA qualcosa della vol futura, ma sa DI PIU' di quella passata: e' un premio
di vol con la gamba realizzata sostituita da una implicita, non una variabile nuova.
· L'unica correlazione col ritorno futuro che sembra qualcosa (h=5g: {F.get('c5_BTC', float('nan')):+.2f} BTC /
{F.get('c5_ETH', float('nan')):+.2f} ETH) NON SOPRAVVIVE alla rimozione dell'ULTIMA SETTIMANA:
diventa {F.get('c5_no7_BTC', float('nan')):+.2f} / {F.get('c5_no7_ETH', float('nan')):+.2f}. Sette giorni tolti da un campione di 70 ne
ribaltano il SEGNO — ed e' il test che conta piu' del percentile contro il null.
E nella regressione controllata per il ritorno passato il t di Newey-West sta sotto 1.0
a ogni orizzonte. Non e' "debole": e' sotto la risoluzione del campione.
Q2 IL CARRY C'E' MA NON PAGA IL BID-ASK, e non e' nemmeno incassato.
· Roll-down da corto: ~0.07 (BTC) / ~0.13 (ETH) punti-vol al GIORNO.
Round-trip ATM 10-20g: 1.26 (BTC) / 1.77 (ETH) punti-vol.
=> {F.get('giorni_carry_BTC', float('nan')):.0f} giorni (BTC) / {F.get('giorni_carry_ETH', float('nan')):.0f} giorni (ETH) di carry per pagare UN giro.
· E il roll NON viene incassato: beta(realizzato ~ implicito) e' NEGATIVO (~-1 a una
settimana), cioe' la forma della curva torna indietro piu' di quanto la curva rotoli.
La differenza realizzato-implicito e' dentro il rumore (|t| <= 0.9 a blocchi).
· A $635 il vincolo binding e' prima di tutto il LOTTO MINIMO: una gamba ATM costa il 26%
(BTC) / 10% (ETH) del conto in premio, la gamba corta vuole margine, e il carry lordo
di una struttura ETH e' ~$0.19/giorno. Il bersaglio dichiarato e' 50 EUR/giorno.
Q3 GATE DI RISCHIO: RIDONDANTE COL TREND — 5a occorrenza, e NON MISURABILE su questa finestra.
· TP01 e' FLAT nel {F.get('tp01_flat', float('nan')):.0%} dei giorni della finestra: non c'e' rischio da togliere.
Un gate che spegne il 25% dei giorni trova TP01 gia' flat nel 79-90% di quei giorni, e
corr(pendenza, esposizione TP01) e' NEGATIVA (-0.29 BTC / -0.52 ETH): la backwardation
arriva quando il trend e' gia' uscito. Firma identica ai 4 precedenti.
· Il candidato DIREZIONALE fa Sharpe ~3 su 24/24 ancore — e non vuol dire niente: il
buy&hold sulla stessa finestra fa altrettanto. Mediana delle DIFFERENZE APPAIATE contro
buy&hold: {F.get('vs_bh_BTC', float('nan')):+.2f} BTC (positiva in {F.get('vs_bh_n_BTC', (0, 0))[0]}/{F.get('vs_bh_n_BTC', (0, 0))[1]} ancore) e {F.get('vs_bh_ETH', float('nan')):+.2f} ETH ({F.get('vs_bh_n_ETH', (0, 0))[0]}/{F.get('vs_bh_n_ETH', (0, 0))[1]}).
· Null del de-levering: ETH {'NON superato' if F.get('delev_ETH') else 'superato'}, BTC {'NON superato' if F.get('delev_BTC') else 'superato'}.
· `implausible_sharpe` segnala ENTRAMBI i candidati di finestra (implaus BTC={F.get('implaus_BTC')},
ETH={F.get('implaus_ETH')}): Calmar 26-52 su 74 giorni di toro = il modo di perdita non e' nel
campione. Il gate qui funziona da controllo positivo del CAMPIONE, non della strategia.
TEST LUNGO (proxy DVOL-RV7, 5 anni): Sharpe {F.get('proxy_sharpe', float('nan')):+.2f}, marginale vs TP01 =
{F.get('proxy_marginal')}, uplift w25 {F.get('proxy_uplift', float('nan')):+.3f} (NEGATIVO), robust_oos=False
=> earns_slot = False. Il proxy e' sfocato (spiega il 21-48% della varianza della pendenza
vera), quindi il suo negativo e' evidenza PIU' DEBOLE di un negativo diretto — ma va nella
stessa direzione del verdetto HEDGE gia' dato a VRP-Z il 2026-06-29.
======================================================================================
VERDETTO: SCARTATO come sleeve e come gate. LEAD residuo SOLO sulla term structure come
PREVISORE DI VOL (non di ritorni), con gate pre-registrato qui sotto.
======================================================================================
Perche' non "LEAD" sul lato direzionale: un LEAD e' una cosa che vale la pena SORVEGLIARE, e
sorvegliare costa (la lezione DVOLSPREAD: 35 giorni di limbo). Qui il lato direzionale non ha
bisogno di altri dati per essere giudicato: e' gia' misurato su 5 anni via proxy (earns_slot
False) e la variabile e' quella di VRP-Z. Aprire un monitor sarebbe pagare due volte lo stesso
filone. Il lato del CARRY e' chiuso dall'aritmetica, che non migliora col tempo: il bid-ask
ATM di Deribit dovrebbe stringersi di 10-20x perche' il roll-down lo paghi.
GATE PRE-REGISTRATO (l'unico pezzo che il tempo puo' cambiare):
· COSA: la pendenza come previsore della RV a 24h (misurata: corr {F.get('conf_fwd_BTC', float('nan')):+.2f} / {F.get('conf_fwd_ETH', float('nan')):+.2f}).
Non serve a prendere posizione sul prezzo; servirebbe come DENOMINATORE del
vol-target, cioe' esattamente cio' che `tp01_dvol_overlay` (26/06) provo' col DVOL
a un tenore solo e trovo' essere puro de-levering. La curva a piu' tenori e' l'unica
variante di quell'esperimento mai fatta.
· QUANDO: 2027-02-01. A quella data la catena avra' ~8 mesi di curva (vs 2,4 oggi) e la
finestra conterra' almeno un regime di vol diverso da questo toro.
· SOGLIE (dichiarate ORA, non allora): si riapre SOLO se, sulla finestra estesa,
(a) corr(pendenza, RV futura 24h) resta <= -0.30 su ENTRAMBI gli asset, E
(b) la pendenza batte il DVOL a tenore singolo come previsore di RV
(R2 incrementale > 0 in una regressione che contiene gia' il DVOL: se non
aggiunge nulla al DVOL, la curva non serve e basta il tenore che abbiamo dal 2021), E
(c) il null del de-levering e' superato sul vol-target risultante.
Se (b) fallisce il filone si CHIUDE definitivamente: vorra' dire che la term
structure non porta informazione oltre il livello, e il livello ce l'abbiamo gia'.
· COSA MI SMENTIREBBE OGGI: una finestra che contenga un vero crash. Tutte e tre le risposte
qui sono misurate su 74 giorni di TORO con TP01 flat il {F.get('tp01_flat', float('nan')):.0%} del tempo. La backwardation
severa (pendenza < -5 pt-vol) copre il {F.get('back_sev_BTC', float('nan')):.1%} delle ore BTC e il {F.get('back_sev_ETH', float('nan')):.1%} delle ore
ETH, e mai dentro un drawdown vero del libro: se in un crash
la pendenza anticipasse i ritorni di ore invece di seguirli, Q1 cambierebbe risposta. Cio'
che NON cambierebbe e' Q2 (aritmetica del bid-ask) ne' l'eseguibilita' a $635.
""")
def main() -> None:
hr("r0822_term_structure — TERM STRUCTURE DELLA VOL IMPLICITA BTC/ETH")
chain = load_quoted_chain()
sezione0(chain)
atm_exp = build_atm_by_expiry(chain)
curve = build_curve(atm_exp)
sezione1(curve)
sezione2(curve)
sezione3(chain, atm_exp, curve)
del chain
sezione4(curve)
sezione5(curve)
sezione6()
sezione7()
hr("FINE")
if __name__ == "__main__":
main()