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 fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from typing import List from typing import List, Optional
router = APIRouter(prefix="/api/watchlist", tags=["watchlist"]) router = APIRouter(prefix="/api/watchlist", tags=["watchlist"])
logger = logging.getLogger(__name__)
# Intentionally duplicated from market_data.py to keep this system fully decoupled # Intentionally duplicated from market_data.py to keep this system fully decoupled
# from the other instrument-list mechanisms (market_watchlist, INSTRUMENT_MODELS, # from the other instrument-list mechanisms (market_watchlist, INSTRUMENT_MODELS,
@@ -23,19 +26,40 @@ def list_watchlist():
return get_instruments_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") @router.get("/quotes")
def watchlist_quotes(): def watchlist_quotes():
from services.database import get_instruments_watchlist from services.database import get_instruments_watchlist
from services.data_fetcher import get_quote_with_volatility from services.data_fetcher import get_quote_with_volatility
items = [] items = []
for row in get_instruments_watchlist(): 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({ items.append({
**row, **row,
"price": q.get("price"), "price": q.get("price"),
"change_pct": q.get("change_pct"), "change_pct": q.get("change_pct"),
"volatility_pct": q.get("volatility_pct"), "volatility_pct": q.get("volatility_pct"),
"volatility_change_pct": q.get("volatility_change_pct"), "volatility_change_pct": q.get("volatility_change_pct"),
"quote_source": q.get("source", "yfinance"),
}) })
return {"items": items} 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 logging
import time import time
import uuid import uuid
from datetime import date from datetime import date, datetime
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import httpx 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: def resolve_option_root_uic(symbol: str) -> int:
return resolve_instrument(symbol)["uic"] 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"> <div className="mt-1.5 pt-1.5 border-t border-slate-700/30 space-y-1">
{((watchlistQuotesData as any)?.items ?? []).map((it: any) => ( {((watchlistQuotesData as any)?.items ?? []).map((it: any) => (
<div key={it.ticker} className="flex items-center justify-between gap-1.5 text-[10px] whitespace-nowrap"> <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"> <div className="flex items-center gap-1.5 shrink-0">
<span className="text-slate-400 font-mono">{fmtPrice(it.price)}</span> <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')}> <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.