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 0000000..4959176 Binary files /dev/null and b/matrice_regimes_corrigee.xlsx differ