from __future__ import annotations import math from statistics import NormalDist import pandas as pd from .schemas import OptionChain NORMAL = NormalDist() def realized_volatility( prices: pd.Series, windows: tuple[int, ...] = (5, 10, 20, 30, 60), trading_days: int = 252, ) -> dict[str, float | None]: close = prices.dropna().astype(float) returns = close.pct_change().dropna() output: dict[str, float | None] = {} for window in windows: key = f"{window}d" if len(returns) < window: output[key] = None continue output[key] = float(returns.tail(window).std(ddof=1) * math.sqrt(trading_days)) return output def _norm_pdf(value: float) -> float: return math.exp(-0.5 * value * value) / math.sqrt(2 * math.pi) def black_scholes_greeks( spot: float, strike: float, time_to_expiry: float, volatility: float, risk_free_rate: float = 0.0, dividend_yield: float = 0.0, option_type: str = "call", ) -> dict[str, float | None]: if spot <= 0 or strike <= 0 or time_to_expiry <= 0 or volatility <= 0: return { "delta": None, "gamma": None, "vega": None, "theta": None, "rho": None, } sqrt_t = math.sqrt(time_to_expiry) d1 = ( math.log(spot / strike) + (risk_free_rate - dividend_yield + 0.5 * volatility * volatility) * time_to_expiry ) / (volatility * sqrt_t) d2 = d1 - volatility * sqrt_t discount_dividend = math.exp(-dividend_yield * time_to_expiry) discount_rate = math.exp(-risk_free_rate * time_to_expiry) option_type = option_type.lower() if option_type == "put": delta = discount_dividend * (NORMAL.cdf(d1) - 1) theta = ( -spot * discount_dividend * _norm_pdf(d1) * volatility / (2 * sqrt_t) + dividend_yield * spot * discount_dividend * NORMAL.cdf(-d1) - risk_free_rate * strike * discount_rate * NORMAL.cdf(-d2) ) / 365 rho = -strike * time_to_expiry * discount_rate * NORMAL.cdf(-d2) / 100 else: delta = discount_dividend * NORMAL.cdf(d1) theta = ( -spot * discount_dividend * _norm_pdf(d1) * volatility / (2 * sqrt_t) - dividend_yield * spot * discount_dividend * NORMAL.cdf(d1) + risk_free_rate * strike * discount_rate * NORMAL.cdf(d2) ) / 365 rho = strike * time_to_expiry * discount_rate * NORMAL.cdf(d2) / 100 gamma = discount_dividend * _norm_pdf(d1) / (spot * volatility * sqrt_t) vega = spot * discount_dividend * _norm_pdf(d1) * sqrt_t / 100 return { "delta": float(delta), "gamma": float(gamma), "vega": float(vega), "theta": float(theta), "rho": float(rho), } def nearest_atm_iv(chain: OptionChain) -> float | None: if chain.underlying_price is None: return None contracts = chain.calls + chain.puts valid = [ contract for contract in contracts if contract.implied_volatility is not None and contract.implied_volatility > 0 ] if not valid: return None nearest = min(valid, key=lambda contract: abs(contract.strike - chain.underlying_price)) return nearest.implied_volatility def simple_skew(chain: OptionChain) -> float | None: if chain.underlying_price is None: return None otm_puts = [ contract for contract in chain.puts if contract.strike < chain.underlying_price and contract.implied_volatility ] otm_calls = [ contract for contract in chain.calls if contract.strike > chain.underlying_price and contract.implied_volatility ] if not otm_puts or not otm_calls: return None put = max(otm_puts, key=lambda contract: contract.strike) call = min(otm_calls, key=lambda contract: contract.strike) return float((put.implied_volatility or 0) - (call.implied_volatility or 0)) def summarize_option_chain(chain: OptionChain, realized_vol_20d: float | None = None) -> dict: atm_iv = nearest_atm_iv(chain) return { "symbol": chain.symbol, "expiration": chain.expiration, "underlying_price": chain.underlying_price, "atm_iv": atm_iv, "iv_rv_spread_20d": ( float(atm_iv - realized_vol_20d) if atm_iv is not None and realized_vol_20d is not None else None ), "skew_put_minus_call": simple_skew(chain), "call_count": len(chain.calls), "put_count": len(chain.puts), } def rank_current_iv_against_rv( current_iv: float | None, realized_vols: dict[str, float | None], ) -> float | None: if current_iv is None: return None rv_values = [value for value in realized_vols.values() if value is not None] if len(rv_values) < 2: return None low = min(rv_values) high = max(rv_values) if high <= low: return None return max(0.0, min(1.0, (current_iv - low) / (high - low))) def classify_volatility_regime( current_iv: float | None, realized_vol_20d: float | None, term_structure_slope: float | None, skew: float | None, ) -> dict: if current_iv is None or realized_vol_20d is None: return { "regime": "unknown", "vol_signal": "insufficient_iv_or_rv", "confidence": "low", "notes": ["Need both option implied volatility and realized volatility."], } iv_rv_spread = current_iv - realized_vol_20d notes = [] if iv_rv_spread > 0.08: regime = "high_implied_vol_premium" vol_signal = "short_vol_candidate" notes.append("Current ATM IV is materially above 20D realized volatility.") elif iv_rv_spread < -0.04: regime = "low_implied_vol_discount" vol_signal = "long_vol_candidate" notes.append("Current ATM IV is below 20D realized volatility.") else: regime = "balanced_iv_vs_rv" vol_signal = "neutral_vol" notes.append("Current ATM IV is close to 20D realized volatility.") if term_structure_slope is not None: if term_structure_slope > 0.04: notes.append("Term structure is upward sloping.") elif term_structure_slope < -0.04: notes.append("Term structure is inverted or front-loaded.") if skew is not None and abs(skew) > 0.05: notes.append("Put-call skew is elevated in the sampled expiration.") confidence = "medium" if len(notes) >= 2 else "low" return { "regime": regime, "vol_signal": vol_signal, "confidence": confidence, "notes": notes, }