feat: saxo price

This commit is contained in:
OpenSquared
2026-07-23 10:33:18 +02:00
parent 1ac2270271
commit a136d6ae11
5 changed files with 118 additions and 40 deletions

View File

@@ -49,8 +49,8 @@ def watchlist_quotes():
items = [] items = []
for row in get_instruments_watchlist(): for row in get_instruments_watchlist():
q = None q = None
if row.get("saxo_symbol"): if row.get("saxo_quote_symbol"):
q = _saxo_quote(row["saxo_symbol"]) q = _saxo_quote(row["saxo_quote_symbol"])
if q is None: if q is None:
q = get_quote_with_volatility(row["ticker"]) or {} q = get_quote_with_volatility(row["ticker"]) or {}
items.append({ items.append({
@@ -110,13 +110,27 @@ class SaxoLinkBody(BaseModel):
saxo_symbol: str | None = None saxo_symbol: str | None = None
@router.put("/{ticker}/saxo-link") @router.put("/{ticker}/saxo-option-link")
def set_saxo_link(ticker: str, body: SaxoLinkBody): def set_saxo_option_link(ticker: str, body: SaxoLinkBody):
"""Link this tracked instrument to a Saxo watchlist symbol (or pass null to unlink) """Link this tracked instrument to the Saxo symbol whose OPTIONS CHAIN Options Lab
so Options Lab's Saxo section shows broker data for it. Adds the symbol to should analyze for it (or pass null to unlink) — e.g. CL=F -> MCL:XCME. Adds the
services.saxo_scheduler's watchlist automatically if it wasn't already there.""" symbol to services.saxo_scheduler's watchlist automatically if it wasn't already
from services.database import set_instrument_watchlist_saxo_symbol there, so that chain starts getting snapshotted."""
ok = set_instrument_watchlist_saxo_symbol(ticker, body.saxo_symbol) from services.database import set_instrument_watchlist_saxo_option_symbol
ok = set_instrument_watchlist_saxo_option_symbol(ticker, body.saxo_symbol)
if not ok: if not ok:
raise HTTPException(404, f"'{ticker}' is not in the instruments watchlist") 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}

View File

@@ -183,10 +183,21 @@ def init_db():
sort_order INTEGER DEFAULT 0, sort_order INTEGER DEFAULT 0,
added_at TEXT DEFAULT (datetime('now')) added_at TEXT DEFAULT (datetime('now'))
)""", )""",
# Optional link to a Saxo watchlist symbol (services.saxo_scheduler) — lets Options # Two DISTINCT, independent Saxo links per tracked instrument — often different
# Lab's Saxo section show only broker data for instruments the user actually tracks # Saxo instruments, not one shared symbol:
# here, instead of an independently-managed Saxo symbol list. # 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_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 # Wavelets — saved optimization/simulation runs (ported from InstrumentSimulator's
# WaveletOptimizationRun: form/results are free-form JSON blobs, not modeled relationally) # WaveletOptimizationRun: form/results are free-form JSON blobs, not modeled relationally)
"""CREATE TABLE IF NOT EXISTS wavelet_simulations ( """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]: def get_instruments_watchlist() -> List[Dict]:
conn = get_conn() conn = get_conn()
rows = conn.execute( 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" "FROM instruments_watchlist ORDER BY sort_order ASC, added_at ASC"
).fetchall() ).fetchall()
conn.close() conn.close()
return [dict(r) for r in rows] return [dict(r) for r in rows]
def set_instrument_watchlist_saxo_symbol(ticker: str, saxo_symbol: Optional[str]) -> bool: 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 a Saxo """Link (or unlink, if saxo_symbol is None/empty) a tracked instrument to the Saxo
watchlist symbol. Linking also adds that symbol to services.saxo_scheduler's own symbol whose OPTIONS CHAIN Options Lab should analyze for it — e.g. CL=F -> MCL:XCME
watchlist if it isn't there yet, so it actually starts getting snapshotted. Unlinking (a micro futures option chain), often NOT the same Saxo instrument as
does NOT remove it from the Saxo watchlist — it may still be wanted directly, or by saxo_quote_symbol (the underlying's own spot/futures price feed). Linking also adds
another linked instrument; remove it from Config -> Saxo if it's truly no longer needed.""" 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 from services.saxo_scheduler import get_watchlist, set_watchlist
ticker = ticker.upper() ticker = ticker.upper()
@@ -3297,7 +3311,7 @@ def set_instrument_watchlist_saxo_symbol(ticker: str, saxo_symbol: Optional[str]
if row is None: if row is None:
conn.close() conn.close()
return False 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.commit()
conn.close() conn.close()
@@ -3308,6 +3322,25 @@ def set_instrument_watchlist_saxo_symbol(ticker: str, saxo_symbol: Optional[str]
return True 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: def add_instrument_watchlist(ticker: str, name: str = "", asset_class: str = "unknown") -> bool:
conn = get_conn() conn = get_conn()
try: try:

View File

@@ -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() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ({ ticker, saxoSymbol }: { ticker: string; saxoSymbol: string | null }) => 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: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ['instruments-watchlist'] }) qc.invalidateQueries({ queryKey: ['instruments-watchlist'] })
qc.invalidateQueries({ queryKey: ['instruments-watchlist-quotes'] }) 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 // Latest cycle report — shares the 'cycle-report-latest' queryKey with RapportIA.tsx
export const useLatestCycleReport = () => export const useLatestCycleReport = () =>
useQuery({ useQuery({

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' 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 { 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' 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 // Inline Saxo-symbol link editor for one watchlist row. Two independent kinds, often
// show only broker data for instruments actually tracked here, instead of relying on an // pointing at DIFFERENT Saxo instruments for the same tracked ticker:
// independently-managed Saxo watchlist. Linking auto-adds the symbol to the Saxo // 'option' — which Saxo options chain Options Lab should analyze (e.g. CL=F -> the
// scheduler's watchlist (Config -> Saxo below) so it starts getting snapshotted. // Saxo micro-futures option chain MCL:XCME). Auto-adds to the Saxo
function SaxoLinkPicker({ ticker, saxoSymbol }: { ticker: string; saxoSymbol: string | null }) { // 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 [editing, setEditing] = useState(false)
const [value, setValue] = useState(saxoSymbol ?? '') const [value, setValue] = useState(saxoSymbol ?? '')
const [showDropdown, setShowDropdown] = useState(false) 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 { data: catalogMatches } = useSaxoCatalog(undefined, value.length >= 2 ? value : undefined)
const meta = SAXO_LINK_KIND_META[kind]
const save = (sym?: string) => { const save = (sym?: string) => {
const resolved = (sym ?? value).trim().toUpperCase() || null const resolved = (sym ?? value).trim().toUpperCase() || null
@@ -349,12 +360,12 @@ function SaxoLinkPicker({ ticker, saxoSymbol }: { ticker: string; saxoSymbol: st
if (!editing) { if (!editing) {
return saxoSymbol ? ( return saxoSymbol ? (
<button onClick={() => { setEditing(true); setValue(saxoSymbol) }} className="flex items-center gap-1 text-[9px] text-emerald-400 hover:text-emerald-300 bg-emerald-900/20 border border-emerald-700/30 px-1.5 py-0.5 rounded"> <button onClick={() => { setEditing(true); setValue(saxoSymbol) }} className={clsx('flex items-center gap-1 text-[9px] px-1.5 py-0.5 rounded border', meta.activeCls)}>
<Link2 className="w-2.5 h-2.5" /> {saxoSymbol} <Link2 className="w-2.5 h-2.5" /> {meta.label}: {saxoSymbol}
</button> </button>
) : ( ) : (
<button onClick={() => setEditing(true)} className="flex items-center gap-1 text-[9px] text-slate-500 hover:text-slate-300 border border-slate-700/40 px-1.5 py-0.5 rounded"> <button onClick={() => setEditing(true)} className="flex items-center gap-1 text-[9px] text-slate-500 hover:text-slate-300 border border-slate-700/40 px-1.5 py-0.5 rounded">
<Unlink className="w-2.5 h-2.5" /> Link Saxo <Unlink className="w-2.5 h-2.5" /> {meta.label}
</button> </button>
) )
} }
@@ -368,7 +379,7 @@ function SaxoLinkPicker({ ticker, saxoSymbol }: { ticker: string; saxoSymbol: st
onFocus={() => setShowDropdown(true)} onFocus={() => setShowDropdown(true)}
onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') setEditing(false) }} onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') setEditing(false) }}
onBlur={() => save()} 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" 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 && <span className="text-[9px] text-slate-600 ml-1"></span>} {isPending && <span className="text-[9px] text-slate-600 ml-1"></span>}
@@ -461,7 +472,8 @@ function WatchlistCard() {
<span className="text-[9px] text-slate-700 bg-dark-700 px-1.5 py-0.5 rounded capitalize">{item.asset_class}</span> <span className="text-[9px] text-slate-700 bg-dark-700 px-1.5 py-0.5 rounded capitalize">{item.asset_class}</span>
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<SaxoLinkPicker ticker={item.ticker} saxoSymbol={item.saxo_symbol ?? null} /> <SaxoLinkPicker ticker={item.ticker} kind="option" saxoSymbol={item.saxo_option_symbol ?? null} />
<SaxoLinkPicker ticker={item.ticker} kind="quote" saxoSymbol={item.saxo_quote_symbol ?? null} />
<button onClick={() => removeTicker(item.ticker)} className="text-slate-600 hover:text-red-400 transition-colors"> <button onClick={() => removeTicker(item.ticker)} className="text-slate-600 hover:text-red-400 transition-colors">
<X className="w-3.5 h-3.5" /> <X className="w-3.5 h-3.5" />
</button> </button>

View File

@@ -971,15 +971,17 @@ export default function Dashboard() {
})()} })()}
{/* Options Lab — Saxo-only IV highlights, scoped to watchlist instruments that have {/* Options Lab — Saxo-only IV highlights, scoped to watchlist instruments that have
a Saxo symbol linked (Config -> Instruments Watchlist). Deliberately not the a Saxo OPTION symbol linked (Config -> Instruments Watchlist, "Option" picker —
yfinance-based IV batch — matches Options Lab itself, which now only shows Saxo. */} 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 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<string, any> = {} const bySaxoSymbol: Record<string, any> = {}
for (const item of ((saxoIvWatchlistData as any)?.items ?? [])) bySaxoSymbol[item.ticker] = item for (const item of ((saxoIvWatchlistData as any)?.items ?? [])) bySaxoSymbol[item.ticker] = item
const highlights = linked 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) .filter(Boolean)
.sort((a: any, b: any) => Math.abs((b.iv_rank ?? 50) - 50) - Math.abs((a.iv_rank ?? 50) - 50)) .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() {
</div> </div>
{linked.length === 0 ? ( {linked.length === 0 ? (
<div className="text-[10px] text-slate-600 mt-1"> <div className="text-[10px] text-slate-600 mt-1">
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
</div> </div>
) : highlights.length > 0 ? ( ) : highlights.length > 0 ? (
<div className="space-y-1.5 mt-1"> <div className="space-y-1.5 mt-1">