""" Thin Saxo OpenAPI REST client for options chain snapshots. Two parts have high confidence (directly verified against Saxo's docs): the OAuth flow (services/saxo_auth.py) and the general shape of a streaming *subscription* call (ContextId/ReferenceId/Arguments, POST-then-DELETE for a one-off snapshot). One part has LOWER confidence and is written defensively on purpose: the exact field names returned by /ref/v1/instruments and /ref/v1/instruments/contractoptionspaces (Saxo's Swagger UI didn't expose the full response schema to static fetching). Every extraction below tries a couple of documented field-name variants and raises a clear error showing the raw keys received if none match — this is the one piece worth a quick calibration pass against a real response once SIM is connected. """ import logging import time import uuid from datetime import date from typing import Any, Dict, List, Optional import httpx from services.saxo_auth import SAXO_API_BASE_URL, get_valid_access_token logger = logging.getLogger(__name__) _OPTION_ASSET_TYPES = "StockOption,StockIndexOption,FuturesOption,ContractFutures" # symbol -> option root Uic, cheap in-process cache (roots don't change within a session) _root_uic_cache: Dict[str, int] = {} class SaxoNotConnected(Exception): pass def _headers() -> Dict[str, str]: token = get_valid_access_token() if not token: raise SaxoNotConnected("Saxo n'est pas connecté — complétez l'OAuth login d'abord.") return {"Authorization": f"Bearer {token}"} def _get(path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: resp = httpx.get(f"{SAXO_API_BASE_URL}{path}", headers=_headers(), params=params or {}, timeout=15) resp.raise_for_status() return resp.json() def _first(d: Dict[str, Any], *keys: str) -> Any: for k in keys: if k in d: return d[k] return None def resolve_option_root_uic(symbol: str) -> int: if symbol in _root_uic_cache: return _root_uic_cache[symbol] data = _get("/ref/v1/instruments", {"Keywords": symbol, "AssetTypes": _OPTION_ASSET_TYPES}) items = data.get("Data") or data.get("data") or [] if not items: raise ValueError(f"Aucun option root Saxo trouvé pour '{symbol}' (réponse: {list(data.keys())})") uic = _first(items[0], "Uic", "uic", "Identifier") if uic is None: raise ValueError(f"Champ Uic introuvable dans la réponse instruments pour '{symbol}': {items[0]}") _root_uic_cache[symbol] = int(uic) return int(uic) def get_option_space(root_uic: int) -> Dict[str, Any]: return _get(f"/ref/v1/instruments/contractoptionspaces/{root_uic}") def _extract_option_legs(space: Dict[str, Any]) -> List[Dict[str, Any]]: """Flatten the option space response into [{uic, expiry_date, strike, option_type}, ...].""" expiries = _first(space, "OptionSpace", "SpecificOptions", "Expiries") or [] legs: List[Dict[str, Any]] = [] # Some Saxo response shapes nest strikes/sides under each expiry; others return a flat # list of contracts directly. Handle both defensively. for entry in expiries: expiry_date = _first(entry, "Expiry", "ExpiryDate", "Date") strikes = _first(entry, "SpecificOptions", "Strikes", "Options") or [] for opt in strikes: uic = _first(opt, "Uic", "uic") strike = _first(opt, "StrikePrice", "Strike") side = _first(opt, "PutCall", "OptionType", "Side") if uic is None or strike is None or side is None: continue legs.append({ "uic": int(uic), "expiry_date": expiry_date, "strike": float(strike), "option_type": "put" if str(side).lower().startswith("p") else "call", }) if not legs: raise ValueError(f"Impossible d'extraire des contrats depuis contractoptionspaces (clés reçues: {list(space.keys())})") return legs def _snapshot_via_subscription(uic: int, asset_type: str = "StockOption") -> Dict[str, Any]: """POST creates a subscription and returns an initial snapshot in the same response; DELETE immediately after — no websocket needs to be kept alive for a one-off read.""" context_id = f"of-{uuid.uuid4().hex[:12]}" reference_id = "chain" token_headers = _headers() body = { "ContextId": context_id, "ReferenceId": reference_id, "Arguments": {"Uic": uic, "AssetType": asset_type}, "RefreshRate": 5000, } resp = httpx.post( f"{SAXO_API_BASE_URL}/trade/v1/optionschain/subscriptions", headers={**token_headers, "Content-Type": "application/json"}, json=body, timeout=15, ) resp.raise_for_status() snapshot = resp.json() try: httpx.delete( f"{SAXO_API_BASE_URL}/trade/v1/optionschain/subscriptions/{context_id}/{reference_id}", headers=token_headers, timeout=10, ) except Exception as e: logger.warning(f"[Saxo] Failed to clean up options-chain subscription {context_id}: {e}") return snapshot def snapshot_options_chain(symbol: str, target_days: int = 30) -> List[Dict[str, Any]]: """ Returns normalized rows ready for services/database.save_saxo_snapshot_rows: {symbol, snapshot_date, spot, expiry_date, strike, option_type, bid, ask, mid, volatility_pct, delta, gamma, theta, vega} """ root_uic = resolve_option_root_uic(symbol) space = get_option_space(root_uic) legs = _extract_option_legs(space) snapshot = _snapshot_via_subscription(root_uic) spot = _first(snapshot, "UnderlyingSpotPrice", "Spot", "UnderlyingPrice") snapshot_date = date.today().isoformat() by_uic = {leg["uic"]: leg for leg in legs} rows: List[Dict[str, Any]] = [] for expiry_block in (_first(snapshot, "Expiries", "OptionsChain") or []): for strike_block in (_first(expiry_block, "Strikes") or []): for side_key in ("Call", "Put", "call", "put"): side = strike_block.get(side_key) if not side: continue uic = _first(side, "Uic", "ContractId") leg = by_uic.get(int(uic)) if uic is not None else None rows.append({ "symbol": symbol.upper(), "snapshot_date": snapshot_date, "spot": float(spot) if spot is not None else None, "expiry_date": _first(expiry_block, "Expiry", "ExpiryDate") or (leg["expiry_date"] if leg else None), "strike": float(_first(strike_block, "Strike", "StrikePrice") or (leg["strike"] if leg else 0)), "option_type": "put" if side_key.lower() == "put" else "call", "bid": _first(side, "Bid"), "ask": _first(side, "Ask"), "mid": _first(side, "Mid"), "volatility_pct": _first(strike_block, "MidVolatilityPct", "VolatilityPct"), "delta": _first(side, "DeltaPct", "Delta"), "gamma": _first(side, "Gamma"), "theta": _first(side, "Theta"), "vega": _first(side, "Vega"), }) if not rows: raise ValueError(f"Snapshot Saxo vide pour '{symbol}' (clés reçues: {list(snapshot.keys())})") return rows