""" Options Lab — "was this option well priced between two dates?" A fine-grained pricing audit for a single contract on an instrument, reusing the same `as_of` historical reconstruction built for the Portfolio retrospective comparison (services.strategy_comparison / services.option_chain's as_of param — see project memory). The strike is chosen WITH HINDSIGHT: the one closest to where the underlying actually ended up by `date_b` ("as if we'd guessed the strike correctly"). That's deliberate — a contract near-the-money-at-the-outcome is the one whose value is most sensitive to the realized move, which makes it the most revealing lens on whether the volatility priced in at `date_a` was actually justified by what happened, rather than picking an arbitrary strike that stayed deep OTM/ITM the whole time and would show almost nothing either way. The price move for each leg is decomposed via its own real Greeks at date_a — Delta×Δspot + Theta×elapsed_days + Vega×ΔIV — the "explained" move; whatever's left over ("residual") is what a pure Black-Scholes/Greeks story doesn't account for (gamma curvature, skew shift, liquidity/spread noise, or a genuine pricing anomaly). """ from typing import Any, Dict, List, Optional def _realized_vol(yf_ticker: str, date_a: str, date_b: str) -> Optional[float]: """Annualized realized vol of the underlying's own daily closes over [date_a, date_b] — compared against the option's implied vol at date_a to answer "was IV a good forecast of what actually happened," the classic IV-vs-RV question.""" import numpy as np from services.data_fetcher import get_historical hist = get_historical(yf_ticker, start=date_a, end=date_b, interval="1d") closes = [h["close"] for h in hist if h.get("close")] if len(closes) < 3: return None log_returns = np.diff(np.log(closes)) if len(log_returns) < 2: return None return float(np.std(log_returns, ddof=1) * np.sqrt(252)) def _leg_quote(rows: List[Dict[str, Any]], expiry_date: str, strike: float, option_type: str) -> Optional[Dict[str, Any]]: for r in rows: if r.get("expiry_date") == expiry_date and abs((r.get("strike") or -1e9) - strike) < 1e-6 and r.get("option_type") == option_type: bid, ask = r.get("bid") or 0.0, r.get("ask") or 0.0 mid = r.get("mid") or (round((bid + ask) / 2, 6) if (bid > 0 and ask > 0) else 0.0) vol_pct = r.get("volatility_pct") return {"bid": bid, "ask": ask, "mid": mid, "iv": (float(vol_pct) / 100.0) if vol_pct is not None else None} return None def analyze_option_pricing(ticker: str, date_a: str, date_b: str, target_dte: Optional[int] = None) -> Dict[str, Any]: from datetime import datetime from services.database import get_saxo_option_symbol_for_ticker, get_snapshot_rows_asof from services.option_chain import get_chain_slice from services.options_pricer import black_scholes saxo_symbol = get_saxo_option_symbol_for_ticker(ticker) if not saxo_symbol: return {"available": False, "reason": f"'{ticker}' n'est pas lié à un chain Saxo (Config → Instruments Watchlist)."} if date_b <= date_a: return {"available": False, "reason": "La date de fin doit être postérieure à la date de départ."} # Same hindsight principle as the strike selection below: by default, target the expiry # closest to date_b (not an arbitrary fixed DTE) — so the contract is still evaluated # right around the moment we actually care about, rather than risking one that's been # expired for weeks by date_b just because target_dte was picked independently of it. # An explicit target_dte still overrides this, e.g. to deliberately look at a # longer-dated contract than the comparison window itself. if target_dte is None: target_dte = ( datetime.strptime(date_b[:10], "%Y-%m-%d").date() - datetime.strptime(date_a[:10], "%Y-%m-%d").date() ).days try: chain_a = get_chain_slice(saxo_symbol, target_days=target_dte, n_expiries=1, as_of=date_a) except ValueError as e: return {"available": False, "reason": f"Pas d'historique Saxo au {date_a} : {e}"} expiry = chain_a["expiries"][0] expiry_date = expiry["expiry_date"] spot_a = chain_a["spot"] if spot_a is None: return {"available": False, "reason": "Spot manquant dans l'historique Saxo à la date de départ."} # Many rows in the accumulated history are spot-tracking placeholders with no real # bid/ask (Saxo doesn't actively quote every strike) — only strikes with an actual # tradeable mid (call OR put) are candidates for the hindsight pick below, otherwise # the "closest to the outcome" strike could be one that was never really quoted at all. strikes_a = sorted({ row["strike"] for row in (expiry["calls"] + expiry["puts"]) if row.get("mid", 0) > 0 }) if not strikes_a: return { "available": False, "reason": f"Aucun strike avec une cotation réelle (bid/ask) dans l'historique Saxo au {date_a} pour l'échéance {expiry_date} — essayez un autre DTE cible ou une autre date.", } rows_b = get_snapshot_rows_asof(saxo_symbol, date_b) if not rows_b: return {"available": False, "reason": f"Pas d'historique Saxo au {date_b}."} spot_b = next((r["spot"] for r in rows_b if r.get("spot") is not None), None) if spot_b is None: return {"available": False, "reason": "Spot manquant dans l'historique Saxo à la date de fin."} # "As if we'd guessed the strike" — closest to where the underlying actually ended up. chosen_strike = min(strikes_a, key=lambda k: abs(k - spot_b)) rows_a = get_snapshot_rows_asof(saxo_symbol, date_a) d_a = datetime.strptime(date_a[:10], "%Y-%m-%d").date() d_b = datetime.strptime(date_b[:10], "%Y-%m-%d").date() d_exp = datetime.strptime(expiry_date[:10], "%Y-%m-%d").date() elapsed_days = (d_b - d_a).days days_to_expiry_a = (d_exp - d_a).days days_to_expiry_b = (d_exp - d_b).days expired_by_b = days_to_expiry_b <= 0 r = 0.05 legs_out: Dict[str, Any] = {} for opt_type in ("call", "put"): q_a = _leg_quote(rows_a, expiry_date, chosen_strike, opt_type) if not q_a or q_a["mid"] <= 0 or q_a["iv"] is None: legs_out[opt_type] = {"available": False} continue greeks_a = black_scholes(spot_a, chosen_strike, max(days_to_expiry_a, 1) / 365, r, q_a["iv"], opt_type) intrinsic_a = max(0.0, spot_a - chosen_strike) if opt_type == "call" else max(0.0, chosen_strike - spot_a) time_value_a = q_a["mid"] - intrinsic_a if expired_by_b: price_b = max(0.0, spot_b - chosen_strike) if opt_type == "call" else max(0.0, chosen_strike - spot_b) iv_b, intrinsic_b, time_value_b = None, price_b, 0.0 else: q_b = _leg_quote(rows_b, expiry_date, chosen_strike, opt_type) if not q_b or q_b["mid"] <= 0: legs_out[opt_type] = {"available": False} continue price_b, iv_b = q_b["mid"], q_b["iv"] intrinsic_b = max(0.0, spot_b - chosen_strike) if opt_type == "call" else max(0.0, chosen_strike - spot_b) time_value_b = price_b - intrinsic_b actual_change = price_b - q_a["mid"] delta_pnl = greeks_a["delta"] * (spot_b - spot_a) theta_pnl = greeks_a["theta"] * elapsed_days vega_pnl = greeks_a["vega"] * ((iv_b - q_a["iv"]) * 100) if iv_b is not None else 0.0 explained = delta_pnl + theta_pnl + vega_pnl legs_out[opt_type] = { "available": True, "price_a": round(q_a["mid"], 4), "price_b": round(price_b, 4), "actual_change": round(actual_change, 4), "iv_a": round(q_a["iv"], 4), "iv_b": round(iv_b, 4) if iv_b is not None else None, "intrinsic_a": round(intrinsic_a, 4), "time_value_a": round(time_value_a, 4), "intrinsic_b": round(intrinsic_b, 4), "time_value_b": round(time_value_b, 4), "greeks_a": {k: greeks_a[k] for k in ("delta", "gamma", "theta", "vega")}, "attribution": { "delta_pnl": round(delta_pnl, 4), "theta_pnl": round(theta_pnl, 4), "vega_pnl": round(vega_pnl, 4), "explained": round(explained, 4), "residual": round(actual_change - explained, 4), }, } realized_vol = _realized_vol(ticker, date_a, date_b) iv_a_ref = next((legs_out[t]["iv_a"] for t in ("call", "put") if legs_out.get(t, {}).get("available")), None) return { "available": True, "ticker": ticker, "saxo_symbol": saxo_symbol, "date_a": date_a, "date_b": date_b, "elapsed_days": elapsed_days, "target_dte_used": target_dte, "expiry_date": expiry_date, "expired_by_date_b": expired_by_b, "spot_a": round(spot_a, 6), "spot_b": round(spot_b, 6), "spot_change_pct": round((spot_b - spot_a) / spot_a * 100, 2) if spot_a else None, "chosen_strike": chosen_strike, "iv_a": iv_a_ref, "realized_vol": round(realized_vol, 4) if realized_vol is not None else None, "vol_risk_premium": ( round(iv_a_ref - realized_vol, 4) if (iv_a_ref is not None and realized_vol is not None) else None ), "legs": legs_out, }