From a136d6ae110c304e1f46f06536cfc4c94a9c5737 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Thu, 23 Jul 2026 10:33:18 +0200 Subject: [PATCH] feat: saxo price --- backend/routers/instruments_watchlist.py | 34 ++++++++++----- backend/services/database.py | 55 +++++++++++++++++++----- frontend/src/hooks/useApi.ts | 21 ++++++++- frontend/src/pages/Config.tsx | 36 ++++++++++------ frontend/src/pages/Dashboard.tsx | 12 +++--- 5 files changed, 118 insertions(+), 40 deletions(-) diff --git a/backend/routers/instruments_watchlist.py b/backend/routers/instruments_watchlist.py index f5f3f20..1012520 100644 --- a/backend/routers/instruments_watchlist.py +++ b/backend/routers/instruments_watchlist.py @@ -49,8 +49,8 @@ def watchlist_quotes(): items = [] for row in get_instruments_watchlist(): q = None - if row.get("saxo_symbol"): - q = _saxo_quote(row["saxo_symbol"]) + 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({ @@ -110,13 +110,27 @@ class SaxoLinkBody(BaseModel): saxo_symbol: str | None = None -@router.put("/{ticker}/saxo-link") -def set_saxo_link(ticker: str, body: SaxoLinkBody): - """Link this tracked instrument to a Saxo watchlist symbol (or pass null to unlink) - so Options Lab's Saxo section shows broker data for it. Adds the symbol to - services.saxo_scheduler's watchlist automatically if it wasn't already there.""" - from services.database import set_instrument_watchlist_saxo_symbol - ok = set_instrument_watchlist_saxo_symbol(ticker, body.saxo_symbol) +@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_symbol": (body.saxo_symbol or "").strip().upper() or None} + 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} diff --git a/backend/services/database.py b/backend/services/database.py index 30a5655..5245e55 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -183,10 +183,21 @@ def init_db(): sort_order INTEGER DEFAULT 0, added_at TEXT DEFAULT (datetime('now')) )""", - # Optional link to a Saxo watchlist symbol (services.saxo_scheduler) — lets Options - # Lab's Saxo section show only broker data for instruments the user actually tracks - # here, instead of an independently-managed Saxo symbol list. + # Two DISTINCT, independent Saxo links per tracked instrument — often different + # Saxo instruments, not one shared symbol: + # saxo_option_symbol — which Saxo options chain Options Lab should analyze for + # this underlying (e.g. CL=F -> MCL:XCME, a micro futures option chain). + # Feeds services.saxo_scheduler's watchlist so that chain gets snapshotted. + # saxo_quote_symbol — which Saxo spot/futures instrument to price this + # instrument FROM in the Cockpit, instead of yfinance's unadjusted continuous- + # futures tickers (BZ=F, CL=F... see project_saxo_quote_source memory). + # `saxo_symbol` (original single field, 2026-07-21) is superseded by + # saxo_option_symbol below — kept as a column (not dropped) with its data copied + # forward, so links set before the split aren't lost. "ALTER TABLE instruments_watchlist ADD COLUMN saxo_symbol TEXT", + "ALTER TABLE instruments_watchlist ADD COLUMN saxo_option_symbol TEXT", + "ALTER TABLE instruments_watchlist ADD COLUMN saxo_quote_symbol TEXT", + "UPDATE instruments_watchlist SET saxo_option_symbol = saxo_symbol WHERE saxo_option_symbol IS NULL AND saxo_symbol IS NOT NULL", # Wavelets — saved optimization/simulation runs (ported from InstrumentSimulator's # WaveletOptimizationRun: form/results are free-form JSON blobs, not modeled relationally) """CREATE TABLE IF NOT EXISTS wavelet_simulations ( @@ -3274,19 +3285,22 @@ def remove_market_custom_ticker(ticker: str) -> bool: def get_instruments_watchlist() -> List[Dict]: conn = get_conn() rows = conn.execute( - "SELECT ticker, name, asset_class, sort_order, added_at, saxo_symbol " + "SELECT ticker, name, asset_class, sort_order, added_at, saxo_option_symbol, saxo_quote_symbol " "FROM instruments_watchlist ORDER BY sort_order ASC, added_at ASC" ).fetchall() conn.close() return [dict(r) for r in rows] -def set_instrument_watchlist_saxo_symbol(ticker: str, saxo_symbol: Optional[str]) -> bool: - """Link (or unlink, if saxo_symbol is None/empty) a tracked instrument to a Saxo - watchlist symbol. Linking also adds that symbol to services.saxo_scheduler's own - watchlist if it isn't there yet, so it actually starts getting snapshotted. Unlinking - does NOT remove it from the Saxo watchlist — it may still be wanted directly, or by - another linked instrument; remove it from Config -> Saxo if it's truly no longer needed.""" +def set_instrument_watchlist_saxo_option_symbol(ticker: str, saxo_symbol: Optional[str]) -> bool: + """Link (or unlink, if saxo_symbol is None/empty) a tracked instrument to the Saxo + symbol whose OPTIONS CHAIN Options Lab should analyze for it — e.g. CL=F -> MCL:XCME + (a micro futures option chain), often NOT the same Saxo instrument as + saxo_quote_symbol (the underlying's own spot/futures price feed). Linking also adds + the symbol to services.saxo_scheduler's own watchlist if it isn't there yet, so its + option chain actually starts getting snapshotted. Unlinking does NOT remove it from + the Saxo watchlist — it may still be wanted directly, or by another linked + instrument; remove it from Config -> Saxo if it's truly no longer needed.""" from services.saxo_scheduler import get_watchlist, set_watchlist ticker = ticker.upper() @@ -3297,7 +3311,7 @@ def set_instrument_watchlist_saxo_symbol(ticker: str, saxo_symbol: Optional[str] if row is None: conn.close() return False - conn.execute("UPDATE instruments_watchlist SET saxo_symbol = ? WHERE ticker = ?", (saxo_symbol, ticker)) + conn.execute("UPDATE instruments_watchlist SET saxo_option_symbol = ? WHERE ticker = ?", (saxo_symbol, ticker)) conn.commit() conn.close() @@ -3308,6 +3322,25 @@ def set_instrument_watchlist_saxo_symbol(ticker: str, saxo_symbol: Optional[str] return True +def set_instrument_watchlist_saxo_quote_symbol(ticker: str, saxo_symbol: Optional[str]) -> bool: + """Link (or unlink) a tracked instrument to the Saxo symbol used to price it in the + Cockpit (services.saxo_client.get_saxo_quote_with_volatility), instead of yfinance's + unadjusted continuous-futures tickers. No scheduler sync needed — chart bars are + pulled live from Saxo per request, nothing to pre-accumulate.""" + ticker = ticker.upper() + saxo_symbol = (saxo_symbol or "").strip().upper() or None + + conn = get_conn() + row = conn.execute("SELECT ticker FROM instruments_watchlist WHERE ticker = ?", (ticker,)).fetchone() + if row is None: + conn.close() + return False + conn.execute("UPDATE instruments_watchlist SET saxo_quote_symbol = ? WHERE ticker = ?", (saxo_symbol, ticker)) + conn.commit() + conn.close() + return True + + def add_instrument_watchlist(ticker: str, name: str = "", asset_class: str = "unknown") -> bool: conn = get_conn() try: diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 201b131..a609aa9 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -151,11 +151,14 @@ export const useRemoveWatchlistInstrument = () => { }) } -export const useSetWatchlistSaxoLink = () => { +// Which Saxo OPTIONS CHAIN Options Lab should analyze for this underlying (e.g. +// CL=F -> MCL:XCME) — independent of useSetWatchlistSaxoQuoteLink below, often a +// different Saxo instrument. +export const useSetWatchlistSaxoOptionLink = () => { const qc = useQueryClient() return useMutation({ mutationFn: ({ ticker, saxoSymbol }: { ticker: string; saxoSymbol: string | null }) => - api.put(`/watchlist/${encodeURIComponent(ticker)}/saxo-link`, { saxo_symbol: saxoSymbol }).then(r => r.data), + api.put(`/watchlist/${encodeURIComponent(ticker)}/saxo-option-link`, { saxo_symbol: saxoSymbol }).then(r => r.data), onSuccess: () => { qc.invalidateQueries({ queryKey: ['instruments-watchlist'] }) qc.invalidateQueries({ queryKey: ['instruments-watchlist-quotes'] }) @@ -165,6 +168,20 @@ export const useSetWatchlistSaxoLink = () => { }) } +// Which Saxo spot/futures symbol prices this instrument in the Cockpit, instead of +// yfinance's unadjusted continuous-futures tickers. +export const useSetWatchlistSaxoQuoteLink = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ ticker, saxoSymbol }: { ticker: string; saxoSymbol: string | null }) => + api.put(`/watchlist/${encodeURIComponent(ticker)}/saxo-quote-link`, { saxo_symbol: saxoSymbol }).then(r => r.data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['instruments-watchlist'] }) + qc.invalidateQueries({ queryKey: ['instruments-watchlist-quotes'] }) + }, + }) +} + // Latest cycle report — shares the 'cycle-report-latest' queryKey with RapportIA.tsx export const useLatestCycleReport = () => useQuery({ diff --git a/frontend/src/pages/Config.tsx b/frontend/src/pages/Config.tsx index b0c12ab..eee32e2 100644 --- a/frontend/src/pages/Config.tsx +++ b/frontend/src/pages/Config.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react' import { Link } from 'react-router-dom' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { useSources, useUpdateSources, useUpdateApiKeys, useConfig, useAiStatus, useAnalysisConfig, useSaveAnalysisConfig, useCycleStatus, useUpdateCycleConfig, useTriggerCycle, useCycleStepCatalog, useRiskProfiles, useUpsertProfile, useDeleteProfile, useExitDefaults, useSaveExitDefaults, useOptionsGate, useSaveOptionsGate, useTechIndicatorsConfig, useSaveTechIndicatorsConfig, useInstrumentsWatchlist, useAddWatchlistInstrument, useRemoveWatchlistInstrument, useSetWatchlistSaxoLink, validateTicker, useSaxoStatus, useDisconnectSaxo, useSaxoWatchlist, useUpdateSaxoWatchlist, useSnapshotSaxoNow, useValidateSaxoWatchlist, useSaxoCatalog, useSaxoCatalogSummary, useRefreshSaxoCatalog, useTestSaxoQuote, useSaxoSettings, useUpdateSaxoSettings, useSnapshotAllSaxoNow, useExpandSaxoWatchlist, type CycleStepDef } from '../hooks/useApi' +import { useSources, useUpdateSources, useUpdateApiKeys, useConfig, useAiStatus, useAnalysisConfig, useSaveAnalysisConfig, useCycleStatus, useUpdateCycleConfig, useTriggerCycle, useCycleStepCatalog, useRiskProfiles, useUpsertProfile, useDeleteProfile, useExitDefaults, useSaveExitDefaults, useOptionsGate, useSaveOptionsGate, useTechIndicatorsConfig, useSaveTechIndicatorsConfig, useInstrumentsWatchlist, useAddWatchlistInstrument, useRemoveWatchlistInstrument, useSetWatchlistSaxoOptionLink, useSetWatchlistSaxoQuoteLink, validateTicker, useSaxoStatus, useDisconnectSaxo, useSaxoWatchlist, useUpdateSaxoWatchlist, useSnapshotSaxoNow, useValidateSaxoWatchlist, useSaxoCatalog, useSaxoCatalogSummary, useRefreshSaxoCatalog, useTestSaxoQuote, useSaxoSettings, useUpdateSaxoSettings, useSnapshotAllSaxoNow, useExpandSaxoWatchlist, type CycleStepDef } from '../hooks/useApi' import { Settings, Key, Globe, CheckCircle, XCircle, AlertCircle, Save, Eye, EyeOff, Brain, SlidersHorizontal, RefreshCw, Zap, Plus, Trash2, Pencil, X, Lock, Gauge, DollarSign, TrendingUp, ShieldAlert, DatabaseBackup, Radar, Link2, Unlink, Camera, ShieldCheck, ExternalLink } from 'lucide-react' import clsx from 'clsx' @@ -331,16 +331,27 @@ function RiskProfilesCard() { ) } -// Inline Saxo-symbol link editor for one watchlist row — lets Options Lab's Saxo section -// show only broker data for instruments actually tracked here, instead of relying on an -// independently-managed Saxo watchlist. Linking auto-adds the symbol to the Saxo -// scheduler's watchlist (Config -> Saxo below) so it starts getting snapshotted. -function SaxoLinkPicker({ ticker, saxoSymbol }: { ticker: string; saxoSymbol: string | null }) { +// Inline Saxo-symbol link editor for one watchlist row. Two independent kinds, often +// pointing at DIFFERENT Saxo instruments for the same tracked ticker: +// 'option' — which Saxo options chain Options Lab should analyze (e.g. CL=F -> the +// Saxo micro-futures option chain MCL:XCME). Auto-adds to the Saxo +// scheduler's watchlist (Config -> Saxo below) so it gets snapshotted. +// 'quote' — which Saxo spot/futures symbol prices this instrument in the Cockpit, +// replacing yfinance's unadjusted continuous-futures tickers. +const SAXO_LINK_KIND_META = { + option: { label: 'Option', placeholder: 'Search Saxo option symbol…', activeCls: 'text-emerald-400 hover:text-emerald-300 bg-emerald-900/20 border-emerald-700/30' }, + quote: { label: 'Quote', placeholder: 'Search Saxo spot/futures symbol…', activeCls: 'text-sky-400 hover:text-sky-300 bg-sky-900/20 border-sky-700/30' }, +} as const + +function SaxoLinkPicker({ ticker, kind, saxoSymbol }: { ticker: string; kind: keyof typeof SAXO_LINK_KIND_META; saxoSymbol: string | null }) { const [editing, setEditing] = useState(false) const [value, setValue] = useState(saxoSymbol ?? '') const [showDropdown, setShowDropdown] = useState(false) - const { mutate: setLink, isPending } = useSetWatchlistSaxoLink() + const optionLink = useSetWatchlistSaxoOptionLink() + const quoteLink = useSetWatchlistSaxoQuoteLink() + const { mutate: setLink, isPending } = kind === 'option' ? optionLink : quoteLink const { data: catalogMatches } = useSaxoCatalog(undefined, value.length >= 2 ? value : undefined) + const meta = SAXO_LINK_KIND_META[kind] const save = (sym?: string) => { const resolved = (sym ?? value).trim().toUpperCase() || null @@ -349,12 +360,12 @@ function SaxoLinkPicker({ ticker, saxoSymbol }: { ticker: string; saxoSymbol: st if (!editing) { return saxoSymbol ? ( - ) : ( ) } @@ -368,7 +379,7 @@ function SaxoLinkPicker({ ticker, saxoSymbol }: { ticker: string; saxoSymbol: st onFocus={() => setShowDropdown(true)} onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') setEditing(false) }} onBlur={() => save()} - placeholder="Search Saxo symbol…" + placeholder={meta.placeholder} className="bg-dark-800 border border-slate-700/40 rounded px-1.5 py-0.5 text-[9px] text-white w-28 focus:outline-none focus:border-blue-500/50" /> {isPending && } @@ -461,7 +472,8 @@ function WatchlistCard() { {item.asset_class}
- + + diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index bfd2e0f..7bf09d2 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -971,15 +971,17 @@ export default function Dashboard() { })()} {/* Options Lab — Saxo-only IV highlights, scoped to watchlist instruments that have - a Saxo symbol linked (Config -> Instruments Watchlist). Deliberately not the - yfinance-based IV batch — matches Options Lab itself, which now only shows Saxo. */} + a Saxo OPTION symbol linked (Config -> Instruments Watchlist, "Option" picker — + distinct from the "Quote" picker used for Watchlist Radar's price/vol source). + Deliberately not the yfinance-based IV batch — matches Options Lab itself, + which now only shows Saxo. */} {(() => { const watchlist: any[] = watchlistItems ?? [] - const linked = watchlist.filter((w: any) => w.saxo_symbol) + const linked = watchlist.filter((w: any) => w.saxo_option_symbol) const bySaxoSymbol: Record = {} for (const item of ((saxoIvWatchlistData as any)?.items ?? [])) bySaxoSymbol[item.ticker] = item const highlights = linked - .map((w: any) => bySaxoSymbol[w.saxo_symbol] ? { ...bySaxoSymbol[w.saxo_symbol], watchlistTicker: w.ticker } : null) + .map((w: any) => bySaxoSymbol[w.saxo_option_symbol] ? { ...bySaxoSymbol[w.saxo_option_symbol], watchlistTicker: w.ticker } : null) .filter(Boolean) .sort((a: any, b: any) => Math.abs((b.iv_rank ?? 50) - 50) - Math.abs((a.iv_rank ?? 50) - 50)) @@ -993,7 +995,7 @@ export default function Dashboard() {
{linked.length === 0 ? (
- Link a watchlist instrument to a Saxo symbol (Config → Instruments Watchlist) to see IV highlights here + Link a watchlist instrument to a Saxo option symbol (Config → Instruments Watchlist → "Option") to see IV highlights here
) : highlights.length > 0 ? (