from __future__ import annotations from datetime import datetime import numpy as np import pandas as pd from .types import DensityEstimate def select_density_slice( options: pd.DataFrame, spot: float, expiry: datetime | None = None, moneyness_band: float = 0.2, min_open_interest: int = 1, min_volume: int = 0, ) -> tuple[pd.Series, pd.Series, datetime]: calls = options[options["option_type"] == "call"].copy() if calls.empty: raise ValueError("No call options available") if expiry is None: counts = calls.groupby("expiry").size().sort_values(ascending=False) expiry = pd.to_datetime(counts.index[0]).to_pydatetime() c = calls[calls["expiry"] == pd.to_datetime(expiry)].copy() c = c[ (c["strike"] >= (1.0 - moneyness_band) * spot) & (c["strike"] <= (1.0 + moneyness_band) * spot) ] if "openInterest" in c.columns: c = c[c["openInterest"].fillna(0) >= min_open_interest] if "volume" in c.columns: c = c[c["volume"].fillna(0) >= min_volume] c = c.sort_values("strike") if len(c) < 4: raise ValueError("Not enough filtered strikes for density estimation") return ( c["strike"].astype(float), c["mid"].astype(float), pd.to_datetime(expiry).to_pydatetime(), ) def estimate_rn_density( strikes: pd.Series, call_prices: pd.Series, expiry: datetime, smooth_window: int = 3, ) -> DensityEstimate: k = np.asarray(strikes, dtype=float) c = np.asarray(call_prices, dtype=float) if len(k) < 4: raise ValueError("Need at least 4 strikes to estimate density") if np.any(np.diff(k) <= 0): raise ValueError("Strikes must be strictly increasing") if smooth_window > 1: c = ( pd.Series(c) .rolling(window=smooth_window, min_periods=1, center=True) .mean() .to_numpy() ) d2 = np.gradient(np.gradient(c, k), k) rho = np.clip(d2, 0.0, None) mass = np.trapezoid(rho, k) if mass <= 0: raise ValueError("Estimated density has non-positive mass") rho = rho / mass return DensityEstimate( strikes=pd.Series(k), density=pd.Series(rho), expiry=expiry, )