feat: saxo
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user