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

@@ -1,8 +1,11 @@
import logging
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import List
from typing import List, Optional
router = APIRouter(prefix="/api/watchlist", tags=["watchlist"])
logger = logging.getLogger(__name__)
# Intentionally duplicated from market_data.py to keep this system fully decoupled
# from the other instrument-list mechanisms (market_watchlist, INSTRUMENT_MODELS,
@@ -23,19 +26,40 @@ def list_watchlist():
return get_instruments_watchlist()
def _saxo_quote(saxo_symbol: str) -> Optional[dict]:
"""Try Saxo's own chart history for price/change/volatility — avoids the unadjusted-
roll artifact continuous futures tickers (BZ=F, CL=F, GC=F...) have on yfinance.
Untested against a live account; any failure here is routine, not an error — the
caller falls back to yfinance."""
from services.database import get_saxo_catalog_by_symbol
from services.saxo_client import get_saxo_quote_with_volatility
entry = get_saxo_catalog_by_symbol(saxo_symbol)
asset_type = entry["asset_type"] if entry else "FxSpot"
try:
return get_saxo_quote_with_volatility(saxo_symbol, asset_type)
except Exception as e:
logger.info(f"[watchlist/quotes] Saxo quote failed for '{saxo_symbol}' ({asset_type}), falling back to yfinance: {e}")
return None
@router.get("/quotes")
def watchlist_quotes():
from services.database import get_instruments_watchlist
from services.data_fetcher import get_quote_with_volatility
items = []
for row in get_instruments_watchlist():
q = get_quote_with_volatility(row["ticker"]) or {}
q = None
if row.get("saxo_symbol"):
q = _saxo_quote(row["saxo_symbol"])
if q is None:
q = get_quote_with_volatility(row["ticker"]) or {}
items.append({
**row,
"price": q.get("price"),
"change_pct": q.get("change_pct"),
"volatility_pct": q.get("volatility_pct"),
"volatility_change_pct": q.get("volatility_change_pct"),
"quote_source": q.get("source", "yfinance"),
})
return {"items": items}

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"]

View File

@@ -438,7 +438,10 @@ export default function Dashboard() {
<div className="mt-1.5 pt-1.5 border-t border-slate-700/30 space-y-1">
{((watchlistQuotesData as any)?.items ?? []).map((it: any) => (
<div key={it.ticker} className="flex items-center justify-between gap-1.5 text-[10px] whitespace-nowrap">
<span className="text-slate-300 font-mono shrink-0">{it.ticker}</span>
<span className="text-slate-300 font-mono shrink-0 flex items-center gap-1">
{it.ticker}
{it.quote_source === 'saxo' && <span className="text-emerald-500" title="Priced from Saxo, not yfinance"></span>}
</span>
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-slate-400 font-mono">{fmtPrice(it.price)}</span>
<span className={clsx('font-mono font-bold w-11 text-right shrink-0', (it.change_pct ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400')}>

Binary file not shown.