feat: instrument analysis

This commit is contained in:
OpenSquared
2026-07-23 22:28:56 +02:00
parent 548bad2dcd
commit e0c4aa8f65
9 changed files with 2395 additions and 307 deletions

View File

@@ -0,0 +1,106 @@
import { useState } from 'react'
import { Link2, Unlink } from 'lucide-react'
import clsx from 'clsx'
import { useSaxoCatalog } from '../hooks/useApi'
// Saxo has no shared naming convention with yfinance — its catalog search matches on
// Saxo's own instrument description text ("Brent Crude"), not the yfinance ticker code
// ("BZ=F"). This maps the common yfinance futures-root/index tickers to the plain-English
// name worth searching for, so opening a picker pre-fills something useful instead of an
// empty box the user has to guess into. Not exhaustive — anything not listed here (most
// FX pairs, where the Saxo symbol IS the yfinance root, e.g. "EURUSD=X" -> "EURUSD") falls
// back to a best-effort strip of yfinance's own suffix/prefix decoration.
const YFINANCE_SEARCH_HINTS: Record<string, string> = {
'BZ=F': 'Brent', 'CL=F': 'WTI Crude', 'NG=F': 'Natural Gas',
'GC=F': 'Gold', 'SI=F': 'Silver', 'HG=F': 'Copper', 'PL=F': 'Platinum',
'^GSPC': 'S&P 500', '^NDX': 'Nasdaq 100', '^DJI': 'Dow Jones', '^RUT': 'Russell 2000',
'^VIX': 'VIX', 'IEF': 'Treasury',
}
export function guessSaxoSearchHint(ticker: string): string {
if (YFINANCE_SEARCH_HINTS[ticker]) return YFINANCE_SEARCH_HINTS[ticker]
return ticker.replace(/=F$|=X$|^\^/g, '')
}
export 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',
assetTypes: 'FuturesOption,FxVanillaOption,StockOption,StockIndexOption',
},
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',
assetTypes: 'ContractFutures,CfdOnFutures,FxSpot,StockIndex',
},
} as const
export type SaxoLinkKind = keyof typeof SAXO_LINK_KIND_META
// Generic Saxo symbol linker — ticker/saxoSymbol are display data, onSave/isPending are
// injected by the caller so this can drive any backing store (instruments_watchlist's
// saxo_option_symbol/saxo_quote_symbol columns via Config.tsx, or instruments.json's
// saxo_quote_symbol field via InstrumentDashboard.tsx) without knowing which one it is.
export default function SaxoLinkPicker({ ticker, kind, saxoSymbol, onSave, isPending }: {
ticker: string
kind: SaxoLinkKind
saxoSymbol: string | null
onSave: (symbol: string | null) => void
isPending: boolean
}) {
const [editing, setEditing] = useState(false)
const [value, setValue] = useState(saxoSymbol ?? '')
const [showDropdown, setShowDropdown] = useState(false)
const meta = SAXO_LINK_KIND_META[kind]
const { data: catalogMatches } = useSaxoCatalog(meta.assetTypes, value.length >= 2 ? value : undefined)
const save = (sym?: string) => {
const resolved = (sym ?? value).trim().toUpperCase() || null
onSave(resolved)
setEditing(false)
}
if (!editing) {
return saxoSymbol ? (
<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" /> {meta.label}: {saxoSymbol}
</button>
) : (
<button onClick={() => { setEditing(true); setValue(guessSaxoSearchHint(ticker)); setShowDropdown(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" /> {meta.label}
</button>
)
}
return (
<div className="relative">
<input
autoFocus
value={value}
onChange={e => { setValue(e.target.value); setShowDropdown(true) }}
onFocus={() => setShowDropdown(true)}
onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') setEditing(false) }}
onBlur={() => save()}
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 && <span className="text-[9px] text-slate-600 ml-1"></span>}
{showDropdown && (catalogMatches ?? []).length > 0 && (
<div className="absolute z-20 top-full left-0 mt-1 w-72 max-h-60 overflow-y-auto bg-dark-800 border border-slate-700 rounded shadow-xl">
{(catalogMatches ?? []).map((c: any) => (
<button
key={c.symbol}
type="button"
onMouseDown={e => e.preventDefault()}
onClick={() => { setValue(c.symbol); setShowDropdown(false); save(c.symbol) }}
className="flex items-center justify-between gap-3 w-full text-left px-2.5 py-1.5 text-[10px] hover:bg-dark-700 transition-colors"
>
<span className="font-mono font-bold text-white shrink-0">{c.symbol}</span>
<span className="text-slate-500 truncate">{c.description}</span>
</button>
))}
</div>
)}
</div>
)
}

View File

@@ -200,6 +200,17 @@ export const useSetWatchlistSaxoQuoteLink = () => {
})
}
// Links an Instrument Analysis instrument (backend/config/instruments.json — a separate
// catalog from instruments_watchlist above) to a Saxo quote symbol, so its chart/indicators/
// wavelets are Saxo-sourced instead of yfinance. InstrumentDashboard.tsx doesn't use React
// Query for its own data (plain axios + useState), so this mutation carries no
// invalidateQueries — the caller re-fetches the instrument list/snapshot itself on success.
export const useSetInstrumentSaxoLink = () =>
useMutation({
mutationFn: ({ instrumentId, saxoSymbol }: { instrumentId: string; saxoSymbol: string | null }) =>
api.put(`/instruments/${encodeURIComponent(instrumentId)}/saxo-link`, { saxo_symbol: saxoSymbol }).then(r => r.data),
})
// Free-form display name — no longer tied to yfinance's long_name lookup, since an
// instrument can now be entirely Saxo-sourced.
export const useRenameWatchlistInstrument = () => {

View File

@@ -4,6 +4,7 @@ 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, useSetWatchlistSaxoOptionLink, useSetWatchlistSaxoQuoteLink, useRenameWatchlistInstrument, 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'
import SaxoLinkPicker from '../components/SaxoLinkPicker'
const API = ''
@@ -338,101 +339,6 @@ function RiskProfilesCard() {
// 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.
// Asset-type filters matching backend services.saxo_client.OPTION_ASSET_TYPES /
// UNDERLYING_ASSET_TYPES — keeps each picker's search scoped to the catalog rows that
// are actually relevant, instead of both searching the same undifferentiated list.
// Saxo has no shared naming convention with yfinance — its catalog search matches on
// Saxo's own instrument description text ("Brent Crude"), not the yfinance ticker code
// ("BZ=F"). This maps the common yfinance futures-root/index tickers to the plain-English
// name worth searching for, so opening a picker pre-fills something useful instead of an
// empty box the user has to guess into. Not exhaustive — anything not listed here (most
// FX pairs, where the Saxo symbol IS the yfinance root, e.g. "EURUSD=X" -> "EURUSD") falls
// back to a best-effort strip of yfinance's own suffix/prefix decoration.
const YFINANCE_SEARCH_HINTS: Record<string, string> = {
'BZ=F': 'Brent', 'CL=F': 'WTI Crude', 'NG=F': 'Natural Gas',
'GC=F': 'Gold', 'SI=F': 'Silver', 'HG=F': 'Copper', 'PL=F': 'Platinum',
'^GSPC': 'S&P 500', '^NDX': 'Nasdaq 100', '^DJI': 'Dow Jones', '^RUT': 'Russell 2000',
'^VIX': 'VIX', 'IEF': 'Treasury',
}
function guessSaxoSearchHint(ticker: string): string {
if (YFINANCE_SEARCH_HINTS[ticker]) return YFINANCE_SEARCH_HINTS[ticker]
return ticker.replace(/=F$|=X$|^\^/g, '')
}
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',
assetTypes: 'FuturesOption,FxVanillaOption,StockOption,StockIndexOption',
},
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',
assetTypes: 'ContractFutures,CfdOnFutures,FxSpot,StockIndex',
},
} 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 optionLink = useSetWatchlistSaxoOptionLink()
const quoteLink = useSetWatchlistSaxoQuoteLink()
const { mutate: setLink, isPending } = kind === 'option' ? optionLink : quoteLink
const meta = SAXO_LINK_KIND_META[kind]
const { data: catalogMatches } = useSaxoCatalog(meta.assetTypes, value.length >= 2 ? value : undefined)
const save = (sym?: string) => {
const resolved = (sym ?? value).trim().toUpperCase() || null
setLink({ ticker, saxoSymbol: resolved }, { onSuccess: () => setEditing(false) })
}
if (!editing) {
return saxoSymbol ? (
<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" /> {meta.label}: {saxoSymbol}
</button>
) : (
<button onClick={() => { setEditing(true); setValue(guessSaxoSearchHint(ticker)); setShowDropdown(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" /> {meta.label}
</button>
)
}
return (
<div className="relative">
<input
autoFocus
value={value}
onChange={e => { setValue(e.target.value); setShowDropdown(true) }}
onFocus={() => setShowDropdown(true)}
onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') setEditing(false) }}
onBlur={() => save()}
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 && <span className="text-[9px] text-slate-600 ml-1"></span>}
{showDropdown && (catalogMatches ?? []).length > 0 && (
<div className="absolute z-20 top-full left-0 mt-1 w-72 max-h-60 overflow-y-auto bg-dark-800 border border-slate-700 rounded shadow-xl">
{(catalogMatches ?? []).map((c: any) => (
<button
key={c.symbol}
type="button"
onMouseDown={e => e.preventDefault()}
onClick={() => { setValue(c.symbol); setShowDropdown(false); save(c.symbol) }}
className="flex items-center justify-between gap-3 w-full text-left px-2.5 py-1.5 text-[10px] hover:bg-dark-700 transition-colors"
>
<span className="font-mono font-bold text-white shrink-0">{c.symbol}</span>
<span className="text-slate-500 truncate">{c.description}</span>
</button>
))}
</div>
)}
</div>
)
}
// Inline-editable display name — the ticker (primary key) no longer has to be a
// yfinance-recognized code now that an instrument can be entirely Saxo-sourced, so the
// name shouldn't be stuck at whatever yfinance's long_name lookup produced (often just
@@ -474,6 +380,8 @@ function WatchlistCard() {
const { data: items, isLoading } = useInstrumentsWatchlist()
const { mutateAsync: addTicker, isPending: adding } = useAddWatchlistInstrument()
const { mutate: removeTicker } = useRemoveWatchlistInstrument()
const optionLink = useSetWatchlistSaxoOptionLink()
const quoteLink = useSetWatchlistSaxoQuoteLink()
const [input, setInput] = useState('')
const [error, setError] = useState('')
const [note, setNote] = useState('')
@@ -543,8 +451,12 @@ function WatchlistCard() {
<span className="text-[9px] text-slate-700 bg-dark-700 px-1.5 py-0.5 rounded capitalize">{item.asset_class}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<SaxoLinkPicker ticker={item.ticker} kind="option" saxoSymbol={item.saxo_option_symbol ?? null} />
<SaxoLinkPicker ticker={item.ticker} kind="quote" saxoSymbol={item.saxo_quote_symbol ?? null} />
<SaxoLinkPicker ticker={item.ticker} kind="option" saxoSymbol={item.saxo_option_symbol ?? null}
isPending={optionLink.isPending}
onSave={sym => optionLink.mutate({ ticker: item.ticker, saxoSymbol: sym })} />
<SaxoLinkPicker ticker={item.ticker} kind="quote" saxoSymbol={item.saxo_quote_symbol ?? null}
isPending={quoteLink.isPending}
onSave={sym => quoteLink.mutate({ ticker: item.ticker, saxoSymbol: sym })} />
<button onClick={() => removeTicker(item.ticker)} className="text-slate-600 hover:text-red-400 transition-colors">
<X className="w-3.5 h-3.5" />
</button>

View File

@@ -2,12 +2,14 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import {
Sparkles, RefreshCw, ChevronDown, TrendingUp, TrendingDown,
Minus, BarChart2, Clock, Calendar, AlertCircle,
Minus, BarChart2, Clock, Calendar, AlertCircle, Link2,
} from 'lucide-react'
import axios from 'axios'
import clsx from 'clsx'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import InstrumentChart, { TheoPoint } from '../components/InstrumentChart'
import SaxoLinkPicker from '../components/SaxoLinkPicker'
import { useSetInstrumentSaxoLink } from '../hooks/useApi'
import { fmtPrice } from '../lib/format'
const api = axios.create({ baseURL: '/api' })
@@ -122,6 +124,7 @@ interface InstrumentConfig {
regime_labels: string[]
chart: { ma_periods: number[]; show_volume: boolean }
correlation_instruments: string[]
saxo_quote_symbol?: string | null
}
interface PriceCandle { time: string; open: number; high: number; low: number; close: number; volume: number }
@@ -182,6 +185,7 @@ interface Snapshot {
trend: TrendMetrics
events: SnapshotEvent[]
current_price: number; change_pct: number; change_abs: number; period: string
source?: 'saxo' | 'yfinance'
}
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -1419,6 +1423,7 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
const [period, setPeriod] = useState('1y')
const [chartStyle, setChartStyle] = useState<'candles' | 'line'>('candles')
const [instruments, setInstruments] = useState<InstrumentConfig[]>([])
const setSaxoLink = useSetInstrumentSaxoLink()
const [snapshot, setSnapshot] = useState<Snapshot | null>(null)
const [narrative, setNarrative] = useState('')
const [loading, setLoading] = useState(false)
@@ -1641,6 +1646,7 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
try {
const path = waveletCausal ? '/wavelet/rolling' : '/wavelet/analyze'
const params: any = { symbol: selected.yf_ticker, period, levels: waveletLevels, wavelet: waveletFamily, method: waveletMethod }
if (selected.saxo_quote_symbol) params.saxo_symbol = selected.saxo_quote_symbol
if (waveletCausal) params.lookback = waveletLookback
const { data } = await api.get(path, { params })
setWaveletData(data)
@@ -1661,6 +1667,7 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
params: {
symbol: selected.yf_ticker, period, levels: waveletLevels,
wavelet: waveletFamily, method: waveletMethod, lookback: waveletLookback,
...(selected.saxo_quote_symbol ? { saxo_symbol: selected.saxo_quote_symbol } : {}),
},
})
setWaveletReliability(data)
@@ -1672,6 +1679,19 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
}
}
// Re-fetch the instrument catalog (source of `selected.saxo_quote_symbol`) and the
// current snapshot after linking/unlinking a Saxo symbol — this component manages its
// own data via useState/axios rather than React Query, so there's no cache to invalidate.
const handleSaxoLinkSave = (saxoSymbol: string | null) => {
if (!instrumentId) return
setSaxoLink.mutate({ instrumentId, saxoSymbol }, {
onSuccess: () => {
api.get('/instruments').then(r => setInstruments(r.data)).catch(() => {})
fetchSnapshot()
},
})
}
const waveletChartData = useMemo(() => {
if (!waveletData) return []
const { dates, original, bands, mean } = waveletData
@@ -1907,6 +1927,11 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
</span>
)}
{selected && (
<SaxoLinkPicker ticker={selected.id} kind="quote" saxoSymbol={selected.saxo_quote_symbol ?? null}
isPending={setSaxoLink.isPending} onSave={handleSaxoLinkSave} />
)}
{displayPrice !== undefined && (
<div className="flex items-center gap-3">
<span className="text-xl font-bold text-white">
@@ -1917,6 +1942,11 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
{(snapshot.change_pct ?? 0) >= 0 ? '+' : ''}{fmtPrice(snapshot.change_abs)} ({(snapshot.change_pct ?? 0) >= 0 ? '+' : ''}{snapshot.change_pct?.toFixed(2)}%)
</span>
)}
{snapshot?.source === 'saxo' && (
<span className="flex items-center gap-1 text-[10px] text-emerald-500" title="Prix source Saxo">
<Link2 className="w-3 h-3" /> Saxo
</span>
)}
</div>
)}