feat: saxo history
This commit is contained in:
@@ -67,13 +67,13 @@ def symbols_with_history():
|
|||||||
|
|
||||||
@router.post("/snapshot-now/{symbol}")
|
@router.post("/snapshot-now/{symbol}")
|
||||||
def snapshot_now(symbol: str):
|
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
|
from services.database import save_saxo_snapshot_rows
|
||||||
try:
|
try:
|
||||||
rows = snapshot_options_chain(symbol)
|
rows = snapshot_options_chain(symbol)
|
||||||
except SaxoNotConnected as e:
|
except SaxoNotConnected as e:
|
||||||
raise HTTPException(status_code=401, detail=str(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))
|
raise HTTPException(status_code=502, detail=str(e))
|
||||||
save_saxo_snapshot_rows(rows)
|
save_saxo_snapshot_rows(rows)
|
||||||
return {"symbol": symbol.upper(), "rows_saved": len(rows)}
|
return {"symbol": symbol.upper(), "rows_saved": len(rows)}
|
||||||
|
|||||||
@@ -34,6 +34,26 @@ class SaxoNotConnected(Exception):
|
|||||||
pass
|
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]:
|
def _headers() -> Dict[str, str]:
|
||||||
token = get_valid_access_token()
|
token = get_valid_access_token()
|
||||||
if not 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]:
|
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 = 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()
|
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:
|
def _first(d: Dict[str, Any], *keys: str) -> Any:
|
||||||
for k in keys:
|
for k in keys:
|
||||||
if k in d:
|
if k in d:
|
||||||
@@ -113,7 +147,7 @@ def _snapshot_via_subscription(uic: int, asset_type: str = "StockOption") -> Dic
|
|||||||
body = {
|
body = {
|
||||||
"ContextId": context_id,
|
"ContextId": context_id,
|
||||||
"ReferenceId": reference_id,
|
"ReferenceId": reference_id,
|
||||||
"Arguments": {"Uic": uic, "AssetType": asset_type},
|
"Arguments": {"Uic": uic, "AssetType": asset_type, "AccountKey": get_default_account_key()},
|
||||||
"RefreshRate": 5000,
|
"RefreshRate": 5000,
|
||||||
}
|
}
|
||||||
resp = httpx.post(
|
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"},
|
headers={**token_headers, "Content-Type": "application/json"},
|
||||||
json=body, timeout=15,
|
json=body, timeout=15,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
_raise_for_status(resp)
|
||||||
snapshot = resp.json()
|
snapshot = resp.json()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user