From ca57f7d8d65424167a7e3aaa384948ec8f5a6cc7 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Sat, 18 Jul 2026 18:44:00 +0200 Subject: [PATCH] feat: saxo history --- backend/routers/saxo.py | 4 ++-- backend/services/saxo_client.py | 40 ++++++++++++++++++++++++++++++--- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/backend/routers/saxo.py b/backend/routers/saxo.py index aedbbf2..6ea576a 100644 --- a/backend/routers/saxo.py +++ b/backend/routers/saxo.py @@ -67,13 +67,13 @@ def symbols_with_history(): @router.post("/snapshot-now/{symbol}") def snapshot_now(symbol: str): - from services.saxo_client import snapshot_options_chain, SaxoNotConnected + from services.saxo_client import snapshot_options_chain, SaxoNotConnected, SaxoApiError from services.database import save_saxo_snapshot_rows try: rows = snapshot_options_chain(symbol) except SaxoNotConnected as e: raise HTTPException(status_code=401, detail=str(e)) - except ValueError as e: + except (ValueError, SaxoApiError) as e: raise HTTPException(status_code=502, detail=str(e)) save_saxo_snapshot_rows(rows) return {"symbol": symbol.upper(), "rows_saved": len(rows)} diff --git a/backend/services/saxo_client.py b/backend/services/saxo_client.py index 01315dd..09d1edb 100644 --- a/backend/services/saxo_client.py +++ b/backend/services/saxo_client.py @@ -34,6 +34,26 @@ 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: @@ -43,10 +63,24 @@ def _headers() -> Dict[str, str]: 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) - resp.raise_for_status() + _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") + account_key = _first(data, "AccountKey", "accountKey") + if not account_key: + raise ValueError(f"Champ AccountKey introuvable dans /port/v1/accounts/me: {data}") + _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: @@ -113,7 +147,7 @@ def _snapshot_via_subscription(uic: int, asset_type: str = "StockOption") -> Dic body = { "ContextId": context_id, "ReferenceId": reference_id, - "Arguments": {"Uic": uic, "AssetType": asset_type}, + "Arguments": {"Uic": uic, "AssetType": asset_type, "AccountKey": get_default_account_key()}, "RefreshRate": 5000, } resp = httpx.post( @@ -121,7 +155,7 @@ def _snapshot_via_subscription(uic: int, asset_type: str = "StockOption") -> Dic headers={**token_headers, "Content-Type": "application/json"}, json=body, timeout=15, ) - resp.raise_for_status() + _raise_for_status(resp) snapshot = resp.json() try: