feat: saxo price
This commit is contained in:
@@ -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}
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
BIN
matrice_regimes_corrigee.xlsx
Normal file
BIN
matrice_regimes_corrigee.xlsx
Normal file
Binary file not shown.
Reference in New Issue
Block a user