feat: saxo

This commit is contained in:
OpenSquared
2026-07-18 22:58:09 +02:00
parent 0d3a6a6fad
commit c3a93bf658

View File

@@ -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: