783 lines
41 KiB
Python
783 lines
41 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 (registrate prima di guardare i numeri — servono a poterle smentire):
|
|
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]] = []
|
|
|
|
|
|
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")
|
|
df["hr"] = df["ts"].dt.floor("h")
|
|
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].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()})")
|
|
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")
|
|
|
|
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)
|
|
rvf = (r1.shift(-24).rolling(24).std().shift(-23) * np.sqrt(24 * 365) * 100).reindex(idx)
|
|
ivf = curve[curve.asset == a].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
|
|
|
|
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))
|
|
print(f" h={h}g n={m.sum():3d} corr={c:+.3f} pctl vs null a blocchi={p:.3f}")
|
|
TRIALS.append((f"Q1 {a}: 3 orizzonti giornalieri", 3))
|
|
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.")
|
|
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(f"\n Un calendar spread e' DUE gambe: a $635 una sola struttura ETH impegna il")
|
|
print(f" ~15-30% del conto e rende centesimi al giorno. BTC e' fuori dal lotto minimo.")
|
|
print(f" Il bersaglio dichiarato e' 50 EUR/giorno: qui si parla di due ordini di grandezza sotto.")
|
|
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()
|
|
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()
|
|
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)))
|
|
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)
|
|
best = None
|
|
for k in np.arange(0.05, 1.01, 0.05):
|
|
b = k * d["r"].reindex(net.index)
|
|
if A._dd_ret(b) <= dd_c:
|
|
best = (k, A._sh(b), A._dd_ret(b))
|
|
break
|
|
print(f" {a}: candidato Sharpe {A._sh(net):+.2f} maxDD {dd_c:.2%} | "
|
|
+ (f"buy&hold de-leverato k={best[0]:.2f} -> stesso DD ({best[2]:.2%}) con Sharpe "
|
|
f"{best[1]:+.2f} => {'IL CANDIDATO NON SOPRAVVIVE' if best[1] >= A._sh(net) else 'candidato sopra il null'}"
|
|
if best else "nessun k<=1 raggiunge quel DD"))
|
|
TRIALS.append((f"Q3 {a}: 20 valori di k nel null de-levering", 20))
|
|
|
|
|
|
# ===========================================================================================
|
|
# 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()
|
|
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)
|
|
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: flagged={imp.get('flagged')} ({imp.get('reasons')})")
|
|
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** — paga solo quando TP01 e' debole, quindi non e' alpha.")
|
|
print(" La pendenza della term structure e', su questa lente, la stessa variabile con la")
|
|
print(" gamba realizzata sostituita da una implicita. Il filone non e' nuovo: e' quello.")
|
|
|
|
|
|
# ===========================================================================================
|
|
# 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).")
|
|
|
|
|
|
def main() -> None:
|
|
hr("r0822_term_structure — TERM STRUCTURE DELLA VOL IMPLICITA BTC/ETH")
|
|
print(__doc__.split("METODO.")[0].strip()[:0] or "", end="")
|
|
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()
|
|
hr("FINE")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|