diff --git a/backend/services/saxo_client.py b/backend/services/saxo_client.py index 516eb93..0f24543 100644 --- a/backend/services/saxo_client.py +++ b/backend/services/saxo_client.py @@ -1,16 +1,12 @@ """ 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. +The options-chain subscription response shape (confirmed against a real payload): +Snapshot.Expiries[].Strikes[].{Call,Put}, each with Bid/Ask/Uic directly and +Delta/Gamma/Theta/Vega/MidVolatility nested under .Greeks. There is no Mid field +(computed as (bid+ask)/2) and no underlying spot field at all — MidStrikePrice on +the nearest expiry is used as the spot proxy. Only strikes within Saxo's active +quoting window carry a Call/Put payload; the rest are bare {Index, Strike}. """ import logging import time @@ -178,33 +174,6 @@ 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 @@ -259,42 +228,47 @@ def snapshot_options_chain(symbol: str, target_days: int = 30) -> List[Dict[str, """ instrument = resolve_instrument(symbol) root_uic = instrument["uic"] - space = get_option_space(root_uic) - legs = _extract_option_legs(space) subscription = _snapshot_via_subscription(root_uic, asset_type=instrument["asset_type"] or "StockOption") # The POST response is the streaming-subscription envelope (ContextId/ReferenceId/Format/ # RefreshRate/InactivityTimeout/State) — the actual chain payload is nested under "Snapshot". snapshot = subscription.get("Snapshot") or subscription - 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]] = [] + expiry_blocks = _first(snapshot, "Expiries", "OptionsChain") or [] + # No spot/underlying price field exists anywhere in this response (confirmed against a + # real payload) — MidStrikePrice on the nearest expiry is the best available proxy. + spot = next((eb.get("MidStrikePrice") for eb in expiry_blocks if eb.get("MidStrikePrice") is not None), None) - 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"): + rows: List[Dict[str, Any]] = [] + for expiry_block in expiry_blocks: + expiry_date = (expiry_block.get("Expiry") or "")[:10] or None + for strike_block in (strike_block for strike_block in (expiry_block.get("Strikes") or [])): + strike = strike_block.get("Strike") + for side_key in ("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 + greeks = side.get("Greeks") or {} + bid, ask = side.get("Bid"), side.get("Ask") + mid_vol = greeks.get("MidVolatility") 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"), + "expiry_date": expiry_date, + "strike": float(strike) if strike is not None else None, + "option_type": "put" if side_key == "Put" else "call", + "bid": bid, + "ask": ask, + "mid": round((bid + ask) / 2, 6) if (bid is not None and ask is not None) else None, + # MidVolatility comes back as a decimal fraction (0.05 = 5%) — store as an + # actual percentage to match the volatility_pct column's name/convention. + "volatility_pct": round(mid_vol * 100, 4) if mid_vol is not None else None, + "delta": greeks.get("Delta"), + "gamma": greeks.get("Gamma"), + "theta": greeks.get("Theta"), + "vega": greeks.get("Vega"), }) if not rows: