c997bce00e
attach_market(df, asset): merge_asof causale del pannello market_snapshots (spot/VRP/funding/net-GEX/gamma-flip/liquidation) sul prezzo, pronto per regime_lab. Fix look-ahead: merge su datetime tz-aware (ns-aligned), MAI astype(int64) su datetime (darebbe ns -> match all'ultimo snapshot = leak). Copertura reale documentata: net-GEX denso solo da ~2026-05-01 (~5-6 sett, singolo regime) -> infrastruttura pronta, edge validabile solo forward. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
191 lines
9.7 KiB
Python
191 lines
9.7 KiB
Python
"""Loader + lookup PREZZI REALI della catena opzioni (da cerbero-bite, data/options/).
|
|
|
|
Fonte: scripts/analysis/options_fetcher.py importa lo storico per-strike reale di
|
|
cerbero-bite (bid/ask/mid/IV/greche/OI/vol, BTC+ETH, dal 2026-05-01, cadenza ~12min)
|
|
in data/options/{eth,btc}_chain.parquet.
|
|
|
|
NB granularita': cerbero-bite snapshotta una FETTA ROTANTE della catena (~1 scadenza
|
|
per volta). Quindi:
|
|
- gli AGGREGATI (curva di skew, livelli di premio per moneyness/tenor) sono ROBUSTI
|
|
e sono l'uso principale -> skew_curve(), premium_levels();
|
|
- il lookup per-trade quote() e' BEST-EFFORT con una finestra di staleness
|
|
(default 48h) e ritorna il flag staleness_h: un backtest per-trade preciso e'
|
|
limitato dalla sparsita', usalo con cautela.
|
|
|
|
Uso:
|
|
from scripts.analysis.options_chain import OptionChain
|
|
oc = OptionChain("ETH")
|
|
oc.premium_levels() # tabella premi reali per moneyness x tenor
|
|
q = oc.quote(ts_ms, spot=1700, otm=0.10, opt_type="P", min_tenor_d=5, max_tenor_d=14)
|
|
# q = dict(ask_pct, iv, atm_iv, skew, spread_pct, oi, delta, strike, tenor_d, staleness_h)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
sys.path.insert(0, str(PROJECT_ROOT))
|
|
OPT_DIR = PROJECT_ROOT / "data" / "options"
|
|
|
|
_NUM = ["strike", "bid", "ask", "mid", "iv", "delta", "gamma", "theta", "vega"]
|
|
|
|
|
|
def load_chain(asset: str) -> pd.DataFrame:
|
|
df = pd.read_parquet(OPT_DIR / f"{asset.lower()}_chain.parquet")
|
|
for c in _NUM:
|
|
df[c] = pd.to_numeric(df[c], errors="coerce")
|
|
for c in ("open_interest", "volume_24h"):
|
|
df[c] = pd.to_numeric(df[c], errors="coerce")
|
|
df["ts"] = pd.to_datetime(df["timestamp"], utc=True)
|
|
df["exp"] = pd.to_datetime(df["expiry"], utc=True)
|
|
df["ts_ms"] = (df["ts"].astype("int64") // 10**6)
|
|
df["tenor_d"] = (df["exp"] - df["ts"]).dt.total_seconds() / 86400.0
|
|
return df.sort_values("ts_ms").reset_index(drop=True)
|
|
|
|
|
|
_MKT_NUM = ["spot", "dvol", "realized_vol_30d", "iv_minus_rv", "funding_perp_annualized",
|
|
"funding_cross_annualized", "dealer_net_gamma", "gamma_flip_level",
|
|
"oi_delta_pct_4h", "macro_days_to_event"]
|
|
|
|
|
|
def load_market(asset: str | None = None) -> pd.DataFrame:
|
|
"""Pannello regime REALE pre-calcolato da cerbero-bite (market_snapshots, dal 2026-03-26,
|
|
~15min): spot, dvol, realized_vol_30d, iv_minus_rv (VRP), funding perp/cross,
|
|
dealer_net_gamma (net-GEX), gamma_flip_level, oi_delta_pct_4h, liquidation_long/short_risk.
|
|
asset=None -> tutti. Restituisce ordinato per ts con colonne ts (UTC) e ts_ms."""
|
|
df = pd.read_parquet(OPT_DIR / "market_snapshots.parquet")
|
|
if asset is not None:
|
|
df = df[df["asset"] == asset].copy()
|
|
for c in _MKT_NUM:
|
|
if c in df.columns:
|
|
df[c] = pd.to_numeric(df[c], errors="coerce")
|
|
df["ts"] = pd.to_datetime(df["timestamp"], utc=True, format="ISO8601")
|
|
df["ts_ms"] = df["ts"].astype("int64") // 10**6
|
|
return df.sort_values("ts_ms").reset_index(drop=True)
|
|
|
|
|
|
def attach_market(price_df: pd.DataFrame, asset: str, cols: list[str] | None = None) -> pd.DataFrame:
|
|
"""merge_asof CAUSALE: ogni barra di price_df (serve colonna 'timestamp' in ms) riceve
|
|
l'ultimo market_snapshot con ts <= barra. Pannello pronto per regime_lab/ricerca:
|
|
spot, dvol, realized_vol_30d, iv_minus_rv (VRP), funding perp/cross, dealer_net_gamma
|
|
(net-GEX), gamma_flip_level, oi_delta_pct_4h, liquidation_long/short_risk.
|
|
Ritorna una copia con le colonne pannello (NaN dove non c'e' ancora storia: dal 2026-03-26)."""
|
|
m = load_market(asset)
|
|
cols = cols or (_MKT_NUM + ["liquidation_long_risk", "liquidation_short_risk"])
|
|
keep = [c for c in cols if c in m.columns]
|
|
base = price_df.copy()
|
|
# chiave di merge = datetime tz-aware (robusto a timestamp int-ms o datetime; NIENTE astype int64
|
|
# su datetime -> darebbe nanosecondi e matcherebbe tutto all'ultimo snapshot = LOOK-AHEAD).
|
|
if np.issubdtype(base["timestamp"].dtype, np.integer):
|
|
base["_k"] = pd.to_datetime(base["timestamp"], unit="ms", utc=True)
|
|
else:
|
|
base["_k"] = pd.to_datetime(base["timestamp"], utc=True)
|
|
base["_k"] = base["_k"].astype("datetime64[ns, UTC]")
|
|
mk = m[["ts"] + keep].rename(columns={"ts": "_k"})
|
|
mk["_k"] = mk["_k"].astype("datetime64[ns, UTC]")
|
|
out = pd.merge_asof(base.sort_values("_k"), mk.sort_values("_k"),
|
|
on="_k", direction="backward")
|
|
return out.drop(columns="_k")
|
|
|
|
|
|
class OptionChain:
|
|
def __init__(self, asset: str):
|
|
self.asset = asset
|
|
self.df = load_chain(asset)
|
|
self._ts = self.df["ts_ms"].values
|
|
|
|
# ---------------- aggregati robusti ----------------
|
|
def _spot_proxy(self) -> pd.Series:
|
|
"""spot ~ strike del put con delta piu' vicino a -0.5, per snapshot."""
|
|
puts = self.df[self.df["option_type"] == "P"].copy()
|
|
puts["d_atm"] = (puts["delta"] + 0.5).abs()
|
|
atm = puts.sort_values("d_atm").groupby("timestamp").first()
|
|
return atm["strike"]
|
|
|
|
def skew_curve(self, opt_type: str = "P") -> pd.DataFrame:
|
|
"""IV mediana / IV-ATM per banda di moneyness (strike/spot)."""
|
|
spot = self._spot_proxy()
|
|
d = self.df[self.df["option_type"] == opt_type].copy()
|
|
d["spot"] = d["timestamp"].map(spot)
|
|
d = d.dropna(subset=["spot", "iv"])
|
|
d["m"] = d["strike"] / d["spot"]
|
|
atm_iv = d.assign(da=(d["delta"].abs() - 0.5).abs()).sort_values("da").groupby("timestamp")["iv"].first()
|
|
d["atm_iv"] = d["timestamp"].map(atm_iv)
|
|
d["skew"] = d["iv"] / d["atm_iv"]
|
|
bins = [0.7, 0.85, 0.9, 0.95, 1.0, 1.05, 1.1, 1.15, 1.3]
|
|
d["band"] = pd.cut(d["m"], bins)
|
|
g = d.groupby("band", observed=True).agg(
|
|
n=("skew", "size"), iv_med=("iv", "median"), skew_med=("skew", "median"),
|
|
spread_med=("ask", lambda s: float("nan")))
|
|
# spread% mediano
|
|
d["spread_pct"] = (d["ask"] - d["bid"]) / ((d["ask"] + d["bid"]) / 2) * 100
|
|
g["spread_med%"] = d.groupby("band", observed=True)["spread_pct"].median()
|
|
g["oi_med"] = d.groupby("band", observed=True)["open_interest"].median()
|
|
return g.drop(columns=["spread_med"])
|
|
|
|
def premium_levels(self, opt_type: str = "P") -> pd.DataFrame:
|
|
"""premio reale mediano (ask, %spot) per banda moneyness x tenor."""
|
|
spot = self._spot_proxy()
|
|
d = self.df[self.df["option_type"] == opt_type].copy()
|
|
d["spot"] = d["timestamp"].map(spot)
|
|
d = d.dropna(subset=["spot", "ask"])
|
|
d["m"] = d["strike"] / d["spot"]
|
|
d["ask_pct"] = d["ask"] * 100.0 # ask quotato in coin -> %spot = ask*100
|
|
d["tenor_b"] = pd.cut(d["tenor_d"], [0, 3.5, 14, 45, 400],
|
|
labels=["1-3d", "4-14d", "15-45d", ">45d"])
|
|
d["m_b"] = pd.cut(d["m"], [0.7, 0.85, 0.9, 0.95, 1.05, 1.15, 1.3],
|
|
labels=["<-10%", "-10%", "-7%", "ATM", "+7%", "+10%"])
|
|
g = d.groupby(["m_b", "tenor_b"], observed=True).agg(
|
|
n=("ask_pct", "size"), prem_pct=("ask_pct", "median"),
|
|
iv=("iv", "median"), oi=("open_interest", "median"))
|
|
return g
|
|
|
|
# ---------------- lookup per-trade (best effort) ----------------
|
|
def quote(self, ts_ms: int, spot: float, otm: float = 0.10, opt_type: str = "P",
|
|
min_tenor_d: float = 5.0, max_tenor_d: float = 14.0,
|
|
max_staleness_h: float = 48.0) -> dict | None:
|
|
"""Quote REALE piu' fresca <= ts_ms per la protezione a `otm` OTM e tenor nel range.
|
|
put: strike target = spot*(1-otm); call: spot*(1+otm). None se nulla nella finestra."""
|
|
lo = ts_ms - int(max_staleness_h * 3600 * 1000)
|
|
i1 = np.searchsorted(self._ts, ts_ms, "right")
|
|
i0 = np.searchsorted(self._ts, lo, "left")
|
|
if i1 <= i0:
|
|
return None
|
|
w = self.df.iloc[i0:i1]
|
|
w = w[w["option_type"] == opt_type]
|
|
w = w[(w["tenor_d"] >= min_tenor_d) & (w["tenor_d"] <= max_tenor_d)]
|
|
if w.empty:
|
|
return None
|
|
# ultima quota per strumento, poi strike piu' vicino al target
|
|
w = w.sort_values("ts_ms").groupby("instrument_name").tail(1)
|
|
target = spot * (1 - otm) if opt_type == "P" else spot * (1 + otm)
|
|
row = w.iloc[(w["strike"] - target).abs().argmin()]
|
|
atm = w.iloc[(w["delta"].abs() - 0.5).abs().argmin()]
|
|
ask, bid = float(row["ask"]), float(row["bid"])
|
|
return dict(
|
|
ask_pct=ask * 100.0, iv=float(row["iv"]), atm_iv=float(atm["iv"]),
|
|
skew=float(row["iv"]) / float(atm["iv"]) if atm["iv"] else float("nan"),
|
|
spread_pct=(ask - bid) / ((ask + bid) / 2) * 100 if (ask + bid) > 0 else float("nan"),
|
|
oi=int(row["open_interest"] or 0), delta=float(row["delta"]),
|
|
strike=float(row["strike"]), tenor_d=float(row["tenor_d"]),
|
|
staleness_h=(ts_ms - int(row["ts_ms"])) / 3600000.0,
|
|
instrument=row["instrument_name"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
for asset in ("ETH", "BTC"):
|
|
oc = OptionChain(asset)
|
|
print(f"\n===== {asset} — {len(oc.df)} righe, {oc.df['ts'].min().date()} -> {oc.df['ts'].max().date()} =====")
|
|
print("\n--- curva di skew (put, IV mediana / IV-ATM) ---")
|
|
print(oc.skew_curve("P").to_string())
|
|
print("\n--- premi reali mediani (ask %spot) per moneyness x tenor ---")
|
|
print(oc.premium_levels("P").to_string())
|
|
# quote demo: ultimo ts disponibile, 10% OTM put settimanale
|
|
ts = int(oc.df["ts_ms"].iloc[-1]); sp = float(oc._spot_proxy().iloc[-1])
|
|
q = oc.quote(ts, spot=sp, otm=0.10, opt_type="P", min_tenor_d=5, max_tenor_d=14)
|
|
print(f"\n--- quote demo: put 10%OTM settimanale ~spot {sp:.0f} ---\n {q}")
|