211 lines
8.8 KiB
Python
211 lines
8.8 KiB
Python
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}
|