Files
OpenFin/backend/services/saxo_client.py
2026-07-18 19:20:50 +02:00

234 lines
9.2 KiB
Python

"""
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.
"""
import logging
import time
import uuid
from datetime import date
from typing import Any, Dict, List, Optional
import httpx
from services.saxo_auth import SAXO_API_BASE_URL, get_valid_access_token
logger = logging.getLogger(__name__)
_OPTION_ASSET_TYPES = "StockOption,StockIndexOption,FuturesOption"
# symbol -> resolved instrument details, cheap in-process cache (roots don't change within a session)
_root_uic_cache: Dict[str, Dict[str, Any]] = {}
class SaxoNotConnected(Exception):
pass
class SaxoApiError(Exception):
"""Wraps an httpx.HTTPStatusError with Saxo's actual JSON error body (ErrorCode/Message),
since the bare status code is useless for debugging a 400 on a subscription body."""
def __init__(self, resp: httpx.Response):
detail = resp.text
try:
detail = resp.json()
except Exception:
pass
super().__init__(f"Saxo API {resp.status_code} on {resp.request.method} {resp.request.url}: {detail}")
def _raise_for_status(resp: httpx.Response):
if resp.is_error:
raise SaxoApiError(resp)
_account_key_cache: Optional[str] = None
def _headers() -> Dict[str, str]:
token = get_valid_access_token()
if not token:
raise SaxoNotConnected("Saxo n'est pas connecté — complétez l'OAuth login d'abord.")
return {"Authorization": f"Bearer {token}"}
def _get(path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
resp = httpx.get(f"{SAXO_API_BASE_URL}{path}", headers=_headers(), params=params or {}, timeout=15)
_raise_for_status(resp)
return resp.json()
def get_default_account_key() -> str:
"""Most /trade/v1 subscriptions (including the options chain) require AccountKey in
Arguments — /port/v1/accounts/me is the standard way to get the user's default account."""
global _account_key_cache
if _account_key_cache:
return _account_key_cache
data = _get("/port/v1/accounts/me")
items = data.get("Data") or data.get("data") or []
if not items:
raise ValueError(f"Aucun compte trouvé dans /port/v1/accounts/me: {data}")
account_key = _first(items[0], "AccountKey", "accountKey")
if not account_key:
raise ValueError(f"Champ AccountKey introuvable pour le compte: {items[0]}")
_account_key_cache = account_key
return account_key
def _first(d: Dict[str, Any], *keys: str) -> Any:
for k in keys:
if k in d:
return d[k]
return None
def resolve_instrument(symbol: str) -> Dict[str, Any]:
"""Full matched instrument (Uic + Symbol/Description/AssetType) — surfaced by /validate so
a wrong-ticker guess is visibly distinguishable from an account-entitlement error."""
if symbol in _root_uic_cache:
return _root_uic_cache[symbol]
data = _get("/ref/v1/instruments", {"Keywords": symbol, "AssetTypes": _OPTION_ASSET_TYPES})
items = data.get("Data") or data.get("data") or []
if not items:
raise ValueError(f"Aucun option root Saxo trouvé pour '{symbol}' (réponse: {list(data.keys())})")
item = items[0]
uic = _first(item, "Uic", "uic", "Identifier")
if uic is None:
raise ValueError(f"Champ Uic introuvable dans la réponse instruments pour '{symbol}': {item}")
result = {
"uic": int(uic),
"symbol": _first(item, "Symbol", "symbol"),
"description": _first(item, "Description", "description"),
"asset_type": _first(item, "AssetType", "assetType"),
}
_root_uic_cache[symbol] = result
return result
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
def _snapshot_via_subscription(uic: int, asset_type: str = "StockOption") -> Dict[str, Any]:
"""POST creates a subscription and returns an initial snapshot in the same response;
DELETE immediately after — no websocket needs to be kept alive for a one-off read."""
context_id = f"of-{uuid.uuid4().hex[:12]}"
reference_id = "chain"
token_headers = _headers()
body = {
"ContextId": context_id,
"ReferenceId": reference_id,
"Arguments": {"Identifier": uic, "AssetType": asset_type, "AccountKey": get_default_account_key()},
"RefreshRate": 5000,
}
resp = httpx.post(
f"{SAXO_API_BASE_URL}/trade/v1/optionschain/subscriptions",
headers={**token_headers, "Content-Type": "application/json"},
json=body, timeout=15,
)
_raise_for_status(resp)
snapshot = resp.json()
try:
httpx.delete(
f"{SAXO_API_BASE_URL}/trade/v1/optionschain/subscriptions/{context_id}/{reference_id}",
headers=token_headers, timeout=10,
)
except Exception as e:
logger.warning(f"[Saxo] Failed to clean up options-chain subscription {context_id}: {e}")
return snapshot
def snapshot_options_chain(symbol: str, target_days: int = 30) -> List[Dict[str, Any]]:
"""
Returns normalized rows ready for services/database.save_saxo_snapshot_rows:
{symbol, snapshot_date, spot, expiry_date, strike, option_type, bid, ask, mid,
volatility_pct, delta, gamma, theta, vega}
"""
instrument = resolve_instrument(symbol)
root_uic = instrument["uic"]
space = get_option_space(root_uic)
legs = _extract_option_legs(space)
snapshot = _snapshot_via_subscription(root_uic, asset_type=instrument["asset_type"] or "StockOption")
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]] = []
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"):
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
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"),
})
if not rows:
raise ValueError(f"Snapshot Saxo vide pour '{symbol}' (clés reçues: {list(snapshot.keys())})")
return rows