research(wave-0822): DEALER-GAMMA scartato — e dealer_net_gamma e' il GEX col segno invertito

This commit is contained in:
Adriano Dal Pastro
2026-08-22 17:08:20 +00:00
parent ff4aa94528
commit 3b206c8e6d
8 changed files with 283 additions and 59 deletions
+97 -36
View File
@@ -156,28 +156,62 @@ def sezione0(chain: pd.DataFrame) -> None:
# ===========================================================================================
# SEZIONE 1 — RICOSTRUZIONE DELLA CURVA + CERTIFICAZIONE
# ===========================================================================================
def _atm_iv(g: pd.DataFrame) -> float:
"""ATM IV di UNO snapshot+scadenza: interpolazione sul DELTA, mai estrapolazione."""
vals = []
for typ, tgt in (("C", 0.5), ("P", -0.5)):
s = g[g.option_type == typ]
if len(s) >= 2:
d = s["delta"].to_numpy()
if d.min() <= tgt <= d.max():
o = np.argsort(d)
vals.append(float(np.interp(tgt, d[o], s["iv"].to_numpy()[o])))
return float(np.mean(vals)) if vals else np.nan
def _interp_at(gid: np.ndarray, x: np.ndarray, y: np.ndarray, ngrp: int,
target: float) -> np.ndarray:
"""Interpolazione lineare di y(x) a `target`, per ogni gruppo, VETTORIALE e senza
estrapolazione (NaN se il gruppo non racchiude il target).
`gid` dev'essere ordinato crescente e (gid, x) lessicograficamente ordinati.
Equivalente a np.interp gruppo-per-gruppo: qui serve solo perche' i gruppi sono ~30.000
e un ciclo python li paga a caro prezzo su una VPS a 2 core condivisa.
"""
n_tot = np.bincount(gid, minlength=ngrp)
start = np.concatenate([[0], np.cumsum(n_tot)[:-1]])
n_below = np.bincount(gid[x <= target], minlength=ngrp)
ok = (n_below > 0) & (n_below < n_tot)
out = np.full(ngrp, np.nan)
if not ok.any():
return out
lo = (start + n_below - 1)[ok]
hi = lo + 1
x0, x1, y0, y1 = x[lo], x[hi], y[lo], y[hi]
w = np.where(x1 != x0, (target - x0) / np.where(x1 != x0, x1 - x0, 1.0), 0.0)
out[ok] = y0 + w * (y1 - y0)
return out
def build_atm_by_expiry(chain: pd.DataFrame) -> pd.DataFrame:
"""ATM IV per (asset, ora, scadenza) = IV interpolata sul DELTA a 0.5 (call) / -0.5 (put).
Perche' il delta e non "lo strike piu' vicino allo spot": il delta referenzia il FORWARD.
A 90 giorni il forward sta lontano dallo spot, quindi lo strike ATM-spot e' un'opzione OTM
e la sua IV porta dentro lo SKEW — che e' un altro filone e falserebbe la pendenza in modo
sistematico e crescente col tenore. Nessuna estrapolazione: se il delta 0.5 non e' racchiuso
dai quotati, quella scadenza a quell'ora non ha ATM e viene scartata.
"""
f = CACHE / "atm_by_expiry.parquet"
if f.exists():
return pd.read_parquet(f)
q = chain[chain.quoted & (chain.dte > DTE_MIN)]
out = (q.groupby(["asset", "hr", "exp"])
.apply(lambda g: pd.Series({"iv": _atm_iv(g), "dte": g["dte"].median()}),
include_groups=False)
.reset_index().dropna(subset=["iv"]))
q = chain[chain.quoted & (chain.dte > DTE_MIN)].copy()
keys = ["asset", "hr", "exp"]
q["gid"] = q.groupby(keys, sort=True).ngroup()
ngrp = int(q["gid"].max()) + 1
ivs = []
for typ, tgt in (("C", 0.5), ("P", -0.5)):
s = q[q.option_type == typ].sort_values(["gid", "delta"])
v = np.full(ngrp, np.nan)
if len(s):
g2 = s["gid"].to_numpy()
uniq, inv = np.unique(g2, return_inverse=True)
v[uniq] = _interp_at(inv, s["delta"].to_numpy(), s["iv"].to_numpy(), len(uniq), tgt)
ivs.append(v)
M = np.vstack(ivs)
with np.errstate(invalid="ignore"):
cnt = np.sum(np.isfinite(M), axis=0)
iv = np.where(cnt > 0, np.nansum(np.where(np.isfinite(M), M, 0.0), axis=0) / np.maximum(cnt, 1), np.nan)
out = (q.groupby(keys, sort=True)["dte"].median().reset_index())
out["iv"] = iv
out = out.dropna(subset=["iv"]).reset_index(drop=True)
CACHE.mkdir(parents=True, exist_ok=True)
out.to_parquet(f)
return out
@@ -266,7 +300,7 @@ def px_hourly(a: str) -> pd.Series:
index=pd.DatetimeIndex(pd.to_datetime(d["datetime"], utc=True)))
def _block_pctl(x: np.ndarray, y: np.ndarray, stat: float, L: int, n: int = 800,
def _block_pctl(x: np.ndarray, y: np.ndarray, stat: float, L: int, n: int = 400,
seed: int = 20260822) -> float:
"""Percentile della correlazione osservata contro il null a BLOCCHI (y ricampionata da
blocchi non allineati a x): conserva l'autocorrelazione e rompe solo l'accoppiamento."""
@@ -400,6 +434,51 @@ def build_atm_cost(chain: pd.DataFrame) -> pd.DataFrame:
return atm
def _roll_table(r: pd.DataFrame, H: int) -> pd.DataFrame:
"""Roll IMPLICITO vs REALIZZATO su orizzonte H ore, appaiato per (scadenza, ISTANTE D'INGRESSO).
Appaiato sull'INGRESSO e non sull'uscita: e' l'uscita che le due letture spostano, e un
inner-join sulla data d'uscita terrebbe solo i casi in cui non e' successo niente (trappola
incontrata 3 volte nel progetto: venue-watch, GTAA, VRP profit-take).
impl = iv_curva(T-H) al tempo t - iv(T) al tempo t (se la curva non si muovesse)
real = iv(T-H) al tempo t+H - iv(T) al tempo t (cio' che e' successo davvero)
"""
r = r[["hr", "exp", "iv", "dte"]].dropna()
nxt = r[["hr", "exp", "iv"]].copy()
nxt["hr"] = nxt["hr"] - pd.Timedelta(hours=H)
j = r.merge(nxt.rename(columns={"iv": "iv_fut"}), on=["hr", "exp"], how="inner")
j["T2"] = j["dte"] - H / 24.0
j = j[j["T2"] > DTE_MIN]
if j.empty:
return pd.DataFrame(columns=["dte", "impl", "real"])
# curva dell'ora t, ordinata: chiave monotona globale gid*1000 + dte -> searchsorted unico
c = r.sort_values(["hr", "dte"]).copy()
c["gid"] = c.groupby("hr", sort=True).ngroup()
key = c["gid"].to_numpy() * 1000.0 + c["dte"].to_numpy()
w = (c["iv"].to_numpy() / 100.0) ** 2 * c["dte"].to_numpy() # varianza totale
gmap = dict(zip(c["hr"].to_numpy(), c["gid"].to_numpy()))
n_tot = np.bincount(c["gid"].to_numpy())
start = np.concatenate([[0], np.cumsum(n_tot)[:-1]])
gq = np.array([gmap[h] for h in j["hr"].to_numpy()])
pos = np.searchsorted(key, gq * 1000.0 + j["T2"].to_numpy(), side="left")
lo, hi = pos - 1, pos
ok = (lo >= start[gq]) & (hi < start[gq] + n_tot[gq])
ivT2 = np.full(len(j), np.nan)
if ok.any():
l, h2 = lo[ok], hi[ok]
x0 = key[l] - gq[ok] * 1000.0
x1 = key[h2] - gq[ok] * 1000.0
t2 = j["T2"].to_numpy()[ok]
ww = np.where(x1 != x0, (t2 - x0) / np.where(x1 != x0, x1 - x0, 1.0), 0.0)
var = w[l] + ww * (w[h2] - w[l])
ivT2[ok] = np.sqrt(np.maximum(var, 0.0) / t2) * 100.0
j = j.assign(ivT2=ivT2).dropna(subset=["ivT2"])
return pd.DataFrame({"dte": j["dte"].to_numpy(),
"impl": j["ivT2"].to_numpy() - j["iv"].to_numpy(),
"real": j["iv_fut"].to_numpy() - j["iv"].to_numpy()})
def sezione3(chain: pd.DataFrame, atm_exp: pd.DataFrame, curve: pd.DataFrame) -> None:
hr("SEZIONE 3 — Q2: il CARRY di vol (roll-down) e' incassabile al bid-ask reale?")
print(" Definizione senza modello: un'opzione a tenore T, dopo dt, sta a T-dt. Se la curva non")
@@ -412,25 +491,7 @@ def sezione3(chain: pd.DataFrame, atm_exp: pd.DataFrame, curve: pd.DataFrame) ->
roll_day = {}
for H in (24, 168):
for a in ASSETS:
r = atm_exp[atm_exp.asset == a]
by_hr = {h: g.sort_values("dte") for h, g in r.groupby("hr")}
rows = []
for _, g in r.set_index(["exp", "hr"]).sort_index().groupby(level=0):
g = g.droplevel(0).sort_index()
fut = g["iv"].reindex(g.index + pd.Timedelta(hours=H))
for t, iv0, d0, iv1 in zip(g.index, g["iv"].to_numpy(),
g["dte"].to_numpy(), fut.to_numpy()):
T2 = d0 - H / 24.0
gg = by_hr.get(t)
if not np.isfinite(iv1) or T2 <= DTE_MIN or gg is None or len(gg) < 2:
continue
td, ivd = gg["dte"].to_numpy(), gg["iv"].to_numpy()
if td.min() > T2 or td.max() < T2:
continue
w = (ivd / 100.0) ** 2 * td
ivT2 = np.sqrt(np.interp(T2, td, w) / T2) * 100.0
rows.append((d0, ivT2 - iv0, iv1 - iv0))
d = pd.DataFrame(rows, columns=["dte", "impl", "real"])
d = _roll_table(atm_exp[atm_exp.asset == a], H)
d = d[(d.dte >= 4) & (d.dte <= 45)]
if len(d) < 50:
print(f" {a} dt={H}h: campione insufficiente ({len(d)})")