feat: saxo price

This commit is contained in:
OpenSquared
2026-07-23 10:07:51 +02:00
parent 502ef6bf17
commit 1ac2270271
4 changed files with 114 additions and 4 deletions

View File

@@ -11,7 +11,7 @@ quoting window carry a Call/Put payload; the rest are bare {Index, Strike}.
import logging
import time
import uuid
from datetime import date
from datetime import date, datetime
from typing import Any, Dict, List, Optional
import httpx
@@ -224,6 +224,89 @@ def get_price_quote(symbol: str, asset_type: str = "FxSpot", amount: int = 10000
}
def get_price_history(symbol: str, asset_type: str = "FxSpot", days: int = 90) -> List[Dict[str, Any]]:
"""Daily OHLC bars via Saxo's Chart API (GET /chart/v1/charts, Horizon=1440 = daily
bars). NOT yet verified against a live account (unlike get_price_quote/
snapshot_options_chain, which were built against confirmed real responses) — this
follows Saxo's documented chart endpoint shape but needs a live check once deployed.
Callers should treat any failure here (wrong field names, entitlement gap, etc.) as
routine and fall back to another source, not surface it as a hard error.
Returns oldest-first [{"date": "YYYY-MM-DD", "close": float}, ...].
"""
instrument = resolve_instrument(symbol, asset_types=asset_type)
data = _get("/chart/v1/charts", {
"AssetType": asset_type,
"Uic": instrument["uic"],
"Horizon": 1440,
"Count": days,
})
bars = data.get("Data") or []
if not bars:
raise ValueError(f"No chart data returned for '{symbol}' ({asset_type})")
out = []
for bar in bars:
close = bar.get("Close")
time_str = bar.get("Time")
if close is None or not time_str:
continue
out.append({"date": str(time_str)[:10], "close": float(close)})
if not out:
raise ValueError(f"Chart data for '{symbol}' had no usable Close/Time fields")
return out
def get_saxo_quote_with_volatility(symbol: str, asset_type: str, vol_window: int = 20) -> Dict[str, Any]:
"""Saxo-sourced equivalent of services.data_fetcher.get_quote_with_volatility — same
output shape, but priced from Saxo's own chart history instead of yfinance. Exists
because continuous front-month futures tickers (BZ=F, CL=F, GC=F...) on yfinance
aren't roll-adjusted, so day-over-day change/volatility can show a spurious jump on
a contract-roll day that has nothing to do with an actual market move. Saxo's own
CFD/spot instruments don't have that artifact. Raises on any failure — the caller
(routers/instruments_watchlist.py) falls back to yfinance rather than surfacing this."""
import numpy as np
bars = get_price_history(symbol, asset_type, days=max(vol_window + 10, 90))
if len(bars) < vol_window + 2:
raise ValueError(f"Not enough Saxo history for '{symbol}' to compute {vol_window}d volatility")
dates = [b["date"] for b in bars]
closes = np.array([b["close"] for b in bars], dtype=float)
price = float(closes[-1])
last_date = dates[-1]
prior_idx = [i for i, d in enumerate(dates) if d < last_date]
prev = float(closes[prior_idx[-1]]) if prior_idx else price
change = price - prev
change_pct = (change / prev * 100) if prev else 0
log_ret = np.diff(np.log(closes))
if len(log_ret) < vol_window:
raise ValueError(f"Not enough Saxo return points for '{symbol}' to compute {vol_window}d volatility")
vol_series = []
for i in range(vol_window, len(log_ret) + 1):
window = log_ret[i - vol_window:i]
vol_series.append((dates[i], float(np.std(window, ddof=1)) * np.sqrt(252) * 100))
if not vol_series:
raise ValueError(f"Volatility series empty for '{symbol}'")
volatility_pct = vol_series[-1][1]
volatility_change_pct = None
prior_vol = [v for d, v in vol_series if d < last_date]
if prior_vol:
volatility_change_pct = round(volatility_pct - prior_vol[-1], 2)
return {
"symbol": symbol,
"price": round(price, 4),
"change": round(change, 4),
"change_pct": round(change_pct, 2),
"volatility_pct": round(volatility_pct, 2),
"volatility_change_pct": volatility_change_pct,
"timestamp": datetime.utcnow().isoformat(),
"source": "saxo",
}
def resolve_option_root_uic(symbol: str) -> int:
return resolve_instrument(symbol)["uic"]