import logging from fastapi import APIRouter, HTTPException from pydantic import BaseModel 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, # instruments.json, options_vol watchlist-tickers). _QUOTE_TYPE_TO_ASSET_CLASS = { "CURRENCY": "forex", "FUTURE": "energy", "INDEX": "indices", "ETF": "etfs", "EQUITY": "equities", "MUTUALFUND": "etfs", } @router.get("/") def list_watchlist(): from services.database import 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: # WARNING, not INFO — the System Logs page only captures WARNING+ (confirmed # 2026-07-23: an earlier INFO-level version of this message never showed up there). logger.warning(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 = None if row.get("saxo_quote_symbol"): q = _saxo_quote(row["saxo_quote_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} @router.post("/{ticker}") def add_ticker(ticker: str): """Adds a tracked instrument. yfinance validation is best-effort, not a gate — an instrument can be entirely Saxo-sourced (both saxo_option_symbol and saxo_quote_symbol linked, see the two PUT .../saxo-*-link endpoints below) with no yfinance equivalent at all, so a ticker yfinance doesn't recognize is still added, just without a name/asset_class lookup or an initial price to report back.""" from services.data_fetcher import get_quote from services.database import get_instruments_watchlist, add_instrument_watchlist import yfinance as yf ticker = ticker.strip().upper() if any(row["ticker"] == ticker for row in get_instruments_watchlist()): raise HTTPException(409, f"'{ticker}' is already in the watchlist") q = get_quote(ticker) name = ticker asset_class = "unknown" if q and q.get("price"): try: info = yf.Ticker(ticker).fast_info name = getattr(info, "long_name", None) or getattr(info, "short_name", None) or ticker quote_type = getattr(info, "quote_type", "") or "" asset_class = _QUOTE_TYPE_TO_ASSET_CLASS.get(quote_type.upper(), "unknown") except Exception: pass add_instrument_watchlist(ticker, name, asset_class) return { "ticker": ticker, "name": name, "asset_class": asset_class, "price": q.get("price") if q else None, "yfinance_recognized": bool(q and q.get("price")), } @router.delete("/{ticker}") def remove_ticker(ticker: str): from services.database import remove_instrument_watchlist remove_instrument_watchlist(ticker.strip().upper()) return {"removed": ticker.strip().upper()} class RenameBody(BaseModel): name: str @router.put("/{ticker}/name") def rename_ticker(ticker: str, body: RenameBody): """Free-form display name — no longer tied to yfinance's long_name lookup, since an instrument can now be entirely Saxo-sourced.""" from services.database import rename_instrument_watchlist if not body.name.strip(): raise HTTPException(400, "Name cannot be empty") ok = rename_instrument_watchlist(ticker, body.name) if not ok: raise HTTPException(404, f"'{ticker}' is not in the instruments watchlist") return {"ticker": ticker.strip().upper(), "name": body.name.strip()} class ReorderBody(BaseModel): tickers: List[str] @router.put("/reorder") def reorder(body: ReorderBody): from services.database import reorder_instruments_watchlist reorder_instruments_watchlist(body.tickers) return {"ok": True} class SaxoLinkBody(BaseModel): saxo_symbol: str | None = None @router.put("/{ticker}/saxo-option-link") def set_saxo_option_link(ticker: str, body: SaxoLinkBody): """Link this tracked instrument to the Saxo symbol whose OPTIONS CHAIN Options Lab should analyze for it (or pass null to unlink) — e.g. CL=F -> MCL:XCME. Adds the symbol to services.saxo_scheduler's watchlist automatically if it wasn't already there, so that chain starts getting snapshotted.""" from services.database import set_instrument_watchlist_saxo_option_symbol ok = set_instrument_watchlist_saxo_option_symbol(ticker, body.saxo_symbol) if not ok: raise HTTPException(404, f"'{ticker}' is not in the instruments watchlist") return {"ticker": ticker.strip().upper(), "saxo_option_symbol": (body.saxo_symbol or "").strip().upper() or None} @router.put("/{ticker}/saxo-quote-link") def set_saxo_quote_link(ticker: str, body: SaxoLinkBody): """Link this tracked instrument to the Saxo symbol used to price it in the Cockpit (or pass null to unlink) — an accurate broker spot/futures feed instead of yfinance's unadjusted continuous-futures tickers. Independent of the options link above; often a different Saxo instrument.""" from services.database import set_instrument_watchlist_saxo_quote_symbol ok = set_instrument_watchlist_saxo_quote_symbol(ticker, body.saxo_symbol) if not ok: raise HTTPException(404, f"'{ticker}' is not in the instruments watchlist") return {"ticker": ticker.strip().upper(), "saxo_quote_symbol": (body.saxo_symbol or "").strip().upper() or None}