import logging from fastapi import APIRouter, HTTPException, Query 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} _HISTORY_PERIODS = { "1w": {"yf": "5d", "days": 7}, "1m": {"yf": "1mo", "days": 30}, "3m": {"yf": "3mo", "days": 90}, "6m": {"yf": "6mo", "days": 180}, "1y": {"yf": "1y", "days": 365}, "5y": {"yf": "5y", "days": 1825}, "max": {"yf": "max", "days": 3650}, } @router.get("/history/{ticker}") def watchlist_history(ticker: str, period: str = Query("3m")): """Daily close series for the Watchlist card's chart — Saxo-sourced if this instrument has a saxo_quote_symbol link (see saxo-quote-link below), yfinance otherwise. Same source-of-truth split as /quotes above, just returning a series instead of a single latest point.""" from services.database import get_instruments_watchlist, get_saxo_catalog_by_symbol from services.saxo_client import get_price_history import yfinance as yf ticker = ticker.strip().upper() spec = _HISTORY_PERIODS.get(period.lower(), _HISTORY_PERIODS["3m"]) row = next((r for r in get_instruments_watchlist() if r["ticker"] == ticker), None) saxo_quote_symbol = row.get("saxo_quote_symbol") if row else None if saxo_quote_symbol: try: entry = get_saxo_catalog_by_symbol(saxo_quote_symbol) asset_type = entry["asset_type"] if entry else "FxSpot" bars = get_price_history(saxo_quote_symbol, asset_type, days=spec["days"]) return {"ticker": ticker, "source": "saxo", "bars": [{"date": b["date"], "close": b["close"]} for b in bars]} except Exception as e: logger.info(f"[watchlist/history] Saxo history failed for '{saxo_quote_symbol}', falling back to yfinance: {e}") try: hist = yf.Ticker(ticker).history(period=spec["yf"], interval="1d", auto_adjust=True) hist = hist.dropna(subset=["Close"]) bars = [{"date": idx.strftime("%Y-%m-%d"), "close": round(float(c), 6)} for idx, c in hist["Close"].items()] return {"ticker": ticker, "source": "yfinance", "bars": bars} except Exception as e: return {"ticker": ticker, "source": "none", "bars": [], "error": str(e)} @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}