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"
# 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: