115 lines
5.5 KiB
TypeScript
115 lines
5.5 KiB
TypeScript
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, assetTypes }: {
|
|
ticker: string
|
|
kind: SaxoLinkKind
|
|
saxoSymbol: string | null
|
|
onSave: (symbol: string | null) => void
|
|
isPending: boolean
|
|
// Overrides SAXO_LINK_KIND_META[kind]'s default asset types — e.g. an FX instrument's
|
|
// "Quote" link should only ever search FxSpot, not the default's ContractFutures/
|
|
// CfdOnFutures/StockIndex too (those exist for commodities/indices priced via futures on
|
|
// Saxo, but searching "EURUSD" under the unfiltered default surfaces CME EURUSD futures
|
|
// contracts alongside the actual spot rate — a different product, not what a currency
|
|
// pair's price should ever be linked to).
|
|
assetTypes?: string
|
|
}) {
|
|
const [editing, setEditing] = useState(false)
|
|
const [value, setValue] = useState(saxoSymbol ?? '')
|
|
const [showDropdown, setShowDropdown] = useState(false)
|
|
const meta = SAXO_LINK_KIND_META[kind]
|
|
const effectiveAssetTypes = assetTypes ?? meta.assetTypes
|
|
const { data: catalogMatches } = useSaxoCatalog(effectiveAssetTypes, 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>
|
|
)
|
|
}
|