From 1ac227027104cb5b8a981b2584325c730a8f5bb8 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Thu, 23 Jul 2026 10:07:51 +0200 Subject: [PATCH] feat: saxo price --- backend/routers/instruments_watchlist.py | 28 +++++++- backend/services/saxo_client.py | 85 ++++++++++++++++++++++- frontend/src/pages/Dashboard.tsx | 5 +- matrice_regimes_corrigee.xlsx | Bin 0 -> 8809 bytes 4 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 matrice_regimes_corrigee.xlsx diff --git a/backend/routers/instruments_watchlist.py b/backend/routers/instruments_watchlist.py index 8647e1a..f5f3f20 100644 --- a/backend/routers/instruments_watchlist.py +++ b/backend/routers/instruments_watchlist.py @@ -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} diff --git a/backend/services/saxo_client.py b/backend/services/saxo_client.py index daca826..8d6d7e4 100644 --- a/backend/services/saxo_client.py +++ b/backend/services/saxo_client.py @@ -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"] diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 9191fc5..bfd2e0f 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -438,7 +438,10 @@ export default function Dashboard() {
{((watchlistQuotesData as any)?.items ?? []).map((it: any) => (
- {it.ticker} + + {it.ticker} + {it.quote_source === 'saxo' && } +
{fmtPrice(it.price)} = 0 ? 'text-emerald-400' : 'text-red-400')}> diff --git a/matrice_regimes_corrigee.xlsx b/matrice_regimes_corrigee.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..4959176bfda995797f6852dbfa8c389affde5db5 GIT binary patch literal 8809 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 PxVK&4EsA52{=WM!Z+Hd+ literal 0 HcmV?d00001