""" Options analytics computed exclusively from our own accumulated Saxo snapshot history (services.database.saxo_option_snapshots) — deliberately never blended with the yfinance-based services.iv_engine, so IV/skew/term-structure for a Saxo watchlist symbol always reflects what the broker itself quoted. Powers Options Lab's Saxo section. Mirrors iv_engine.py's output shape (iv_current_pct, iv_rank, iv_percentile, history_days, iv_change_1d_pct, skew, term_structure, signal) so the frontend can reuse the same rendering patterns — just fed by a different, non-mixed data source. Options flow (open interest based) isn't available: Saxo snapshots don't carry OI/volume. """ from datetime import date, datetime, timezone from typing import Any, Dict, List, Optional _DAYS_TARGETS = {"iv_30d": 30, "iv_60d": 60, "iv_90d": 90, "iv_180d": 180} def _days_to(expiry_date: str, today: date) -> int: return (datetime.strptime(expiry_date[:10], "%Y-%m-%d").date() - today).days def _atm_iv(rows: List[Dict[str, Any]], spot: Optional[float], target_days: int, today: date) -> Optional[Dict[str, Any]]: """Pick the expiry closest to target_days among `rows` (already one row per contract), then the strike closest to spot within it. Averages call+put IV at that strike.""" if not rows or not spot: return None by_expiry: Dict[str, List[Dict[str, Any]]] = {} for r in rows: by_expiry.setdefault(r["expiry_date"], []).append(r) candidates = [(e, _days_to(e, today)) for e in by_expiry if _days_to(e, today) >= 0] if not candidates: return None best_exp, best_days = min(candidates, key=lambda x: abs(x[1] - target_days)) exp_rows = by_expiry[best_exp] strikes = sorted({r["strike"] for r in exp_rows}) if not strikes: return None atm_strike = min(strikes, key=lambda s: abs(s - spot)) ivs = [r["volatility_pct"] for r in exp_rows if r["strike"] == atm_strike and r.get("volatility_pct")] if not ivs: return None return { "iv_pct": round(sum(ivs) / len(ivs), 1), "expiry_date": best_exp, "days_to_expiry": best_days, } def _skew(rows: List[Dict[str, Any]], target_days: int, today: date) -> Dict[str, Any]: """25-delta put IV minus 25-delta call IV, using Saxo's own delta field (no strike approximation needed, unlike the yfinance path which has to estimate ~10%/90% OTM).""" result: Dict[str, Any] = {"put_skew": None, "skew_pct": None, "iv_put_25d": None, "iv_call_25d": None, "interpretation": None} if not rows: return result by_expiry: Dict[str, List[Dict[str, Any]]] = {} for r in rows: by_expiry.setdefault(r["expiry_date"], []).append(r) candidates = [(e, _days_to(e, today)) for e in by_expiry if _days_to(e, today) >= 0] if not candidates: return result best_exp, _ = min(candidates, key=lambda x: abs(x[1] - target_days)) exp_rows = by_expiry[best_exp] puts = [r for r in exp_rows if r["option_type"] == "put" and r.get("delta") is not None and r.get("volatility_pct")] calls = [r for r in exp_rows if r["option_type"] == "call" and r.get("delta") is not None and r.get("volatility_pct")] if not puts or not calls: return result put_row = min(puts, key=lambda r: abs(r["delta"] - (-0.25))) call_row = min(calls, key=lambda r: abs(r["delta"] - 0.25)) iv_put_pct, iv_call_pct = put_row["volatility_pct"], call_row["volatility_pct"] put_skew = (iv_put_pct - iv_call_pct) / 100 # decimal, matches iv_engine.py's convention result["put_skew"] = round(put_skew, 4) result["skew_pct"] = round(iv_put_pct - iv_call_pct, 1) result["iv_put_25d"] = round(iv_put_pct, 1) result["iv_call_25d"] = round(iv_call_pct, 1) if put_skew > 0.03: result["interpretation"] = "Market buying puts — high downside protection" elif put_skew < -0.02: result["interpretation"] = "Market buying calls — speculative bullish bias" else: result["interpretation"] = "Balanced skew — no strong directional bias" return result def _term_structure(rows: List[Dict[str, Any]], spot: Optional[float], today: date) -> Dict[str, Any]: result: Dict[str, Any] = {"iv_30d": None, "iv_60d": None, "iv_90d": None, "iv_180d": None, "structure": None} for field, target in _DAYS_TARGETS.items(): atm = _atm_iv(rows, spot, target, today) if atm and abs(atm["days_to_expiry"] - target) <= 20: result[field] = round(atm["iv_pct"] / 100, 4) # decimal, matches iv_engine.py's convention iv30, iv90 = result.get("iv_30d"), result.get("iv_90d") if iv30 and iv90: diff = iv90 - iv30 result["structure"] = "contango" if diff > 0.015 else "backwardation" if diff < -0.015 else "flat" return result def _daily_atm_series(symbol: str, target_days: int = 30, days: int = 365) -> List[Dict[str, Any]]: """One ATM-IV point per day that has Saxo snapshots, oldest first.""" from services.database import get_saxo_daily_snapshot_rows daily_rows = get_saxo_daily_snapshot_rows(symbol, days=days) by_date: Dict[str, List[Dict[str, Any]]] = {} for r in daily_rows: by_date.setdefault(r["snapshot_date"][:10], []).append(r) series = [] for d, rows in sorted(by_date.items()): spot = next((r["spot"] for r in rows if r.get("spot") is not None), None) try: ref_date = datetime.strptime(d, "%Y-%m-%d").date() except ValueError: continue atm = _atm_iv(rows, spot, target_days, ref_date) if atm: series.append({"date": d, "iv_pct": atm["iv_pct"]}) return series def get_saxo_iv_snapshot(symbol: str, target_days: int = 30) -> Dict[str, Any]: """Full Saxo-only IV snapshot for one watchlist symbol: current ATM IV, rank/percentile (from Saxo's own history), term structure, skew, day-over-day change.""" from services.database import get_latest_saxo_snapshot_rows symbol = symbol.upper() today = date.today() rows = get_latest_saxo_snapshot_rows(symbol) spot = next((r["spot"] for r in rows if r.get("spot") is not None), None) if rows else None atm = _atm_iv(rows, spot, target_days, today) if rows else None iv_current_pct = atm["iv_pct"] if atm else None series = _daily_atm_series(symbol, target_days) history_days = len(series) iv_rank = iv_percentile = iv_min_52w = iv_max_52w = None iv_change_1d_pct = None if series: hist_vals = [p["iv_pct"] for p in series] iv_min_52w, iv_max_52w = min(hist_vals), max(hist_vals) if iv_current_pct is not None: if iv_max_52w > iv_min_52w: iv_rank = round((iv_current_pct - iv_min_52w) / (iv_max_52w - iv_min_52w) * 100, 1) else: iv_rank = 50.0 iv_percentile = round(sum(1 for v in hist_vals if v < iv_current_pct) / len(hist_vals) * 100, 1) # Day-over-day: last series point strictly before today vs current todays_iso = today.isoformat() prior = [p for p in series if p["date"] < todays_iso] if prior and iv_current_pct is not None: iv_change_1d_pct = round(iv_current_pct - prior[-1]["iv_pct"], 1) return { "ticker": symbol, "proxy": symbol, "iv_current_pct": iv_current_pct, "iv_change_1d_pct": iv_change_1d_pct, "iv_rank": iv_rank, "iv_percentile": iv_percentile, "history_days": history_days, "iv_min_52w_pct": iv_min_52w, "iv_max_52w_pct": iv_max_52w, "term_structure": _term_structure(rows, spot, today) if rows else _term_structure([], None, today), "skew": _skew(rows, target_days, today) if rows else _skew([], target_days, today), "options_flow": {}, # not available — Saxo snapshots carry no open interest/volume "fetched_at": datetime.now(timezone.utc).isoformat(), "iv_source": "saxo" if atm else "none", "spot": spot, } def get_saxo_iv_history(symbol: str, days: int = 90) -> Dict[str, Any]: series = _daily_atm_series(symbol.upper(), days=days) # Match iv_engine's get_iv_history row shape (recorded_date, iv_current as a decimal) history = [{"recorded_date": p["date"], "iv_current": p["iv_pct"] / 100} for p in reversed(series)] return {"ticker": symbol, "proxy": symbol, "history": history, "count": len(history)} def get_saxo_iv_watchlist() -> Dict[str, Any]: """Summary row per symbol in the Saxo watchlist (services.saxo_scheduler) — the same symbols already being snapshotted every ~5 min, no separate mapping to maintain.""" from services.saxo_scheduler import get_watchlist results = [] for symbol in get_watchlist(): snap = get_saxo_iv_snapshot(symbol) if snap["iv_current_pct"] is None: continue rank = snap["iv_rank"] results.append({ "ticker": symbol, "iv_current_pct": snap["iv_current_pct"], "iv_change_1d_pct": snap["iv_change_1d_pct"], "iv_rank": rank, "iv_percentile": snap["iv_percentile"], "history_days": snap["history_days"], "iv_source": "saxo", "signal": ( "sell_vol" if (rank or 0) > 80 else "buy_vol" if (rank or 100) < 20 else "neutral" ), }) results.sort(key=lambda x: -(x.get("iv_rank") or 0)) return {"items": results, "count": len(results)}