""" 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" # symbol -> resolved instrument details, cheap in-process cache (roots don't change within a session) _root_uic_cache: Dict[str, Dict[str, Any]] = {} class SaxoNotConnected(Exception): pass class SaxoApiError(Exception): """Wraps an httpx.HTTPStatusError with Saxo's actual JSON error body (ErrorCode/Message), since the bare status code is useless for debugging a 400 on a subscription body.""" def __init__(self, resp: httpx.Response): detail = resp.text try: detail = resp.json() except Exception: pass super().__init__(f"Saxo API {resp.status_code} on {resp.request.method} {resp.request.url}: {detail}") def _raise_for_status(resp: httpx.Response): if resp.is_error: raise SaxoApiError(resp) _account_key_cache: Optional[str] = None 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) _raise_for_status(resp) return resp.json() def get_default_account_key() -> str: """Most /trade/v1 subscriptions (including the options chain) require AccountKey in Arguments — /port/v1/accounts/me is the standard way to get the user's default account.""" global _account_key_cache if _account_key_cache: return _account_key_cache data = _get("/port/v1/accounts/me") items = data.get("Data") or data.get("data") or [] if not items: raise ValueError(f"Aucun compte trouvé dans /port/v1/accounts/me: {data}") account_key = _first(items[0], "AccountKey", "accountKey") if not account_key: raise ValueError(f"Champ AccountKey introuvable pour le compte: {items[0]}") _account_key_cache = account_key return account_key def _first(d: Dict[str, Any], *keys: str) -> Any: for k in keys: if k in d: return d[k] return None def resolve_instrument(symbol: str) -> Dict[str, Any]: """Full matched instrument (Uic + Symbol/Description/AssetType) — surfaced by /validate so a wrong-ticker guess is visibly distinguishable from an account-entitlement error.""" 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())})") item = items[0] uic = _first(item, "Uic", "uic", "Identifier") if uic is None: raise ValueError(f"Champ Uic introuvable dans la réponse instruments pour '{symbol}': {item}") result = { "uic": int(uic), "symbol": _first(item, "Symbol", "symbol"), "description": _first(item, "Description", "description"), "asset_type": _first(item, "AssetType", "assetType"), } _root_uic_cache[symbol] = result return result def resolve_option_root_uic(symbol: str) -> int: return resolve_instrument(symbol)["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": {"Identifier": uic, "AssetType": asset_type, "AccountKey": get_default_account_key()}, "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, ) _raise_for_status(resp) 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} """ instrument = resolve_instrument(symbol) root_uic = instrument["uic"] space = get_option_space(root_uic) legs = _extract_option_legs(space) snapshot = _snapshot_via_subscription(root_uic, asset_type=instrument["asset_type"] or "StockOption") 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