"""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()