research(wave-0822): DEALER-GAMMA scartato — e dealer_net_gamma e' il GEX col segno invertito
This commit is contained in:
@@ -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)})")
|
||||
|
||||
Reference in New Issue
Block a user