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