diff --git a/backend/services/saxo_client.py b/backend/services/saxo_client.py index bdd25d7..e3b6c66 100644 --- a/backend/services/saxo_client.py +++ b/backend/services/saxo_client.py @@ -24,6 +24,18 @@ logger = logging.getLogger(__name__) _OPTION_ASSET_TYPES = "StockOption,StockIndexOption,FuturesOption,FxVanillaOption" +# Some CME/COMEX underlyings are quoted by the exchange (and passed through as-is by Saxo) +# in a different unit than this app expects everywhere else (spot/strike/bid/ask all in +# dollars). Confirmed 2026-07-29 for COMEX copper (HX:XCME): Saxo's MidStrikePrice/strikes +# come back ~100x too large (630 vs a real ~$6.30/lb) because copper is exchange-quoted in +# cents/lb, not dollars/lb — this was previously caught downstream by portfolio_pricing's +# sanity check against a yfinance reference and silently discarded (falling back to +# yfinance entirely). Correcting the scale here instead, at ingestion, means the Saxo chain +# itself becomes usable and nothing downstream needs to fall back at all. +_PRICE_SCALE_OVERRIDES: Dict[str, float] = { + "HX:XCME": 0.01, +} + # Applied around a Black-Scholes theoretical price when Saxo returns no live Bid/Ask (FX # options in particular go quiet outside FX market hours — closed over the weekend) but # still supplies MidVolatility/Greeks from its own model, so IV/spot/strike are usable. @@ -356,17 +368,28 @@ def resolve_option_root_uic(symbol: str) -> int: return resolve_instrument(symbol)["uic"] -def _open_subscription(uic: int, asset_type: str, headers: Dict[str, str]) -> tuple: - """POST creates a subscription and returns an initial snapshot in the same response. - Caller owns the subscription until _close_subscription is called — this lets - snapshot_options_chain scroll it via PATCH before tearing it down, instead of the - old POST-then-immediately-DELETE pattern (still used as-is by debug_chain_raw).""" +def _open_subscription( + uic: int, asset_type: str, headers: Dict[str, str], + expiries: Optional[List[Dict[str, int]]] = None, max_strikes_per_expiry: Optional[int] = None, +) -> tuple: + """POST creates a subscription and returns an initial snapshot IN THE SAME RESPONSE + (201, confirmed against Saxo's reference docs — unlike PATCH on an existing subscription, + which returns 204 No Content and only pushes the update over the subscription's + streaming/websocket channel, useless to a client like ours that never keeps one open). + Optional Arguments.Expiries/MaxStrikesPerExpiry request a specific window of expiry + indices/strikes directly in this same synchronous call, instead of Saxo's ATM-centered + default. Caller must _close_subscription when done.""" context_id = f"of-{uuid.uuid4().hex[:12]}" reference_id = "chain" + arguments: Dict[str, Any] = {"Identifier": uic, "AssetType": asset_type, "AccountKey": get_default_account_key()} + if expiries is not None: + arguments["Expiries"] = expiries + if max_strikes_per_expiry is not None: + arguments["MaxStrikesPerExpiry"] = max_strikes_per_expiry body = { "ContextId": context_id, "ReferenceId": reference_id, - "Arguments": {"Identifier": uic, "AssetType": asset_type, "AccountKey": get_default_account_key()}, + "Arguments": arguments, "RefreshRate": 5000, } resp = httpx.post( @@ -388,29 +411,8 @@ def _close_subscription(context_id: str, reference_id: str, headers: Dict[str, s logger.warning(f"[Saxo] Failed to clean up options-chain subscription {context_id}: {e}") -def _patch_subscription_window( - context_id: str, reference_id: str, indices: List[int], max_strikes_per_expiry: int, headers: Dict[str, str], -) -> Dict[str, Any]: - """Scrolls an already-open options-chain subscription to a different set of expiry - indices (0 = nearest expiry, 1 = next, ...) — the mechanism Saxo's own option board - uses to page through a chain. https://www.developer.saxo/openapi/learn/options-chain - documents Arguments.Expiries[].Index / MaxStrikesPerExpiry for this, with a hard cap - of (#indices requested * max_strikes_per_expiry) <= 100 per call. Response shape is - lower-confidence (not directly confirmed against a real payload the way the POST - snapshot shape was) — assumed to mirror the same Expiries[]/Strikes[] structure, - positionally indexed the same way, with untouched positions left empty/unchanged.""" - resp = httpx.patch( - f"{SAXO_API_BASE_URL}/trade/v1/optionschain/subscriptions/{context_id}/{reference_id}", - headers={**headers, "Content-Type": "application/json"}, - json={"Expiries": [{"Index": i} for i in indices], "MaxStrikesPerExpiry": max_strikes_per_expiry}, - timeout=15, - ) - _raise_for_status(resp) - return resp.json() - - def _snapshot_via_subscription(uic: int, asset_type: str = "StockOption") -> Dict[str, Any]: - """One-off snapshot: open then immediately close, no scrolling — used by debug_chain_raw.""" + """One-off default-window snapshot: open then immediately close — used by debug_chain_raw.""" headers = _headers() context_id, reference_id, snapshot = _open_subscription(uic, asset_type, headers) _close_subscription(context_id, reference_id, headers) @@ -430,11 +432,13 @@ def debug_chain_raw(symbol: str) -> Dict[str, Any]: # money strikes on the ~3 nearest expiry indices) even though the chain itself extends much # further — the initial snapshot already lists every expiry index up to ExpiryCount with its # date, just with an empty Strikes array beyond that default window. These constants govern -# scrolling the subscription via PATCH to backfill the rest, respecting Saxo's fixed -# (#expiries_requested * MaxStrikesPerExpiry) <= 100 cap per call. +# fetching the rest via dedicated POST+DELETE round trips (each requesting its own +# Expiries/MaxStrikesPerExpiry window, returned synchronously in the POST response — see +# _open_subscription), respecting Saxo's fixed (#expiries_requested * MaxStrikesPerExpiry) +# <= 100 cap per call. _MAX_STRIKES_PER_SAXO_REQUEST = 100 _STRIKES_PER_EXPIRY_WINDOW = 20 -_EXPIRIES_PER_PATCH_BATCH = _MAX_STRIKES_PER_SAXO_REQUEST // _STRIKES_PER_EXPIRY_WINDOW +_EXPIRIES_PER_BATCH = _MAX_STRIKES_PER_SAXO_REQUEST // _STRIKES_PER_EXPIRY_WINDOW def _expiry_days_out(expiry: Optional[str], snapshot_date: str) -> Optional[int]: @@ -456,55 +460,68 @@ def snapshot_options_chain(symbol: str, max_days: int = 120) -> List[Dict[str, A when Saxo quoted it, or otherwise an IV borrowed from a smile built across whatever strikes/expiries in this same snapshot DID carry a live MidVolatility. - Covers every expiry within `max_days` (not just Saxo's default near-dated window) by - opening the subscription, scrolling it via PATCH in batches of _EXPIRIES_PER_PATCH_BATCH - expiry indices, and closing it — see _patch_subscription_window. A batch that fails - (network hiccup, entitlement gap) is logged and skipped rather than aborting the whole - symbol, so a partial chain is still better than none. + Covers every expiry within `max_days` (not just Saxo's default near-dated window) via + dedicated POST+DELETE round trips per batch of expiry indices — see _EXPIRIES_PER_BATCH. + A batch that fails (network hiccup, entitlement gap) is logged and skipped rather than + aborting the whole symbol, so a partial chain is still better than none. """ instrument = resolve_instrument(symbol) root_uic = instrument["uic"] asset_type = instrument["asset_type"] or "StockOption" headers = _headers() + + # Step 1: default window — cheap way to learn every expiry's date (Saxo lists all of + # them, even ones with an empty Strikes array beyond its default near-dated window). context_id, reference_id, opened = _open_subscription(root_uic, asset_type, headers) - try: - # The POST response is the streaming-subscription envelope (ContextId/ReferenceId/ - # Format/RefreshRate/InactivityTimeout/State) — the actual chain payload is nested - # under "Snapshot". - snapshot = opened.get("Snapshot") or opened - snapshot_date = date.today().isoformat() + _close_subscription(context_id, reference_id, headers) + # The POST response is the streaming-subscription envelope (ContextId/ReferenceId/ + # Format/RefreshRate/InactivityTimeout/State) — the actual chain payload is nested + # under "Snapshot". + snapshot = opened.get("Snapshot") or opened + snapshot_date = date.today().isoformat() - expiry_blocks = _first(snapshot, "Expiries", "OptionsChain") or [] - by_index: Dict[int, Dict[str, Any]] = {i: eb for i, eb in enumerate(expiry_blocks)} + expiry_blocks = _first(snapshot, "Expiries", "OptionsChain") or [] + by_index: Dict[int, Dict[str, Any]] = {i: eb for i, eb in enumerate(expiry_blocks)} - wanted_indices = [] - for i, eb in by_index.items(): - dte = _expiry_days_out(eb.get("Expiry"), snapshot_date) - if dte is not None and dte <= max_days: - wanted_indices.append(i) - wanted_indices.sort() + wanted_indices = [] + for i, eb in by_index.items(): + dte = _expiry_days_out(eb.get("Expiry"), snapshot_date) + if dte is not None and dte <= max_days: + wanted_indices.append(i) + wanted_indices.sort() - for batch_start in range(0, len(wanted_indices), _EXPIRIES_PER_PATCH_BATCH): - batch = wanted_indices[batch_start:batch_start + _EXPIRIES_PER_PATCH_BATCH] - try: - patched = _patch_subscription_window(context_id, reference_id, batch, _STRIKES_PER_EXPIRY_WINDOW, headers) - except Exception as e: - logger.warning(f"[Saxo] Options-chain scroll failed for {symbol} expiry indices {batch}: {e}") - continue - patched_snapshot = patched.get("Snapshot") or patched - for i, eb in enumerate(_first(patched_snapshot, "Expiries", "OptionsChain") or []): - if i in batch and (eb.get("Strikes") or []): - by_index[i] = eb + # Step 2: one dedicated subscription per batch, each requesting its own Expiries/ + # MaxStrikesPerExpiry window directly — Saxo returns this synchronously in the POST + # response body (confirmed against the reference docs), unlike PATCHing an existing + # subscription (204 No Content, update only pushed over a streaming/websocket + # connection we never keep open — confirmed the hard way via production logs). + for batch_start in range(0, len(wanted_indices), _EXPIRIES_PER_BATCH): + batch = wanted_indices[batch_start:batch_start + _EXPIRIES_PER_BATCH] + try: + b_context_id, b_reference_id, b_opened = _open_subscription( + root_uic, asset_type, headers, + expiries=[{"Index": i} for i in batch], max_strikes_per_expiry=_STRIKES_PER_EXPIRY_WINDOW, + ) + except Exception as e: + logger.warning(f"[Saxo] Options-chain window fetch failed for {symbol} expiry indices {batch}: {e}") + continue + _close_subscription(b_context_id, b_reference_id, headers) + b_snapshot = b_opened.get("Snapshot") or b_opened + for i, eb in enumerate(_first(b_snapshot, "Expiries", "OptionsChain") or []): + if i in batch and (eb.get("Strikes") or []): + by_index[i] = eb - expiry_blocks = [by_index[i] for i in sorted(by_index)] - finally: - _close_subscription(context_id, reference_id, headers) + expiry_blocks = [by_index[i] for i in sorted(by_index)] # 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) + price_scale = _PRICE_SCALE_OVERRIDES.get(symbol.upper(), 1.0) + if price_scale != 1.0 and spot is not None: + spot = spot * price_scale + # First pass: take exactly what Saxo quoted, no synthesis yet. raw: List[Dict[str, Any]] = [] for expiry_block in expiry_blocks: @@ -515,6 +532,8 @@ def snapshot_options_chain(symbol: str, max_days: int = 120) -> List[Dict[str, A days_to_expiry = None for strike_block in (expiry_block.get("Strikes") or []): strike = strike_block.get("Strike") + if price_scale != 1.0 and strike is not None: + strike = strike * price_scale for side_key in ("Call", "Put"): side = strike_block.get(side_key) if not side: @@ -522,6 +541,22 @@ def snapshot_options_chain(symbol: str, max_days: int = 120) -> List[Dict[str, A greeks = side.get("Greeks") or {} bid, ask = side.get("Bid"), side.get("Ask") mid_vol = greeks.get("MidVolatility") + delta, gamma, theta, vega = greeks.get("Delta"), greeks.get("Gamma"), greeks.get("Theta"), greeks.get("Vega") + if price_scale != 1.0: + # Delta (dV/dS) is a ratio of two quantities scaled the same way, so it's + # invariant. Gamma (d^2V/dS^2) picks up an inverse power of the scale; + # Theta/Vega (dV/dt, dV/d_sigma) scale linearly with V, same as price + # itself. Bid/Ask/Mid are prices, same linear scale. + if bid is not None: + bid = bid * price_scale + if ask is not None: + ask = ask * price_scale + if gamma is not None: + gamma = gamma / price_scale + if theta is not None: + theta = theta * price_scale + if vega is not None: + vega = vega * price_scale raw.append({ "symbol": symbol.upper(), "snapshot_date": snapshot_date, @@ -536,10 +571,10 @@ def snapshot_options_chain(symbol: str, max_days: int = 120) -> List[Dict[str, A # 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"), + "delta": delta, + "gamma": gamma, + "theta": theta, + "vega": vega, }) if not raw: