feat: saxo price

This commit is contained in:
OpenSquared
2026-07-29 18:50:32 +02:00
parent e514e9799b
commit 7e03c9c301

View File

@@ -24,6 +24,18 @@ logger = logging.getLogger(__name__)
_OPTION_ASSET_TYPES = "StockOption,StockIndexOption,FuturesOption,FxVanillaOption" _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 # 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 # 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. # 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"] return resolve_instrument(symbol)["uic"]
def _open_subscription(uic: int, asset_type: str, headers: Dict[str, str]) -> tuple: def _open_subscription(
"""POST creates a subscription and returns an initial snapshot in the same response. uic: int, asset_type: str, headers: Dict[str, str],
Caller owns the subscription until _close_subscription is called — this lets expiries: Optional[List[Dict[str, int]]] = None, max_strikes_per_expiry: Optional[int] = None,
snapshot_options_chain scroll it via PATCH before tearing it down, instead of the ) -> tuple:
old POST-then-immediately-DELETE pattern (still used as-is by debug_chain_raw).""" """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]}" context_id = f"of-{uuid.uuid4().hex[:12]}"
reference_id = "chain" 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 = { body = {
"ContextId": context_id, "ContextId": context_id,
"ReferenceId": reference_id, "ReferenceId": reference_id,
"Arguments": {"Identifier": uic, "AssetType": asset_type, "AccountKey": get_default_account_key()}, "Arguments": arguments,
"RefreshRate": 5000, "RefreshRate": 5000,
} }
resp = httpx.post( 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}") 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]: 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() headers = _headers()
context_id, reference_id, snapshot = _open_subscription(uic, asset_type, headers) context_id, reference_id, snapshot = _open_subscription(uic, asset_type, headers)
_close_subscription(context_id, reference_id, 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 # 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 # 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 # 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 # fetching the rest via dedicated POST+DELETE round trips (each requesting its own
# (#expiries_requested * MaxStrikesPerExpiry) <= 100 cap per call. # 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 _MAX_STRIKES_PER_SAXO_REQUEST = 100
_STRIKES_PER_EXPIRY_WINDOW = 20 _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]: 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 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. 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 Covers every expiry within `max_days` (not just Saxo's default near-dated window) via
opening the subscription, scrolling it via PATCH in batches of _EXPIRIES_PER_PATCH_BATCH dedicated POST+DELETE round trips per batch of expiry indices — see _EXPIRIES_PER_BATCH.
expiry indices, and closing it — see _patch_subscription_window. A batch that fails A batch that fails (network hiccup, entitlement gap) is logged and skipped rather than
(network hiccup, entitlement gap) is logged and skipped rather than aborting the whole aborting the whole symbol, so a partial chain is still better than none.
symbol, so a partial chain is still better than none.
""" """
instrument = resolve_instrument(symbol) instrument = resolve_instrument(symbol)
root_uic = instrument["uic"] root_uic = instrument["uic"]
asset_type = instrument["asset_type"] or "StockOption" asset_type = instrument["asset_type"] or "StockOption"
headers = _headers() 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) context_id, reference_id, opened = _open_subscription(root_uic, asset_type, headers)
try: _close_subscription(context_id, reference_id, headers)
# The POST response is the streaming-subscription envelope (ContextId/ReferenceId/ # The POST response is the streaming-subscription envelope (ContextId/ReferenceId/
# Format/RefreshRate/InactivityTimeout/State) — the actual chain payload is nested # Format/RefreshRate/InactivityTimeout/State) — the actual chain payload is nested
# under "Snapshot". # under "Snapshot".
snapshot = opened.get("Snapshot") or opened snapshot = opened.get("Snapshot") or opened
snapshot_date = date.today().isoformat() snapshot_date = date.today().isoformat()
expiry_blocks = _first(snapshot, "Expiries", "OptionsChain") or [] expiry_blocks = _first(snapshot, "Expiries", "OptionsChain") or []
by_index: Dict[int, Dict[str, Any]] = {i: eb for i, eb in enumerate(expiry_blocks)} by_index: Dict[int, Dict[str, Any]] = {i: eb for i, eb in enumerate(expiry_blocks)}
wanted_indices = [] wanted_indices = []
for i, eb in by_index.items(): for i, eb in by_index.items():
dte = _expiry_days_out(eb.get("Expiry"), snapshot_date) dte = _expiry_days_out(eb.get("Expiry"), snapshot_date)
if dte is not None and dte <= max_days: if dte is not None and dte <= max_days:
wanted_indices.append(i) wanted_indices.append(i)
wanted_indices.sort() wanted_indices.sort()
for batch_start in range(0, len(wanted_indices), _EXPIRIES_PER_PATCH_BATCH): # Step 2: one dedicated subscription per batch, each requesting its own Expiries/
batch = wanted_indices[batch_start:batch_start + _EXPIRIES_PER_PATCH_BATCH] # MaxStrikesPerExpiry window directly — Saxo returns this synchronously in the POST
try: # response body (confirmed against the reference docs), unlike PATCHing an existing
patched = _patch_subscription_window(context_id, reference_id, batch, _STRIKES_PER_EXPIRY_WINDOW, headers) # subscription (204 No Content, update only pushed over a streaming/websocket
except Exception as e: # connection we never keep open — confirmed the hard way via production logs).
logger.warning(f"[Saxo] Options-chain scroll failed for {symbol} expiry indices {batch}: {e}") for batch_start in range(0, len(wanted_indices), _EXPIRIES_PER_BATCH):
continue batch = wanted_indices[batch_start:batch_start + _EXPIRIES_PER_BATCH]
patched_snapshot = patched.get("Snapshot") or patched try:
for i, eb in enumerate(_first(patched_snapshot, "Expiries", "OptionsChain") or []): b_context_id, b_reference_id, b_opened = _open_subscription(
if i in batch and (eb.get("Strikes") or []): root_uic, asset_type, headers,
by_index[i] = eb 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)] expiry_blocks = [by_index[i] for i in sorted(by_index)]
finally:
_close_subscription(context_id, reference_id, headers)
# No spot/underlying price field exists anywhere in this response (confirmed against a # 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. # 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) 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. # First pass: take exactly what Saxo quoted, no synthesis yet.
raw: List[Dict[str, Any]] = [] raw: List[Dict[str, Any]] = []
for expiry_block in expiry_blocks: 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 days_to_expiry = None
for strike_block in (expiry_block.get("Strikes") or []): for strike_block in (expiry_block.get("Strikes") or []):
strike = strike_block.get("Strike") 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"): for side_key in ("Call", "Put"):
side = strike_block.get(side_key) side = strike_block.get(side_key)
if not side: 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 {} greeks = side.get("Greeks") or {}
bid, ask = side.get("Bid"), side.get("Ask") bid, ask = side.get("Bid"), side.get("Ask")
mid_vol = greeks.get("MidVolatility") 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({ raw.append({
"symbol": symbol.upper(), "symbol": symbol.upper(),
"snapshot_date": snapshot_date, "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 # MidVolatility comes back as a decimal fraction (0.05 = 5%) — store as an
# actual percentage to match the volatility_pct column's name/convention. # 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, "volatility_pct": round(mid_vol * 100, 4) if mid_vol is not None else None,
"delta": greeks.get("Delta"), "delta": delta,
"gamma": greeks.get("Gamma"), "gamma": gamma,
"theta": greeks.get("Theta"), "theta": theta,
"vega": greeks.get("Vega"), "vega": vega,
}) })
if not raw: if not raw: