793 lines
43 KiB
Python
793 lines
43 KiB
Python
"""r0822_skew.py — la FORMA della superficie di volatilita' (skew), mai usata dal progetto.
|
|
|
|
Il progetto ha usato molte volte il LIVELLO della vol implicita (DVOL/ATM): VRP01 lo vende sotto
|
|
gate IV-rank, DVOLSPREAD ne fa relative-value BTC/ETH, TP01xDVOL lo prova come denominatore del
|
|
vol-target. Non ha MAI usato la FORMA: il risk reversal 25-delta (RR), il butterfly (BF) e la loro
|
|
dinamica. La catena per-strike esiste da 2026-05-01 (`data/raw/cb_chain/`), quindi la domanda e'
|
|
finalmente misurabile.
|
|
|
|
Tre domande, in ordine di valore dichiarato nel brief:
|
|
Q1 lo skew ANTICIPA lo spot? ("il posizionamento in opzioni anticipa il prezzo")
|
|
Q2 lo skew e' un GATE DI RISCHIO sopra il libro TP01?
|
|
Q3 lo skew spiega l'errore di PREZZATURA di VRP01 (f=0.73 misurato il 30/07)?
|
|
|
|
Onesta' strutturale, dichiarata PRIMA dei numeri:
|
|
* La finestra utile e' 2026-06-09 -> oggi, cioe' ~75 giorni, NON i 113 giorni di righe presenti.
|
|
Prima del 09/06 cerbero-bite raccoglieva UNA SOLA scadenza per ora (verificato: `exps=1`),
|
|
quindi non esiste ne' struttura a termine ne' maturita' costante. Righe presenti != dato
|
|
presente (regola 8 del brief), e qui il conto va fatto in ORE DI SUPERFICIE RICOSTRUIBILE.
|
|
* HOLDOUT del progetto = 2025-01-01: l'INTERO campione e' posteriore. Non esiste hold-out,
|
|
non esiste in-sample -> `study_family_honest` e `select_cell_insample` NON sono eseguibili
|
|
(l'in-sample e' vuoto). Il verdetto massimo ottenibile e' LEAD con gate pre-registrato.
|
|
* Con ~75 osservazioni giornaliere SE(Sharpe annualizzato) ~ sqrt(365/75) ~ 2.2: qualunque
|
|
Sharpe giornaliero misurato qui e' indistinguibile da zero. Percio' il peso della sessione
|
|
sta dove la POTENZA c'e' davvero: la relazione oraria skew<->prezzo (N~1.800) e la
|
|
decomposizione di prezzo di VRP01 (deterministica, non statistica).
|
|
|
|
Convenzioni di costruzione (sono SCELTE, non dettagli — vedi sezione 1):
|
|
* "quotato" = bid>0 AND ask>0 AND ask>=bid AND iv/delta non nulli. Si contano le quote, non le righe.
|
|
* smile per (asset, ora, scadenza): IV interpolata LINEARMENTE in |delta| a 0.25 e 0.50,
|
|
senza MAI estrapolare (se la catena non abbraccia il delta bersaglio, la cella si scarta).
|
|
* maturita' costante: RR/BF/ATM interpolati fra le due scadenze che abbracciano T (7g e 30g).
|
|
Il roll e' CONTINUO per costruzione: non c'e' una data di roll, quindi non c'e' il dente di
|
|
sega di tenore che un "scadenza piu' vicina a T" iniettera' nel segnale.
|
|
* ogni ora porta `ts_max` = il timestamp piu' recente fra le quote usate. I rendimenti in avanti
|
|
partono STRETTAMENTE dopo `ts_max`: senza questo l'ancora e' sbagliata di ~30 minuti e nasce
|
|
un falso "lo skew anticipa di 15 minuti" (misurato in sessione: vedi sezione 2).
|
|
|
|
Uso: nice -n 19 timeout 900 uv run python scripts/research/r0822_skew.py
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import glob
|
|
import os
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
sys.path.insert(0, str(ROOT))
|
|
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
|
|
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
|
|
|
import altlib as A # noqa: E402
|
|
import cblib as CB # noqa: E402
|
|
|
|
CHAIN = ROOT / "data" / "raw" / "cb_chain"
|
|
SCRATCH = Path(os.environ.get("SKEW_SCRATCH", "/tmp/claude-1001/-opt-docker-PythagorasGoal/"
|
|
"b6cc75e7-14f8-4c32-bd07-ab8a0d2aaee6/scratchpad"))
|
|
ASSETS = ("BTC", "ETH")
|
|
COLS = ["ts", "asset", "instrument_name", "option_type", "strike", "exp",
|
|
"bid", "ask", "mid", "iv", "delta", "open_interest", "quote_status"]
|
|
|
|
# tick MISURATO sui dati (non assunto): 0.0001 sotto 0.005 di premio, 0.0005 sopra.
|
|
def tick_of(prezzo: np.ndarray) -> np.ndarray:
|
|
return np.where(np.asarray(prezzo, float) < 0.005, 0.0001, 0.0005)
|
|
|
|
|
|
def _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). Stessa misura sui due")
|
|
print(" tronconi, piu' la copertura oraria (il guasto quote-vuote del 29-30/07 e' qui dentro):")
|
|
TAGLIO = pd.Timestamp("2026-07-30 20:00", tz="UTC")
|
|
for a in ASSETS:
|
|
S = cm30[a]
|
|
for nome, m in (("bite ", S.index < TAGLIO), ("nostra ", S.index >= TAGLIO)):
|
|
sub = S[m]
|
|
if len(sub) < 100:
|
|
continue
|
|
L2 = leadlag(sub, a)
|
|
av = L2[L2["minuti"].isin([5, 15, 60])]["corr_dRR"].to_numpy()
|
|
ind = L2[L2["minuti"].isin([-15, -60])]["corr_dRR"].to_numpy()
|
|
gg = len(np.unique(pd.DatetimeIndex(sub.index).date))
|
|
print(f" {a} {nome} ore={len(sub):5d} giorni={gg:3d} ({len(sub)/max(gg,1):.1f} ore/giorno)"
|
|
f" avanti(+5/+15/+60m) {np.round(av,3)} indietro(-15/-60m) {np.round(ind,3)}")
|
|
gio = pd.Series(1, index=pd.DatetimeIndex(cm30[ASSETS[0]].index)).groupby(
|
|
pd.DatetimeIndex(cm30[ASSETS[0]].index).date).sum()
|
|
peggio = gio.nsmallest(4)
|
|
print(f" giorni con MENO ore di superficie ricostruibile (BTC): "
|
|
f"{ {str(k): int(v) for k, v in peggio.items()} } su 24 attese")
|
|
|
|
print("\n Controllo POSITIVO — la stessa misura con l'ancora ASSUNTA a :30 dell'ora:")
|
|
FF = pd.concat([leadlag_ancora_sbagliata(cm30[a], a) for a in ASSETS], ignore_index=True)
|
|
print(FF.to_string(index=False))
|
|
|
|
# strategia giornaliera
|
|
rule("2b. Q1 — lo skew come SEGNALE DIREZIONALE giornaliero (griglia dichiarata)")
|
|
feats = ["rr", "rr_n", "drr"]
|
|
wins = [10, 20, 40]
|
|
segni = [+1, -1]
|
|
celle, serie = [], {}
|
|
for f in feats:
|
|
for w in wins:
|
|
for sg in segni:
|
|
nome = f"{f}|w{w}|sg{sg:+d}"
|
|
per_asset = {}
|
|
for a in ASSETS:
|
|
df = A.get(a, "1d")
|
|
Sx = cm30[a]
|
|
m = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)) >= (
|
|
Sx.index.min().floor("D"))
|
|
sub = df[m].reset_index(drop=True)
|
|
tgt = make_target(Sx, f, w, sg)(sub, a)
|
|
ev = A.eval_weights(sub, tgt)
|
|
per_asset[a] = pd.Series(ev["net"], index=ev["idx"])
|
|
J = pd.concat(per_asset, axis=1, join="inner").fillna(0.0)
|
|
d = 0.5 * J[ASSETS[0]] + 0.5 * J[ASSETS[1]]
|
|
d.index = pd.DatetimeIndex(d.index)
|
|
serie[nome] = d
|
|
celle.append(dict(cella=nome, sharpe=round(sh_of(d.to_numpy()), 3),
|
|
maxdd=round(dd_of(d.to_numpy()), 4),
|
|
cagr=round(float((1 + d).prod() ** (365.25 / len(d)) - 1), 4)))
|
|
C = pd.DataFrame(celle).sort_values("sharpe", ascending=False)
|
|
print(f" griglia: {len(feats)} feature x {len(wins)} finestre-z x {len(segni)} segni = {len(C)} celle")
|
|
print(C.head(6).to_string(index=False))
|
|
print(" ...")
|
|
print(C.tail(3).to_string(index=False))
|
|
best = C.iloc[0]["cella"]
|
|
bs = serie[best]
|
|
lo, hi = sharpe_boot(bs)
|
|
print(f"\n MIGLIORE della griglia: {best} Sharpe {C.iloc[0]['sharpe']:+.3f}")
|
|
print(f" IC95% block-bootstrap (blocchi 10g, 500 estrazioni): [{lo:+.2f}, {hi:+.2f}] n={len(bs)} giorni")
|
|
print(f" ⚠ ampiezza dell'IC = {hi-lo:.2f} di Sharpe: con {len(bs)} giorni la misura non separa")
|
|
print(" un edge da zero. Nessuna cella di questa griglia e' distinguibile dal rumore.")
|
|
dsr, sr0 = A.deflated_sharpe(C.iloc[0]["sharpe"], C["sharpe"].tolist(), bs)
|
|
print(f" deflated Sharpe della cella migliore: DSR={dsr:.3f} (soglia 0.95), "
|
|
f"max atteso sotto il nullo {sr0:+.2f}")
|
|
print(" NB: e' un deflated-Sharpe BEST-OF-GRID, non in-sample-selected: `select_cell_insample`")
|
|
print(" NON e' eseguibile qui (HOLDOUT=2025-01-01, l'in-sample e' VUOTO).")
|
|
|
|
print("\n marginal_vs_tp01 sulla cella migliore:")
|
|
try:
|
|
rep = A.marginal_vs_tp01(bs)
|
|
for k in ("marginal_verdict", "corr_full", "n_days", "has_insample_edge", "is_hedge",
|
|
"beats_noise_null", "robust_oos"):
|
|
if k in rep:
|
|
print(f" {k:20s} = {rep[k]}")
|
|
except Exception as ex:
|
|
print(f" non girato: {ex}")
|
|
|
|
print("\n causalita' (tagli DENTRO la finestra dello skew, non all'80% della storia prezzi):")
|
|
for a in ASSETS:
|
|
f_, w_, sg_ = best.split("|")
|
|
fn = make_target(cm30[a], f_, int(w_[1:]), int(sg_.replace("sg", "")))
|
|
print(f" {a}: {causality_finestra(cm30[a], fn, a)}")
|
|
|
|
# ------------------------------------------------------------------ 3. Q2
|
|
rule("3. Q2 — lo skew come GATE DI RISCHIO sopra TP01")
|
|
B = A.tp01_baseline_daily()
|
|
inizio = min(cm30[a].index.min() for a in ASSETS).floor("D")
|
|
Bw = B[B.index >= inizio]
|
|
z_all = {}
|
|
for a in ASSETS:
|
|
df = A.get(a, "1d")
|
|
m = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)) >= inizio
|
|
sub = df[m].reset_index(drop=True)
|
|
F = daily_signal(cm30[a], sub)
|
|
x = F["rr_n"]
|
|
z = (x - x.rolling(20, min_periods=10).mean()) / x.rolling(20, min_periods=10).std()
|
|
z_all[a] = pd.Series(z.to_numpy(), index=pd.DatetimeIndex(pd.to_datetime(sub["datetime"], utc=True)))
|
|
Z = pd.concat(z_all, axis=1).mean(axis=1)
|
|
Z = Z.reindex(Bw.index).ffill()
|
|
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))
|
|
|
|
rule(f"fine — {time.time()-t_start:.0f}s")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|