Files

692 lines
40 KiB
Python

"""r0822 — DEALER GAMMA: il regime di gamma dei dealer separa mean-reversion da trend su BTC/ETH?
Filone dell'ondata 2026-08-22. Dato mai usato dal progetto: `data/raw/cb_market_snapshots.parquet`
(ereditato da cerbero-bite, dismesso il 30/07) con `dealer_net_gamma`, `gamma_flip_level`,
`oi_delta_pct_4h`, `liquidation_*_risk`.
IPOTESI DI MERCATO CLASSICA (due gambe, vanno confermate ENTRAMBE):
(A) dealer LONG gamma -> l'hedging SOPPRIME la vol realizzata; SHORT gamma -> la AMPLIFICA;
(B) dealer LONG gamma -> il prezzo MEAN-REVERTE; SHORT gamma -> il prezzo TRENDA.
La gamba tradeable e' la (B): e' quella che diventerebbe un gate di regime sopra TP01/SKH01.
Aspettativa dichiarata PRIMA di misurare: (A) plausibile ma confusa con il livello di vol;
(B) improbabile su 3 mesi, e comunque a rischio "TP01 travestito".
ORDINE OBBLIGATO: prima la validazione del DATO (non e' certificato, la fonte non esiste piu'),
poi il FATTO condizionale, e solo dopo — se il fatto c'e' — il gate.
Girare: nice -n 19 timeout 900 uv run python scripts/research/r0822_dealer_gamma.py
"""
from __future__ import annotations
import glob
import sys
import warnings
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
warnings.filterwarnings("ignore")
pd.set_option("display.width", 220)
RAW = ROOT / "data" / "raw"
SNAP_F = RAW / "cb_market_snapshots.parquet"
CHAIN_D = RAW / "cb_chain"
SEED = 20260822
ASSETS = ("BTC", "ETH")
HOUR_YEAR = 24 * 365.0
ANN = np.sqrt(HOUR_YEAR) * 100.0 # da dev.std. oraria a vol annua in punti percentuali
# La cadenza a 15 min comincia qui: prima ci sono 37 punti GIORNALIERI (vedi sezione 1).
HF_START = pd.Timestamp("2026-05-01 15:00", tz="UTC")
# ------------------------------------------------------------------ conteggio dei trial
# Ogni cella/statistica valutata viene contata qui, AL RIALZO (regola: il conteggio dei trial
# e' l'unico posto dove barare e' indolore e invisibile — 30/07). Il verdetto stampa la
# ripartizione DERIVATA da questo dizionario, non una somma scritta a mano.
COUNT: dict[str, int] = dict(ricostruzione=0, sma=0, regressioni=0, vol_condizionata=0,
variance_ratio=0, momentum=0, gate=0, delevering=0)
NEFF: dict[str, float] = {} # ampiezza effettiva per asset, misurata in sezione 1e
def hr(t: str) -> None:
print("\n" + "=" * 96 + f"\n{t}\n" + "=" * 96)
def sh_d(x: pd.Series) -> float:
x = pd.Series(x).dropna()
return float(x.mean() / x.std() * np.sqrt(365.25)) if len(x) > 2 and x.std() > 0 else 0.0
def dd_d(x: pd.Series) -> float:
e = (1.0 + pd.Series(x).dropna()).cumprod()
return float((e / e.cummax() - 1.0).min()) if len(e) else 0.0
# ------------------------------------------------------------------ caricamento
def load_snap() -> pd.DataFrame:
d = pd.read_parquet(SNAP_F).sort_values("ts")
d["ts"] = pd.to_datetime(d["ts"], utc=True).astype("datetime64[ns, UTC]")
return d
def load_bars(asset: str) -> pd.DataFrame:
px = A.get(asset, "1h").copy()
px["dt"] = pd.to_datetime(px["timestamp"], unit="ms", utc=True).astype("datetime64[ns, UTC]")
px = px[["dt", "close"]].dropna().reset_index(drop=True)
px["r"] = np.log(px["close"]).diff()
return px
def joined(asset: str, snap: pd.DataFrame) -> pd.DataFrame:
"""Barra oraria + ULTIMO snapshot con ts <= apertura della barra.
Causale per costruzione: `direction='backward'` sull'apertura della barra, quindi il
regime usato per la barra [t, t+1h) e' noto a t. Tolleranza 2h: se il collettore e'
fermo da piu' di due ore la riga esce, invece di trascinare un valore vecchio.
"""
px = load_bars(asset)
s = (snap[snap["asset"] == asset]
[["ts", "dealer_net_gamma", "gamma_flip_level", "spot", "dvol", "realized_vol_30d"]]
.dropna(subset=["dealer_net_gamma", "gamma_flip_level"]))
m = pd.merge_asof(px.sort_values("dt"), s.sort_values("ts"),
left_on="dt", right_on="ts", direction="backward",
tolerance=pd.Timedelta("2h"))
m = m[m["ts"].notna()].reset_index(drop=True)
# SEGNO CORRETTO (giustificato nella sezione 2): dealer_net_gamma = -GEX
# dealer_net_gamma > 0 <=> GEX < 0 <=> dealer SHORT gamma
m["short_gamma"] = (m["dealer_net_gamma"] > 0).astype(float)
m["above_flip"] = (m["close"] > m["gamma_flip_level"]).astype(float)
m["rvp24"] = np.sqrt((m["r"] ** 2).rolling(24).mean())
m["rvp168"] = np.sqrt((m["r"] ** 2).rolling(168).mean())
m["ret168"] = np.log(m["close"] / m["close"].shift(168))
m["z"] = m["r"] / m["rvp24"].shift(1)
for k in (4, 24):
m[f"rvf{k}"] = np.sqrt((m["r"].shift(-1) ** 2).rolling(k).sum().shift(-(k - 1)) / k)
return m
def ols(y, cols):
y = np.asarray(y, float)
n = len(y)
X = np.column_stack([np.ones(n)] + [np.asarray(c, float) for c in cols])
b, *_ = np.linalg.lstsq(X, y, rcond=None)
res = y - X @ b
s2 = res @ res / (n - X.shape[1])
se = np.sqrt(np.diag(s2 * np.linalg.pinv(X.T @ X)))
return b, b / se, 1.0 - np.var(res) / np.var(y)
def variance_ratio(r, k: int) -> float:
r = np.asarray(pd.Series(r).dropna(), float)
if len(r) < 5 * k:
return np.nan
x = pd.Series(r).rolling(k).sum().dropna().values
return float(np.var(x) / (k * np.var(r)))
# ================================================================== 1. IL DATO
def sezione_1_dato(snap: pd.DataFrame) -> dict:
hr("1. VALIDAZIONE DEL DATO — prima del segnale (fonte dismessa, mai certificata)")
out = {}
print(f"file: {SNAP_F.name} righe {len(snap)} {snap.ts.min()} -> {snap.ts.max()}")
# 1a. cadenza: il file NON e' omogeneo
d = snap[snap.asset == "BTC"]
gaps = d.ts.diff().dt.total_seconds().div(60).round(0).value_counts()
print(f"\n1a. CADENZA (BTC): distribuzione dei salti in minuti {dict(list(gaps.items())[:6])}")
pre = snap[snap.ts < HF_START]
post = snap[snap.ts >= HF_START]
print(f" -> {len(pre)//2} righe/asset a cadenza GIORNALIERA (12:00 UTC) fino al 2026-05-01,")
print(f" {len(post)//2} righe/asset a 15 min dopo. Il 'file da 4 mesi' e' 90 GIORNI utili.")
out["n_hf_per_asset"] = len(post) // 2
for a in ASSETS:
x = post[post.asset == a]
hrs = x.ts.dt.floor("h").nunique()
span = (x.ts.max().floor("h") - x.ts.min().floor("h")).total_seconds() / 3600 + 1
print(f" {a}: ore distinte {hrs} su {span:.0f} di span = copertura {100*hrs/span:.2f}%"
f" (buchi > 20 min: {int((x.ts.diff().dt.total_seconds()/60 > 20).sum())})")
# 1b. colonne MORTE — una colonna presente non e' un dato presente
print("\n1b. COLONNE MORTE (regola 8 del brief: contare cio' che VARIA, non le righe)")
for c in ["liquidation_long_risk", "liquidation_short_risk", "macro_days_to_event"]:
v = snap[c].dropna()
print(f" {c:26s} non-nulli {len(v):6d} ({100*len(v)/len(snap):5.1f}%) "
f"valori distinti {v.nunique()} {'*** COSTANTE = ZERO INFORMAZIONE ***' if v.nunique() <= 1 else ''}")
out["dead_cols"] = [c for c in ["liquidation_long_risk", "liquidation_short_risk"]
if snap[c].dropna().nunique() <= 1]
# 1c. dealer_net_gamma e gamma_flip_level: si muovono? sono plausibili?
print("\n1c. PLAUSIBILITA' delle due colonne di regime (finestra 15 min)")
for a in ASSETS:
x = post[post.asset == a]
g, f = x.dealer_net_gamma, x.gamma_flip_level
rel = x.spot / f - 1.0
print(f" {a} dealer_net_gamma: nan {100*g.isna().mean():4.1f}% frac>0 {(g>0).mean():.3f} "
f"consecutivi identici {(g.diff()==0).mean():.4f} -> si muove")
print(f" {a} gamma_flip_level: nan {100*f.isna().mean():4.1f}% "
f"consecutivi identici {(f.diff()==0).mean():.4f} "
f"(spot/flip-1) p05 {rel.quantile(.05):+.1%} med {rel.median():+.1%} p95 {rel.quantile(.95):+.1%}")
print(" -> BTC: il flip e' CONGELATO nel 61% dei passi e sta fino a +42% SOTTO lo spot al p95.")
print(" Un 'livello di flip' a 42% dallo spot non e' un livello di flip: e' degenere.")
# 1d. LE DUE COLONNE SI CONTRADDICONO
print("\n1d. CONTRADDIZIONE INTERNA fra le due colonne della STESSA fonte")
for a in ASSETS:
x = post[post.asset == a].dropna(subset=["dealer_net_gamma", "gamma_flip_level", "spot"])
c = np.corrcoef((x.dealer_net_gamma > 0).astype(float), (x.spot > x.gamma_flip_level).astype(float))[0, 1]
print(f" {a}: corr( dng>0 , spot>flip ) = {c:+.3f}")
print(" Nella convenzione classica dealer LONG gamma sta SOPRA il flip: le due colonne")
print(" dovrebbero correlare POSITIVAMENTE. Correlano NEGATIVAMENTE -> una delle due ha il")
print(" segno invertito. Quale, lo decide la sezione 2 (ricostruzione dalla catena), non io.")
# 1e. quante EPISODI di regime ci sono davvero -> il numero che conta
print("\n1e. AMPIEZZA EFFETTIVA — 2.160 ore NON sono 2.160 osservazioni")
for a in ASSETS:
x = post[post.asset == a].dropna(subset=["dealer_net_gamma"])
lab = (x.dealer_net_gamma > 0).astype(int).values
runs = pd.Series(lab).groupby((pd.Series(lab).diff() != 0).cumsum()).size() * 0.25 # ore
hourly = pd.Series(lab).iloc[::4]
rho = float(hourly.autocorr(1))
neff = len(hourly) * (1 - rho) / (1 + rho)
print(f" {a}: {len(runs)} episodi; {int((runs>72).sum())} durano >72h e coprono il "
f"{100*runs[runs>72].sum()/runs.sum():.0f}% del tempo")
print(f" AR(1) orario del regime {rho:.4f} -> n_eff ~ {neff:.0f} su {len(hourly)} ore")
out[f"neff_{a}"] = NEFF[a] = neff
print(" -> l'errore standard onesto di ogni statistica condizionata al regime va moltiplicato")
print(f" per ~sqrt(2130/n_eff) = 6x (BTC) - 9x (ETH). E' IL numero della sezione 3.")
return out
# ================================================================== 2. SEGNO E RICOSTRUIBILITA'
def sezione_2_ricostruzione(snap: pd.DataFrame) -> dict:
hr("2. DA DOVE VIENE `dealer_net_gamma`, E POSSIAMO RIFARLO OGGI?")
out = {}
print("La domanda non e' accademica: bite e' dismesso, la serie finisce il 2026-07-30. Se il")
print("segnale non e' ricostruibile dalla catena che raccogliamo NOI, il lead e' morto alla nascita.")
arch = CHAIN_D / "bite_archive.parquet"
d = pd.read_parquet(arch, columns=["ts", "asset", "option_type", "strike", "gamma",
"open_interest", "source"])
d["ts"] = pd.to_datetime(d["ts"], utc=True).astype("datetime64[ns, UTC]")
d["tsf"] = d["ts"].dt.floor("15min")
per_snap = d.groupby(["source", "asset", "tsf"]).size().rename("n").reset_index()
print("\n2a. STRIKE PER SNAPSHOT nell'archivio ereditato (mediana):")
print(per_snap.groupby(["source", "asset"])["n"].median().to_string())
print(" -> `bite:live` ha ~16-18 strike: e' la manciata che serviva al suo motore VRP, NON")
print(" una catena. Solo `bite:research` (~280-320 strike) e' ricostruibile. E parte dal")
print(" 2026-06-09, non dal 05-01.")
# spot: l'archivio ha underlying_price 100% NaN -> lo prendiamo dallo snapshot (stessa fonte)
d = d[(d["source"] == "bite:research")].dropna(subset=["gamma", "open_interest"])
sp = (snap[["asset", "spot", "dealer_net_gamma", "gamma_flip_level"]]
.assign(tsf=snap["ts"].dt.floor("15min"))
.dropna(subset=["spot"]).drop_duplicates(["asset", "tsf"]))
d = d.merge(sp[["asset", "tsf", "spot"]], on=["asset", "tsf"], how="inner")
print(f"\n (l'archivio ha underlying_price 100% NaN -> spot preso dallo snapshot, stessa fonte)")
sgn = np.where(d["option_type"].values == "C", 1.0, -1.0)
S = d["spot"].values
G = d["gamma"].values * d["open_interest"].values
d["gex_std"] = G * S * S * 0.01 * sgn # GEX da manuale: dollar-gamma per 1%, call +, put -
d["gex_all"] = G * S * S * 0.01 # senza distinzione call/put
d["gex_nos"] = G * sgn # senza scala S^2
d["gex_S1"] = G * S * sgn # scala lineare in S
agg = (d.groupby(["asset", "tsf"])
.agg(**{c: (c, "sum") for c in ["gex_std", "gex_all", "gex_nos", "gex_S1"]},
n=("gamma", "size")).reset_index())
print("\n2b. LA RICOSTRUZIONE (catena near-full `bite:research` vs la colonna di bite)")
for a in ASSETS:
j = (agg[agg.asset == a].merge(sp[sp.asset == a][["tsf", "dealer_net_gamma"]], on="tsf")
.dropna(subset=["dealer_net_gamma"]))
print(f" {a}: n={len(j)} snapshot, ~{j.n.median():.0f} strike ciascuno")
for c in ["gex_std", "gex_all", "gex_nos", "gex_S1"]:
COUNT["ricostruzione"] += 1
rp = np.corrcoef(j[c], j.dealer_net_gamma)[0, 1]
rs = j[c].corr(j.dealer_net_gamma, method="spearman")
sa = float(np.mean((j[c] > 0) == (j.dealer_net_gamma > 0)))
sl, ic = np.polyfit(j[c], j.dealer_net_gamma, 1)
print(f" {c:8s} corr {rp:+.3f} rank {rs:+.3f} accordo-di-segno {sa:.3f}"
f" fit dng = {sl:+.3f}*GEX {ic:+.2e}")
best = j
out[f"corr_{a}"] = float(np.corrcoef(best.gex_std, best.dealer_net_gamma)[0, 1])
out[f"slope_{a}"] = float(np.polyfit(best.gex_std, best.dealer_net_gamma, 1)[0])
out[f"intc_{a}"] = float(np.polyfit(best.gex_std, best.dealer_net_gamma, 1)[1])
print("\n RISULTATO: il GEX da manuale riproduce la colonna di bite a corr -0.95 (BTC) /")
print(" -0.89 (ETH), con accordo-di-segno del 10%/8%. Cioe':")
print(" dealer_net_gamma ~ -0.7 * GEX_standard -> LA COLONNA E' IL GEX COL SEGNO INVERTITO.")
print(" `dealer_net_gamma > 0` significa dealer SHORT gamma, non long. Il nome mente.")
print(" Questo CHIUDE la contraddizione della sezione 1d: e' la colonna dng ad avere il segno")
print(" girato, il gamma_flip_level e' nella convenzione classica. Da qui in poi il codice usa")
print(" `short_gamma = dealer_net_gamma > 0`.")
print("\n2c. LA SOGLIA NON E' PORTABILE — e questo e' il punto che uccide il riuso diretto")
for a in ASSETS:
z = -out[f"intc_{a}"] / out[f"slope_{a}"]
print(f" {a}: dng=0 corrisponde a GEX = {z:+.3e}, non a GEX = 0.")
print(" Le due serie hanno zeri DIVERSI (universi diversi: il nostro collettore filtra OI>=100")
print(" e scadenze <=95g). Trasportare 'la soglia zero' da una all'altra e' un cambio di")
print(" definizione mascherato da continuita'. Ricalibrarla sui 90 giorni sarebbe selezione.")
print("\n2d. IL SEGNALE E' VIVO OGGI? (raccolta nostra, `pyg`, oraria)")
fs = sorted(glob.glob(str(CHAIN_D / "2026-08-*.parquet")))
if fs:
o = pd.concat([pd.read_parquet(f, columns=["ts", "asset", "option_type", "gamma",
"open_interest", "underlying_price",
"quote_status"]) for f in fs], ignore_index=True)
o["ts"] = pd.to_datetime(o["ts"], utc=True)
o = o.dropna(subset=["gamma", "open_interest", "underlying_price"])
s2 = np.where(o["option_type"].values == "C", 1.0, -1.0)
o["gex"] = (o["gamma"].values * o["open_interest"].values
* o["underlying_price"].values ** 2 * 0.01 * s2)
g = (o.groupby(["asset", o["ts"].dt.floor("h")])
.agg(gex=("gex", "sum"), n=("gamma", "size")).reset_index())
for a in ASSETS:
x = g[g.asset == a]
print(f" {a}: {len(x)} ore dal {x.ts.min():%Y-%m-%d} al {x.ts.max():%Y-%m-%d %H:%M}, "
f"~{x.n.median():.0f} strike/ora, quote ok {100*(o[o.asset==a].quote_status=='ok').mean():.1f}%")
print(f" GEX medio {x.gex.mean():+.3e} frac GEX>0 (dealer LONG gamma) {(x.gex>0).mean():.3f}"
f" ultimo {x.gex.iloc[-1]:+.3e}")
print(" -> RICOSTRUIBILE, oraria, viva, con MEGLIO del dato originale (segno giusto e")
print(" `quote_status` esplicito). Il lead e' inseguibile: il dato non e' il vincolo.")
print(" -> ma nota: ad agosto il GEX e' >0 nell'88-93% delle ore. Il regime 'short gamma'")
print(" compare a sprazzi: la variabile non varia quasi mai, e questo e' un problema")
print(" di potenza che il tempo aggiusta LENTAMENTE.")
else:
print(" (nessun file 2026-08-* nella raccolta: salto)")
return out
# ================================================================== 3. IL FATTO (A): la vol
def sezione_3_fatto_vol(M: dict, snap: pd.DataFrame, rec: dict) -> dict:
hr("3. GAMBA (A) DELL'IPOTESI — short gamma amplifica la vol realizzata?")
out = {}
rng = np.random.default_rng(SEED)
print("Misura il FATTO condizionale, non una strategia. Se un effetto e' vero si vede qui.")
print("\n3a. VOL REALIZZATA FORWARD, condizionata al regime (segno corretto in sezione 2)")
for a in ASSETS:
m = M[a]
for c in ["rvf4", "rvf24"]:
g = m.groupby("short_gamma")[c].mean() * ANN
COUNT["vol_condizionata"] += 1
print(f" {a} {c}: LONG gamma {g.get(0.0, np.nan):5.1f}% SHORT gamma {g.get(1.0, np.nan):5.1f}%"
f" spread {g.get(1.0,0)-g.get(0.0,0):+5.1f} pp")
print(" Direzione CONFORME al manuale: short gamma -> vol forward piu' alta. Prima gamba OK.")
print("\n3b. MA: il regime coincide quasi col MESE. Scomposizione (regola: un contributo si")
print(" scompone per periodo PRIMA di crederci — lezione SOL, 22/08)")
for a in ASSETS:
m = M[a].dropna(subset=["rvf24", "short_gamma"]).copy()
m["mo"] = m["dt"].dt.strftime("%Y-%m")
obs = (m.rvf24[m.short_gamma == 1].mean() - m.rvf24[m.short_gamma == 0].mean()) * ANN
print(f" {a} spread pieno {obs:+.1f} pp")
for mo, gg in m.groupby("mo"):
ns, nl = int(gg.short_gamma.sum()), int((1 - gg.short_gamma).sum())
sp = (gg.rvf24[gg.short_gamma == 1].mean() - gg.rvf24[gg.short_gamma == 0].mean()) * ANN
print(f" {mo}: spread {sp:+6.1f} pp ore SHORT {ns:4d} / LONG {nl:4d}"
f" {'<-- mese quasi interamente in UN regime' if min(ns,nl) < 100 else ''}")
out[f"spread_{a}"] = obs
print(" -> Maggio e Luglio sono mesi LONG-gamma, Giugno e' un mese SHORT-gamma. Il confronto")
print(" 'short vs long' e', in pratica, 'giugno vs maggio+luglio': UN confronto, non 2.136.")
print("\n3c. NULL a spostamento circolare (conserva l\'autocorrelazione di ENTRAMBE le serie,")
print(" rompe solo l\'allineamento) — e il suo LIMITE DI RISOLUZIONE")
for a in ASSETS:
m = M[a].dropna(subset=["rvf24", "short_gamma"])
lab, y = m.short_gamma.values, m.rvf24.values
n = len(y)
obs = (y[lab == 1].mean() - y[lab == 0].mean()) * ANN
null = np.empty(2000)
for i in range(2000):
L = np.roll(lab, rng.integers(24, n - 24))
null[i] = (y[L == 1].mean() - y[L == 0].mean()) * ANN
p = float((np.abs(null) >= abs(obs)).mean())
runs = pd.Series(lab).groupby((pd.Series(lab).diff() != 0).cumsum()).size()
long_runs = runs[runs > 24]
n_align = n / max(long_runs.median(), 1.0)
print(f" {a}: osservato {obs:+.1f} pp | null media {null.mean():+.2f} sd {null.std():.2f}"
f" | p empirico (2 code) {p:.4f}")
print(f" MA lo spostamento circolare di una serie con episodi lunghi ~{long_runs.median():.0f}h")
print(f" produce solo ~{n_align:.0f} allineamenti DISTINTI: la risoluzione del p non e\'")
print(f" 1/2000, e\' ~1/{n_align:.0f}. Un p 'zero' su {n_align:.0f} configurazioni non e\' 1e-4.")
out[f"p_{a}"] = p
print("\n3c-bis. LA PROVA CHE COSTA MENO DI TUTTE: si toglie GIUGNO (l\'unico mese short-gamma)")
for a in ASSETS:
m = M[a].dropna(subset=["rvf24", "short_gamma"]).copy()
m["mo"] = m["dt"].dt.strftime("%Y-%m")
for drop in (None, "2026-06"):
g = m if drop is None else m[m.mo != drop]
ns, nl = int(g.short_gamma.sum()), int((1 - g.short_gamma).sum())
sp = (g.rvf24[g.short_gamma == 1].mean() - g.rvf24[g.short_gamma == 0].mean()) * ANN
tag = "campione pieno" if drop is None else "senza giugno"
print(f" {a} {tag:16s}: spread {sp:+6.1f} pp ore SHORT {ns:4d} / LONG {nl:4d}")
print(" -> tolto UN mese su tre restano ~40-130 ore di regime short su 1.400: lo 'spread'")
print(" diventa il confronto fra due manciate di ore. Non e\' un campione, e\' un episodio.")
print("\n3d. NULL LOCATION-MATCHED per `gamma_flip_level`: livello di opzioni o media mobile?")
for a in ASSETS:
m = M[a]
g = m.groupby("above_flip")["rvf24"].mean() * ANN
sp_gamma = float(g.get(0.0, np.nan) - g.get(1.0, np.nan))
print(f" {a} GAMMA spot>flip: sotto {g.get(0.0, np.nan):.1f}% sopra {g.get(1.0, np.nan):.1f}%"
f" spread {sp_gamma:+.1f} pp")
best = None
for nn in (6, 12, 24, 48, 72, 120, 168, 240, 336, 504, 720):
COUNT["sma"] += 1
sma = m["close"].rolling(nn).mean()
ok = sma.notna()
agree = float((((m["close"] > m["gamma_flip_level"]) == (m["close"] > sma))[ok]).mean())
gg = m.groupby((m["close"] > sma).astype(float))["rvf24"].mean() * ANN
spread = float(gg.get(0.0, np.nan) - gg.get(1.0, np.nan))
if best is None or agree > best[1]:
best = (nn, agree, spread)
if best[2] > sp_gamma + 1.0:
verdetto = "il sosia da uno spread PIU GRANDE del livello di opzioni"
elif abs(best[2] - sp_gamma) <= 1.0:
verdetto = "il sosia da lo STESSO spread"
else:
verdetto = "il sosia da uno spread minore"
print(f" miglior sosia: spot>SMA({best[0]}h) -> accordo di regime {best[1]:.3f}, "
f"spread {best[2]:+.1f} pp ({verdetto})")
out[f"sma_agree_{a}"], out[f"sma_span_{a}"] = best[1], best[0]
out[f"sma_spread_{a}"], out[f"flip_spread_{a}"] = best[2], sp_gamma
print(" LETTURA, asset per asset (i due casi NON dicono la stessa cosa):")
print(" BTC: accordo 0.68, e la media mobile NUDA da' uno spread uguale o maggiore")
print(" (+8.8 contro +7.8 pp) -> il livello di opzioni non aggiunge nulla a una SMA.")
print(" ETH: accordo 0.96 -> `gamma_flip_level` E' una media mobile a 30 giorni con un")
print(" altro nome. Il suo spread e' piu' grande (+16.6 vs +10.1 pp), ma la")
print(" differenza vive tutta nel 4% di ore in cui i due regimi divergono: 4% di")
print(" 2.160 ore dentro un campione da 3 episodi non e' evidenza, e non e' testabile.")
print(" In nessuno dei due casi c'e' un contenuto 'opzioni' isolabile: e' la trappola 2 del")
print(" brief (TP01 travestito). `gamma_flip_level` non e' usabile come regime.")
print("\n3e. IL REGIME AGGIUNGE SOPRA CIO' CHE GIA' SAPPIAMO? (log rv fwd 24h ~ vol passata + trend)")
print(" t_eff = t / sqrt(24) corregge la sovrapposizione della finestra a 24h. NON corregge")
print(" l'ampiezza effettiva (sezione 1e: n_eff 24-53 su 2.130): un t_eff di +2.3 su 6-11")
print(" episodi indipendenti resta un t che conta episodi come se fossero ore.")
for a in ASSETS:
m = M[a]
d = m.dropna(subset=["rvf24", "rvp24", "rvp168", "ret168", "dvol", "short_gamma", "above_flip"])
d = d[d.rvf24 > 0]
y = np.log(d.rvf24)
base = [np.log(d.rvp24), np.log(d.rvp168), d.ret168]
d = d.assign(sma720=(d["close"] > d["close"].rolling(720).mean()).astype(float).fillna(0.0))
print(f" {a} (n={len(d)}) corr(short_gamma, DVOL) = "
f"{np.corrcoef(d.short_gamma, d.dvol)[0,1]:+.3f}")
for lab, extra in [("base: vol+trend", []),
(" + spot>SMA720", [d.sma720]),
(" + spot>flip", [d.above_flip]),
(" + short_gamma", [d.short_gamma]),
(" + DVOL", [np.log(d.dvol)]),
(" + DVOL + short_gamma", [np.log(d.dvol), d.short_gamma])]:
COUNT["regressioni"] += 1
b, t, r2 = ols(y, base + extra)
print(f" {lab:24s} R2 {r2:.3f} ultimo coef {b[-1]:+.3f} t {t[-1]:+6.2f}"
f" t_eff {t[-1]/np.sqrt(24):+5.2f}")
print(" -> BTC: `short_gamma` da solo spiega quanto DVOL da solo (R2 0.271 vs 0.272) e i due")
print(" insieme fanno 0.312 -> l'informazione e' in larga parte GIA' NEL DVOL, che il")
print(" progetto ha gia' provato come modulatore di TP01 il 26/06: era de-levering puro.")
print(" ETH: t_eff ~ +1.0 = rumore.")
return out
# ================================================================== 4. IL FATTO (B): MR vs trend
def sezione_4_fatto_mrtrend(M: dict) -> dict:
hr("4. GAMBA (B) DELL'IPOTESI — la gamba TRADEABILE: short gamma = trend, long gamma = MR?")
out = {}
print("Questa e' la gamba che diventerebbe un gate. La (A) e' solo il contorno.")
print("\n4a. VARIANCE RATIO per regime. VR<1 = mean-reversion, VR>1 = trend.")
print(" La versione 'z' standardizza ogni ritorno per la vol delle 24h precedenti: senza,")
print(" il clustering di volatilita' DENTRO un regime gonfia il VR e si legge come trend.")
for a in ASSETS:
m = M[a]
for lab, col in [("grezzo", "r"), ("z-std", "z")]:
L = {k: variance_ratio(m[col][m.short_gamma == 0], k) for k in (2, 4, 12, 24)}
S = {k: variance_ratio(m[col][m.short_gamma == 1], k) for k in (2, 4, 12, 24)}
COUNT["variance_ratio"] += 4
print(f" {a} {lab}: LONG " + " ".join(f"VR{k}={L[k]:.3f}" for k in (2, 4, 12, 24)))
print(f" {a} {lab}: SHORT " + " ".join(f"VR{k}={S[k]:.3f}" for k in (2, 4, 12, 24)))
if lab == "z-std":
out[f"vr24_long_{a}"], out[f"vr24_short_{a}"] = L[24], S[24]
print("\n LETTURA (versione z a 24h, l'unica scale-free):")
for a in ASSETS:
vl, vs = out[f"vr24_long_{a}"], out[f"vr24_short_{a}"]
if abs(vl - vs) < 0.05:
diag = "NESSUNA separazione (differenza < 0.05)"
elif vs > vl:
diag = "separazione NEL VERSO dell'ipotesi (short gamma piu' trendante)"
else:
diag = "separazione ROVESCIATA: e' il regime LONG gamma a trendare"
print(f" {a}: LONG {vl:.3f} vs SHORT {vs:.3f} -> {diag}")
print(" Due asset, due risposte diverse, e quella che separa lo fa al contrario. La gamba (B)")
print(" non e' 'debole': e' INCOERENTE. Con la gamba (A) confermata, questo e' il punto in cui")
print(" l'ipotesi si rompe — perche' e' la (B) che si traderebbe.")
print("\n4b. La forma tradeable: payoff di momentum per regime, sgn(ritorno passato L) x ritorno")
print(" futuro H. Sharpe ANNUALIZZATO LORDO (nessuna fee: se non regge lordo e' finita).")
Ls, Hs = (1, 4, 12, 24), (1, 4, 12, 24)
cells = []
for a in ASSETS:
m = M[a]
print(f" {a} " + " ".join(f"H={h:<2d}" for h in Hs) + " (L=long-gamma, S=short-gamma)")
for L in Ls:
past = np.log(m["close"] / m["close"].shift(L))
row = []
for H in Hs:
fwd = np.log(m["close"].shift(-H) / m["close"])
pay = np.sign(past) * fwd
for lab, mask in [("L", m.short_gamma == 0), ("S", m.short_gamma == 1)]:
v = pay[mask].dropna()
s = float(v.mean() / v.std() * np.sqrt(HOUR_YEAR / H)) if len(v) > 30 and v.std() > 0 else np.nan
COUNT["momentum"] += 1
cells.append((a, L, H, lab, s, v))
row.append(f"{lab}{s:+.2f}")
print(f" L={L:2d}h " + " ".join(row))
se = np.sqrt(HOUR_YEAR / len(m))
neff = NEFF.get(a, len(m))
print(f" [errore standard di UNA cella a H=1h: +/-{se:.2f} di Sharpe su {len(m)} ore.")
print(f" Con l'ampiezza effettiva della sezione 1e (n_eff={neff:.0f}) diventa"
f" +/-{se*np.sqrt(len(m)/neff):.1f}.")
print(" Ogni numero della tabella qui sopra e' dentro il proprio errore standard.]")
out["cells"] = cells
return out
# ================================================================== 5. IL GATE SUL LIBRO
def sezione_5_gate(snap: pd.DataFrame, fatti4: dict) -> dict:
hr("5. IL GATE SUL LIBRO — costruito e misurato, con la potenza dichiarata")
out = {}
print("La sezione 4 ha gia' refutato la gamba tradeable. Il gate si costruisce lo stesso, perche'")
print("un 'non funziona' misurato vale piu' di un 'non l'ho provato'.")
B = A.tp01_baseline_daily()
reg = {}
for a in ASSETS:
s = (snap[(snap.asset == a) & (snap.ts >= HF_START)]
.dropna(subset=["dealer_net_gamma"])[["ts", "dealer_net_gamma"]]
.set_index("ts").resample("1D").last())
# ultimo snapshot del giorno d -> usato per il giorno d+1: causale per costruzione
reg[a] = (s["dealer_net_gamma"] > 0).astype(float).shift(1)
R = pd.concat(reg, axis=1).mean(axis=1).dropna()
J = pd.concat({"B": B, "S": R}, axis=1, join="inner").dropna()
b = J["B"]
n = len(J)
se = np.sqrt(365.25 / n)
print(f"\n5a. FINESTRA: {J.index.min():%Y-%m-%d} -> {J.index.max():%Y-%m-%d}, {n} giorni.")
print(f" TP01 (baseline 50/50) su questa finestra: Sharpe {sh_d(b):+.2f} maxDD {dd_d(b):.2%}"
f" ritorno {(1+b).prod()-1:+.2%}")
print(f" ERRORE STANDARD DELLO SHARPE su {n} giorni: +/-{se:.2f}.")
print(" Cioe': su questa finestra TP01 e' indistinguibile da qualunque cosa fra -4 e 0. Il")
print(" libro non e' misurabile qui, e nessun gate lo sara'. E' il vincolo, non un dettaglio.")
print(f" (per giunta TP01 e' quasi FLAT: ritorno lordo {100*(1+b).prod()-100:+.2f}% in 3 mesi)")
out["n_days"], out["se_sharpe"] = n, se
print("\n5b. QUATTRO gate dichiarati (2 polarita' x 2 intensita'), + il null del de-levering")
variants = {
"classico SHORT->1.5x LONG->0.5x": (0.5, 1.5),
"classico mite SHORT->1.25 LONG->0.75": (0.75, 1.25),
"INVERSO SHORT->0.5x LONG->1.5x": (1.5, 0.5),
"risk-off in SHORT (1.0 / 0.5)": (1.0, 0.5),
}
rows = []
for name, (lo, hi) in variants.items():
x = b * (lo + (hi - lo) * J["S"])
COUNT["gate"] += 1
rows.append((name, sh_d(x), dd_d(x)))
print(f" {name:38s} Sharpe {sh_d(x):+.2f} maxDD {dd_d(x):.2%}")
print("\n NULL DEL DE-LEVERING (obbligatorio su ogni claim di DD — 5 occorrenze nel progetto):")
for k in (0.5, 0.6, 0.7, 0.8, 0.9, 1.0):
COUNT["delevering"] += 1
x = b * k
print(f" baseline x{k:.1f} Sharpe {sh_d(x):+.2f} maxDD {dd_d(x):.2%}")
best_dd = min(rows, key=lambda r: abs(r[2]))
print(f"\n -> il gate 'classico' porta il maxDD da {dd_d(b):.2%} a {rows[0][2]:.2%} con Sharpe"
f" {rows[0][1]:+.2f} (peggiore del baseline {sh_d(b):+.2f});")
print(f" il semplice `baseline x0.6` fa maxDD {dd_d(b*0.6):.2%} a Sharpe {sh_d(b*0.6):+.2f}.")
print(" NULL DE-LEVERING: **FALLITO**. Il gate e' meno leva con passaggi in piu'.")
out["null_delevering"] = "FALLITO"
print("\n5c. `marginal_vs_tp01` sullo stream INCREMENTALE del gate (cio' che il gate aggiunge)")
ov = (b * (0.5 + 1.0 * J["S"])) - b
try:
rep = A.marginal_vs_tp01(ov)
for k in ("marginal_verdict", "n_days", "n_hold_days", "corr_full", "cand_full_sharpe",
"beta_to_tp01", "reason"):
if k in rep:
print(f" {k:20s} {rep[k]}")
bl = rep.get("blends", {})
for w, v in bl.items():
print(f" blend {w}: full {v.get('full')} uplift_full {v.get('uplift_full')}"
f" uplift_hold {v.get('uplift_hold')}")
out["marginal"] = rep.get("marginal_verdict")
except Exception as e: # pragma: no cover
out["marginal"] = f"errore: {e}"
print(f" non girato: {e}")
print(" ATTENZIONE alla lettura: `n_hold_days` == `n_days` — i 90 giorni cadono INTERAMENTE")
print(" dentro l'hold-out di altlib, quindi 'full' e 'hold' sono LA STESSA FINESTRA e i loro")
print(" uplift coincidono (si vede sopra: -0.066 e -0.066). I gate multi-cut e il jackknife")
print(" di `marginal_vs_tp01` girano su una finestra sola.")
print(" Un ADDS qui non sarebbe un ADDS: sarebbe la definizione di selezione")
print(" sull'hold-out. Si riporta il verdetto, non lo si usa per promuovere.")
print("\n5d. DEFLATED SHARPE sulla cella migliore della griglia della sezione 4")
cells = [c for c in fatti4["cells"] if np.isfinite(c[4])]
best = max(cells, key=lambda c: c[4])
a, L, H, lab, s_best, v = best
allsr = [c[4] for c in cells]
try:
dsr, null_max = A.deflated_sharpe(s_best, allsr, pd.Series(v.values).dropna().values,
dpy=HOUR_YEAR / H)
reg = "SHORT" if lab == "S" else "LONG"
print(f" cella migliore: {a} L={L}h H={H}h regime={reg} gamma -> Sharpe LORDO {s_best:+.2f}"
f" (su {len(allsr)} trial dichiarati)")
print(f" deflated Sharpe = {dsr:.3f} -> {'PASS' if dsr >= 0.95 else 'FAIL'} (soglia 0.95)")
print(f" massimo Sharpe ATTESO SOTTO IL NULLO con {len(allsr)} trial = {null_max:+.2f}")
if s_best < null_max:
print(f" -> la cella migliore ({s_best:+.2f}) sta SOTTO cio' che il puro rumore produce")
print(f" ({null_max:+.2f}) cercando {len(allsr)} volte. Non c'e' niente da deflazionare:")
print(" la griglia non ha prodotto nemmeno il massimo che il caso avrebbe prodotto.")
out["dsr"], out["dsr_nullmax"] = float(dsr), float(null_max)
except Exception as e: # pragma: no cover
out["dsr"] = f"errore: {e}"
print(f" non girato: {e}")
print("\n5e. CAUSALITA'")
print(" Per COSTRUZIONE: ogni riga usa `merge_asof(direction='backward')` sull'APERTURA della")
print(" barra con tolleranza 2h, quindi il regime della barra [t,t+1h) e' noto a t; il gate")
print(" giornaliero usa l'ultimo snapshot del giorno d-1 (`.shift(1)`).")
print(" `A.causality_ok` non e' applicabile: perturba il futuro dei prezzi certificati, ma il")
print(" regime qui non viene dai prezzi — viene da un file esterno che copre 90 giorni su 8")
print(" anni. Girarlo darebbe un PASS privo di potenza sul 97% del campione (stessa forma del")
print(" test a potenza zero corretto il 21/08). Si dichiara l'argomento strutturale, non un")
print(" numero finto.")
out["causality"] = "per costruzione (merge_asof backward + shift(1)); A.causality_ok non applicabile"
return out
# ================================================================== main
def main() -> None:
hr("r0822 DEALER-GAMMA — il regime di gamma dei dealer separa MR da trend su BTC/ETH?")
print("IPOTESI: (A) short gamma amplifica la vol; (B) short gamma fa TRENDARE il prezzo, long")
print("gamma lo fa MEAN-REVERTERE -> gate di regime sopra TP01/SKH01.")
print("ATTESA DICHIARATA PRIMA DI MISURARE: (A) plausibile ma confusa col livello di vol;")
print("(B) improbabile su 3 mesi e a rischio 'TP01 travestito'.")
snap = load_snap()
d1 = sezione_1_dato(snap)
d2 = sezione_2_ricostruzione(snap)
hf = snap[snap.ts >= HF_START]
M = {a: joined(a, hf) for a in ASSETS}
for a in ASSETS:
print(f"\n[merge] {a}: {len(M[a])} barre orarie con regime, "
f"{M[a].dt.min():%Y-%m-%d} -> {M[a].dt.max():%Y-%m-%d}")
d3 = sezione_3_fatto_vol(M, hf, d1)
d4 = sezione_4_fatto_mrtrend(M)
d5 = sezione_5_gate(snap, d4)
hr("VERDETTO")
tot = sum(COUNT.values())
print(f"GRIGLIA DICHIARATA: {tot} celle/statistiche valutate, contate al rialzo:")
for k, v in COUNT.items():
print(f" {k:18s} {v:4d}")
print(" Nessuna griglia e' stata ridotta per budget: il vincolo qui e' il DATO, non la macchina.")
print(f" Le {COUNT['momentum']} celle di momentum sono quelle passate al deflated Sharpe (5d).")
print()
print("SCARTATO. La gamba (A) e' confermata nella direzione ma non nella potenza; la gamba (B),")
print("che e' quella che si traderebbe, e' INCOERENTE fra i due asset e rovesciata dove separa.")
print()
print(" 1. Il dato regge meno di quanto dichiara: 15 min solo dal 2026-05-01 (90 giorni, non 4")
print(" mesi); 2 colonne su 16 COSTANTI; `gamma_flip_level` congelato al 61% su BTC.")
print(" 2. `dealer_net_gamma` e' il GEX COL SEGNO INVERTITO (corr -0.95 BTC / -0.89 ETH contro")
print(" la ricostruzione dalla catena). Chi lo usasse col nome che porta leggerebbe ogni")
print(" regime al contrario. -> Questo e' il risultato riusabile di tutto il filone.")
print(" 3. `gamma_flip_level` e' una media mobile: 96% di accordo con spot>SMA(720h) su ETH; su")
print(" BTC (accordo 0.68) la SMA nuda da' uno spread di vol MAGGIORE del livello stesso.")
print(" Niente contenuto di opzioni isolabile. Trappola 2 del brief.")
print(" 4. Ampiezza effettiva: 73 (BTC) / 59 (ETH) episodi di regime, ma SEI e OTTO di essi")
print(" coprono il 78-86% del tempo; n_eff 53 e 24 ore su 2.130. Il regime coincide col")
print(" MESE (maggio LONG, giugno SHORT, luglio LONG): lo spread di vol e' un confronto")
print(" fra tre mesi, non fra 2.136 osservazioni. Tolto giugno cala del 46% (BTC) e del")
print(" 71% (ETH) e resta poggiato su 134 / 366 ore.")
print(" 5. Cio' che resta della gamba (A) e' quasi tutto DVOL (corr 0.66 su BTC), e il DVOL")
print(" come modulatore di TP01 e' gia' stato refutato il 26/06: era de-levering.")
print(" 6. Il gate misurato FALLISCE il null del de-levering (baseline x0.6 lo domina) su una")
print(" finestra dove SE(Sharpe) = +/-2.0 e TP01 e' praticamente flat.")
print(" 7. Deflated Sharpe 0.440 = FAIL, e nel modo piu' netto: la cella migliore delle 64")
print(" (+4.98 LORDO) sta SOTTO il massimo che 64 estrazioni di puro rumore producono")
print(" (+5.12). Non c'e' un candidato da deflazionare, c'e' una griglia sotto il rumore.")
print()
print("NON e' un LEAD: un lead richiede una soglia e una data che abbiano senso, e qui la soglia")
print("non e' nemmeno portabile fra la serie storica e quella che raccogliamo (zeri diversi,")
print("universi diversi). Ricalibrarla sui 90 giorni sarebbe la selezione che il progetto rifiuta.")
print()
print("COSA CONSERVARE (non e' una strategia, e' infrastruttura):")
print(" - il GEX si ricostruisce dalla NOSTRA catena, oraria, viva (518 ore dal 2026-08-01,")
print(" ~209-262 strike, quote ok ~100%), col segno GIUSTO e `quote_status` esplicito;")
print(" - se un giorno lo si riapre, si riapre su GEX ricostruito da noi, mai sulla colonna")
print(" ereditata, e non prima di avere ~30-40 EPISODI di regime (non 30-40 giorni):")
print(" al ritmo osservato (6-8 episodi / 90 giorni) sono ~2 anni, cioe' meta' 2028.")
print()
print("COSA MI SMENTIREBBE: una separazione del variance ratio z-standardizzato nello STESSO")
print("verso sui due asset (short gamma VR>1, long gamma VR<1) su GEX ricostruito da noi, con")
print("almeno 30 episodi di regime indipendenti e uno spread che sopravvive al controllo")
print("location-matched contro spot>SMA. Oggi ho l'opposto: BTC non separa, ETH separa al rovescio.")
print()
print(f"SCRIPT: scripts/research/r0822_dealer_gamma.py")
if __name__ == "__main__":
main()