""" Real option chain fetcher for the Strategy Builder — reuses the same yfinance proxy/resolution logic as iv_engine.py (futures/indices → optionable ETFs). """ import logging import math from datetime import date, datetime from typing import Any, Dict, List, Optional import yfinance as yf from services.iv_engine import _resolve_ticker, _get_current_price logger = logging.getLogger(__name__) def _num(v: Any, default: float = 0.0) -> float: try: f = float(v) return default if math.isnan(f) else f except (TypeError, ValueError): return default def _rows_from_df(df) -> List[Dict[str, Any]]: rows = [] for _, r in df.iterrows(): bid = _num(r.get("bid")) ask = _num(r.get("ask")) rows.append({ "strike": _num(r.get("strike")), "bid": bid, "ask": ask, "mid": round((bid + ask) / 2, 4) if (bid > 0 and ask > 0) else _num(r.get("lastPrice")), "last": _num(r.get("lastPrice")), "iv": _num(r.get("impliedVolatility")), "open_interest": int(_num(r.get("openInterest"))), "volume": int(_num(r.get("volume"))), }) return sorted(rows, key=lambda x: x["strike"]) def get_chain_slice(symbol: str, target_days: int = 8, n_expiries: int = 3) -> Dict[str, Any]: """ Fetch the real option chain for `symbol` around a target horizon (days). Returns the `n_expiries` expirations closest to target_days, each with normalized calls/puts rows (strike, bid, ask, mid, last, iv, open_interest, volume). """ proxy = _resolve_ticker(symbol) t = yf.Ticker(proxy) spot = _get_current_price(t) if not spot: raise ValueError(f"Impossible d'obtenir le prix spot pour {symbol} ({proxy})") expirations = t.options if not expirations: raise ValueError(f"Aucune chaîne d'options disponible pour {symbol} ({proxy})") today = date.today() dated = sorted( expirations, key=lambda e: abs((datetime.strptime(e, "%Y-%m-%d").date() - today).days - target_days), )[:max(1, n_expiries)] expiries_out = [] for exp in dated: try: chain = t.option_chain(exp) days_to_expiry = (datetime.strptime(exp, "%Y-%m-%d").date() - today).days expiries_out.append({ "expiry_date": exp, "days_to_expiry": days_to_expiry, "calls": _rows_from_df(chain.calls), "puts": _rows_from_df(chain.puts), }) except Exception as e: logger.debug(f"[OptionChain] {proxy} {exp}: {e}") if not expiries_out: raise ValueError(f"Aucune chaîne exploitable pour {symbol} ({proxy})") return { "symbol": symbol.upper(), "proxy": proxy, "spot": round(float(spot), 4), "expiries": expiries_out, } def find_quote(chain_slice: Dict[str, Any], expiry_date: str, strike: float, option_type: str) -> Optional[Dict[str, Any]]: """Look up a single contract's quote row within a previously fetched chain slice.""" for exp in chain_slice["expiries"]: if exp["expiry_date"] != expiry_date: continue rows = exp["calls"] if option_type == "call" else exp["puts"] for row in rows: if abs(row["strike"] - strike) < 1e-6: return row return None