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

File diff suppressed because it is too large Load Diff

View File

@@ -15,11 +15,16 @@ from services.instrument_service import (
get_snapshot, get_snapshot,
get_narrative, get_narrative,
update_instrument_drivers, update_instrument_drivers,
update_instrument_saxo_link,
) )
class DriverUpdate(BaseModel): class DriverUpdate(BaseModel):
drivers: List[Dict[str, Any]] drivers: List[Dict[str, Any]]
class SaxoLinkBody(BaseModel):
saxo_symbol: Optional[str] = None
router = APIRouter(prefix="/api/instruments", tags=["instruments"]) router = APIRouter(prefix="/api/instruments", tags=["instruments"])
@@ -90,6 +95,25 @@ def update_drivers(instrument_id: str, body: DriverUpdate) -> Dict[str, Any]:
return {"ok": True, "instrument_id": instrument_id.upper(), "drivers_count": len(body.drivers)} return {"ok": True, "instrument_id": instrument_id.upper(), "drivers_count": len(body.drivers)}
@router.put("/{instrument_id}/saxo-link")
def set_saxo_link(instrument_id: str, body: SaxoLinkBody) -> Dict[str, Any]:
"""Link this instrument to the Saxo symbol used to price it (chart, indicators, regime,
wavelets) instead of yfinance — or pass null to unlink. Mirrors
routers/instruments_watchlist.py's /{ticker}/saxo-quote-link, but persisted in
instruments.json (this catalog's own store) rather than the instruments_watchlist table."""
config = get_instrument(instrument_id)
if not config:
raise HTTPException(status_code=404, detail=f"Instrument '{instrument_id}' not found")
saxo_symbol = (body.saxo_symbol or "").strip().upper() or None
try:
update_instrument_saxo_link(instrument_id, saxo_symbol)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
return {"instrument_id": instrument_id.upper(), "saxo_quote_symbol": saxo_symbol}
# ── Instrument mult (pips → price conversion) ───────────────────────────────── # ── Instrument mult (pips → price conversion) ─────────────────────────────────
_INST_MULT: Dict[str, int] = {"EURUSD": 10000, "GBPUSD": 10000, "USDJPY": 100, "AUDUSD": 10000} _INST_MULT: Dict[str, int] = {"EURUSD": 10000, "GBPUSD": 10000, "USDJPY": 100, "AUDUSD": 10000}

View File

@@ -23,7 +23,26 @@ _PERIOD_TO_DAYS = {
} }
def _fetch_history(symbol: str, period: str, interval: str = "1d", start: Optional[str] = None, end: Optional[str] = None): def _fetch_history(symbol: str, period: str, interval: str = "1d", start: Optional[str] = None, end: Optional[str] = None, saxo_symbol: Optional[str] = None):
"""Saxo-first when saxo_symbol is given (resolved via the watchlist/Instrument Analysis
Saxo link), yfinance otherwise or as a silent fallback on any Saxo failure. Wavelets only
ever consume the close series, so unlike instrument_service's snapshot fetch this doesn't
need full OHLCV."""
if saxo_symbol and start is None and end is None:
# Saxo's Chart API is day-count-from-now only (no explicit date range), so a custom
# start_date/end_date request can't be served from Saxo — falls through to yfinance.
try:
from services.database import get_saxo_catalog_by_symbol
from services.saxo_client import get_price_history
entry = get_saxo_catalog_by_symbol(saxo_symbol)
asset_type = entry["asset_type"] if entry else "FxSpot"
days = _PERIOD_TO_DAYS.get(period, 365)
bars = get_price_history(saxo_symbol, asset_type, days=days)
return [b["close"] for b in bars], [b["date"] for b in bars]
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"[wavelet] Saxo fetch failed for '{saxo_symbol}', falling back to yfinance: {e}")
from services.data_fetcher import get_historical from services.data_fetcher import get_historical
hist = get_historical(symbol, period=period, interval=interval, start=start, end=end) hist = get_historical(symbol, period=period, interval=interval, start=start, end=end)
values = [h["close"] for h in hist] values = [h["close"] for h in hist]
@@ -34,7 +53,7 @@ def _fetch_history(symbol: str, period: str, interval: str = "1d", start: Option
def _fetch_padded_history( def _fetch_padded_history(
symbol: str, lookback: int, period: str = "1y", symbol: str, lookback: int, period: str = "1y",
start_date: Optional[str] = None, end_date: Optional[str] = None, start_date: Optional[str] = None, end_date: Optional[str] = None,
future_padding_days: int = 0, future_padding_days: int = 0, saxo_symbol: Optional[str] = None,
): ):
"""Fetch enough history to cover the requested causal-output range plus a """Fetch enough history to cover the requested causal-output range plus a
`lookback`-sized warmup window before it (mirrors main.py's fetch_start padding). `lookback`-sized warmup window before it (mirrors main.py's fetch_start padding).
@@ -60,7 +79,7 @@ def _fetch_padded_history(
if start_date: if start_date:
fetch_start = (date.fromisoformat(start_date) - timedelta(days=pad_days)).isoformat() fetch_start = (date.fromisoformat(start_date) - timedelta(days=pad_days)).isoformat()
fetch_end = (date.fromisoformat(end_date) + timedelta(days=future_padding_days)).isoformat() if end_date and future_padding_days else end_date fetch_end = (date.fromisoformat(end_date) + timedelta(days=future_padding_days)).isoformat() if end_date and future_padding_days else end_date
values, dates = _fetch_history(symbol, period, start=fetch_start, end=fetch_end) values, dates = _fetch_history(symbol, period, start=fetch_start, end=fetch_end, saxo_symbol=saxo_symbol)
return values, dates, start_date return values, dates, start_date
out_days = _PERIOD_TO_DAYS.get(period, 365) out_days = _PERIOD_TO_DAYS.get(period, 365)
@@ -69,7 +88,7 @@ def _fetch_padded_history(
(p for p, d in sorted(_PERIOD_TO_DAYS.items(), key=lambda kv: kv[1]) if d >= total_days), (p for p, d in sorted(_PERIOD_TO_DAYS.items(), key=lambda kv: kv[1]) if d >= total_days),
"10y", "10y",
) )
values, dates = _fetch_history(symbol, fetch_period) values, dates = _fetch_history(symbol, fetch_period, saxo_symbol=saxo_symbol)
cutoff = (datetime.utcnow() - timedelta(days=out_days)).date().isoformat() cutoff = (datetime.utcnow() - timedelta(days=out_days)).date().isoformat()
return values, dates, cutoff return values, dates, cutoff
@@ -84,10 +103,11 @@ def wavelet_analyze(
method: str = Query("cwt", description="cwt (default) or ssq"), method: str = Query("cwt", description="cwt (default) or ssq"),
start_date: Optional[str] = Query(None, description="ISO date (YYYY-MM-DD) — overrides `period` with an explicit custom range"), start_date: Optional[str] = Query(None, description="ISO date (YYYY-MM-DD) — overrides `period` with an explicit custom range"),
end_date: Optional[str] = Query(None, description="ISO date (YYYY-MM-DD), only used alongside start_date; omit for 'through today'"), end_date: Optional[str] = Query(None, description="ISO date (YYYY-MM-DD), only used alongside start_date; omit for 'through today'"),
saxo_symbol: Optional[str] = Query(None, description="Saxo quote symbol — Saxo-first, falls back to `symbol` (yfinance) on any failure"),
): ):
from services.wavelet_engine import windowed_band_decompose, band_decompose_ssq from services.wavelet_engine import windowed_band_decompose, band_decompose_ssq
values, dates = _fetch_history(symbol, period, start=start_date, end=end_date) values, dates = _fetch_history(symbol, period, start=start_date, end=end_date, saxo_symbol=saxo_symbol)
if len(values) < 32: if len(values) < 32:
raise HTTPException(400, "Historique insuffisant pour une analyse ondelette (32 points minimum).") raise HTTPException(400, "Historique insuffisant pour une analyse ondelette (32 points minimum).")
@@ -112,13 +132,14 @@ def wavelet_rolling(
method: str = Query("cwt", description="cwt (default) or ssq"), method: str = Query("cwt", description="cwt (default) or ssq"),
start_date: Optional[str] = Query(None, description="ISO date (YYYY-MM-DD) — overrides `period`; the causal output starts here"), start_date: Optional[str] = Query(None, description="ISO date (YYYY-MM-DD) — overrides `period`; the causal output starts here"),
end_date: Optional[str] = Query(None, description="ISO date (YYYY-MM-DD), only used alongside start_date; omit for 'through today'"), end_date: Optional[str] = Query(None, description="ISO date (YYYY-MM-DD), only used alongside start_date; omit for 'through today'"),
saxo_symbol: Optional[str] = Query(None, description="Saxo quote symbol — Saxo-first, falls back to `symbol` (yfinance) on any failure"),
): ):
"""Walk-forward version of /analyze: band values are computed day by day from a """Walk-forward version of /analyze: band values are computed day by day from a
trailing `lookback`-point window only, so a trade simulation built on this never trailing `lookback`-point window only, so a trade simulation built on this never
sees data past its own decision date.""" sees data past its own decision date."""
from services.wavelet_engine import rolling_causal_bands, rolling_causal_bands_ssq from services.wavelet_engine import rolling_causal_bands, rolling_causal_bands_ssq
values, dates, cutoff = _fetch_padded_history(symbol, lookback, period, start_date, end_date) values, dates, cutoff = _fetch_padded_history(symbol, lookback, period, start_date, end_date, saxo_symbol=saxo_symbol)
if len(values) < lookback + 32: if len(values) < lookback + 32:
raise HTTPException(400, "Historique insuffisant pour une analyse ondelette (32 points minimum).") raise HTTPException(400, "Historique insuffisant pour une analyse ondelette (32 points minimum).")
@@ -158,6 +179,7 @@ def wavelet_reliability_endpoint(
max_future_padding: int = Query(60, ge=10, le=180, description="extra real days fetched beyond the requested range, to cover the slowest band's own (data-driven) confirm horizon"), max_future_padding: int = Query(60, ge=10, le=180, description="extra real days fetched beyond the requested range, to cover the slowest band's own (data-driven) confirm horizon"),
start_date: Optional[str] = Query(None, description="ISO date (YYYY-MM-DD) — overrides `period`; the causal output starts here"), start_date: Optional[str] = Query(None, description="ISO date (YYYY-MM-DD) — overrides `period`; the causal output starts here"),
end_date: Optional[str] = Query(None, description="ISO date (YYYY-MM-DD), only used alongside start_date; omit for 'through today'"), end_date: Optional[str] = Query(None, description="ISO date (YYYY-MM-DD), only used alongside start_date; omit for 'through today'"),
saxo_symbol: Optional[str] = Query(None, description="Saxo quote symbol — Saxo-first, falls back to `symbol` (yfinance) on any failure"),
): ):
"""For every reversal a live (causal, walk-forward) decomposition would have flagged, """For every reversal a live (causal, walk-forward) decomposition would have flagged,
checks whether redoing the decomposition later still shows the same reversal — a checks whether redoing the decomposition later still shows the same reversal — a
@@ -169,7 +191,7 @@ def wavelet_reliability_endpoint(
# The horizon is now computed per band inside wavelet_reliability (from each band's own # The horizon is now computed per band inside wavelet_reliability (from each band's own
# measured cycle length), so we don't know it in advance here — pad generously enough to # measured cycle length), so we don't know it in advance here — pad generously enough to
# cover even a slow band's cycle instead. # cover even a slow band's cycle instead.
values, dates, cutoff = _fetch_padded_history(symbol, lookback, period, start_date, end_date, future_padding_days=max_future_padding) values, dates, cutoff = _fetch_padded_history(symbol, lookback, period, start_date, end_date, future_padding_days=max_future_padding, saxo_symbol=saxo_symbol)
if len(values) < lookback + max_future_padding + 32: if len(values) < lookback + max_future_padding + 32:
raise HTTPException(400, "Historique insuffisant pour un test de fiabilité (32 points minimum au-delà de la fenêtre + marge).") raise HTTPException(400, "Historique insuffisant pour un test de fiabilité (32 points minimum au-delà de la fenêtre + marge).")

View File

@@ -75,6 +75,32 @@ def update_instrument_drivers(instrument_id: str, drivers: List[Dict]) -> None:
logger.info(f"[instrument_service] Updated drivers for {uid} ({len(drivers)} drivers)") logger.info(f"[instrument_service] Updated drivers for {uid} ({len(drivers)} drivers)")
def update_instrument_saxo_link(instrument_id: str, saxo_symbol: Optional[str]) -> None:
"""Persist the Saxo quote-symbol link to instruments.json and refresh in-memory config.
saxo_symbol=None clears the link (falls back to yfinance)."""
global _configs
if _configs is None:
_load_configs()
uid = instrument_id.upper()
if uid not in _configs:
raise ValueError(f"Instrument {uid} not found")
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
raw = json.load(f)
for inst in raw["instruments"]:
if inst["id"] == uid:
inst["saxo_quote_symbol"] = saxo_symbol
break
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(raw, f, ensure_ascii=False, indent=2)
_configs[uid]["saxo_quote_symbol"] = saxo_symbol
logger.info(f"[instrument_service] Updated Saxo link for {uid}: {saxo_symbol}")
# ── DataFrame helpers ────────────────────────────────────────────────────────── # ── DataFrame helpers ──────────────────────────────────────────────────────────
def _ohlcv_to_df(records: List[Dict]) -> pd.DataFrame: def _ohlcv_to_df(records: List[Dict]) -> pd.DataFrame:
@@ -653,6 +679,46 @@ def _get_relevant_events(
# ── Main snapshot ────────────────────────────────────────────────────────────── # ── Main snapshot ──────────────────────────────────────────────────────────────
# Approximate calendar-day span of each yfinance-style period string — used to size the
# Saxo `days` fetch window (Saxo's Chart API is day-count based, not period-string based).
# Same values as routers/wavelet.py's _PERIOD_TO_DAYS; duplicated locally since it's a
# tiny, stable lookup table not worth sharing across modules.
_PERIOD_TO_DAYS = {
"5d": 5, "1mo": 30, "3mo": 90, "6mo": 182,
"1y": 365, "2y": 730, "5y": 1825, "10y": 3650, "max": 3650,
}
def _fetch_ohlcv(config: Dict[str, Any], instrument_id: str, period: str, interval: str) -> Tuple[List[Dict], str]:
"""Fetch OHLCV records for the snapshot — Saxo-first when the instrument has a
saxo_quote_symbol linked, yfinance otherwise (or as a silent fallback on any Saxo
failure). Returns (records, source)."""
saxo_symbol = config.get("saxo_quote_symbol")
if saxo_symbol:
try:
from services.database import get_saxo_catalog_by_symbol
from services.saxo_client import get_price_history
entry = get_saxo_catalog_by_symbol(saxo_symbol)
asset_type = entry["asset_type"] if entry else "FxSpot"
days = _PERIOD_TO_DAYS.get(period, 365)
bars = get_price_history(saxo_symbol, asset_type, days=days)
records = [{"date": b["date"], "open": b.get("open"), "high": b.get("high"),
"low": b.get("low"), "close": b.get("close"), "volume": b.get("volume")}
for b in bars]
return records, "saxo"
except Exception as e:
logger.warning(f"[instrument_service] Saxo fetch failed for {instrument_id} ({saxo_symbol}), falling back to yfinance: {e}")
yf_ticker = config.get("yf_ticker", instrument_id)
try:
from services.data_fetcher import get_historical
records = get_historical(yf_ticker, period=period, interval=interval)
except Exception as e:
logger.error(f"[instrument_service] Data fetch failed for {instrument_id}: {e}")
records = []
return records, "yfinance"
async def get_snapshot( async def get_snapshot(
instrument_id: str, instrument_id: str,
period: str = "1y", period: str = "1y",
@@ -666,16 +732,7 @@ async def get_snapshot(
if not config: if not config:
return {"error": f"Unknown instrument: {instrument_id}"} return {"error": f"Unknown instrument: {instrument_id}"}
yf_ticker = config.get("yf_ticker", instrument_id) records, source = _fetch_ohlcv(config, instrument_id, period, interval)
# Fetch OHLCV
try:
from services.data_fetcher import get_historical
records = get_historical(yf_ticker, period=period, interval=interval)
except Exception as e:
logger.error(f"[instrument_service] Data fetch failed for {instrument_id}: {e}")
records = []
df = _ohlcv_to_df(records) df = _ohlcv_to_df(records)
# Compute everything # Compute everything
@@ -752,6 +809,7 @@ async def get_snapshot(
"change_pct": change_pct, "change_pct": change_pct,
"change_abs": change_abs, "change_abs": change_abs,
"period": period, "period": period,
"source": source,
} }

View File

@@ -249,7 +249,9 @@ def get_price_history(symbol: str, asset_type: str = "FxSpot", days: int = 90) -
Callers should treat any failure here (entitlement gap, still-wrong field names for Callers should treat any failure here (entitlement gap, still-wrong field names for
some other asset type, etc.) as routine and fall back to another source, not surface some other asset type, etc.) as routine and fall back to another source, not surface
it as a hard error. it as a hard error.
Returns oldest-first [{"date": "YYYY-MM-DD", "close": float}, ...]. Returns oldest-first [{"date": "YYYY-MM-DD", "close": float, "open": float|None,
"high": float|None, "low": float|None, "volume": float|None}, ...] — open/high/low/volume
are None when the raw bar doesn't carry them (e.g. FX Spot has no traded volume).
""" """
instrument = resolve_instrument(symbol, asset_types=asset_type) instrument = resolve_instrument(symbol, asset_types=asset_type)
data = _get("/chart/v3/charts", { data = _get("/chart/v3/charts", {
@@ -271,7 +273,14 @@ def get_price_history(symbol: str, asset_type: str = "FxSpot", days: int = 90) -
close = (bar["CloseBid"] + bar["CloseAsk"]) / 2 close = (bar["CloseBid"] + bar["CloseAsk"]) / 2
if close is None or not time_str: if close is None or not time_str:
continue continue
out.append({"date": str(time_str)[:10], "close": float(close)}) out.append({
"date": str(time_str)[:10],
"close": float(close),
"open": float(bar["Open"]) if bar.get("Open") is not None else None,
"high": float(bar["High"]) if bar.get("High") is not None else None,
"low": float(bar["Low"]) if bar.get("Low") is not None else None,
"volume": float(bar["Volume"]) if bar.get("Volume") is not None else None,
})
if not out: if not out:
raise ValueError(f"Chart data for '{symbol}' had no usable Close/CloseMid/CloseBid+CloseAsk/Time fields — raw bar keys: {list(bars[0].keys()) if bars else []}") raise ValueError(f"Chart data for '{symbol}' had no usable Close/CloseMid/CloseBid+CloseAsk/Time fields — raw bar keys: {list(bars[0].keys()) if bars else []}")
return out return out

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 // Free-form display name — no longer tied to yfinance's long_name lookup, since an
// instrument can now be entirely Saxo-sourced. // instrument can now be entirely Saxo-sourced.
export const useRenameWatchlistInstrument = () => { 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 { 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 { 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'
import SaxoLinkPicker from '../components/SaxoLinkPicker'
const API = '' const API = ''
@@ -338,101 +339,6 @@ function RiskProfilesCard() {
// scheduler's watchlist (Config -> Saxo below) so it gets snapshotted. // scheduler's watchlist (Config -> Saxo below) so it gets snapshotted.
// 'quote' — which Saxo spot/futures symbol prices this instrument in the Cockpit, // 'quote' — which Saxo spot/futures symbol prices this instrument in the Cockpit,
// replacing yfinance's unadjusted continuous-futures tickers. // 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 // 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 // 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 // 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 { data: items, isLoading } = useInstrumentsWatchlist()
const { mutateAsync: addTicker, isPending: adding } = useAddWatchlistInstrument() const { mutateAsync: addTicker, isPending: adding } = useAddWatchlistInstrument()
const { mutate: removeTicker } = useRemoveWatchlistInstrument() const { mutate: removeTicker } = useRemoveWatchlistInstrument()
const optionLink = useSetWatchlistSaxoOptionLink()
const quoteLink = useSetWatchlistSaxoQuoteLink()
const [input, setInput] = useState('') const [input, setInput] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [note, setNote] = 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> <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} kind="option" saxoSymbol={item.saxo_option_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} /> 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"> <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

@@ -2,12 +2,14 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router-dom' import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import { import {
Sparkles, RefreshCw, ChevronDown, TrendingUp, TrendingDown, Sparkles, RefreshCw, ChevronDown, TrendingUp, TrendingDown,
Minus, BarChart2, Clock, Calendar, AlertCircle, Minus, BarChart2, Clock, Calendar, AlertCircle, Link2,
} from 'lucide-react' } from 'lucide-react'
import axios from 'axios' import axios from 'axios'
import clsx from 'clsx' import clsx from 'clsx'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts' import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import InstrumentChart, { TheoPoint } from '../components/InstrumentChart' import InstrumentChart, { TheoPoint } from '../components/InstrumentChart'
import SaxoLinkPicker from '../components/SaxoLinkPicker'
import { useSetInstrumentSaxoLink } from '../hooks/useApi'
import { fmtPrice } from '../lib/format' import { fmtPrice } from '../lib/format'
const api = axios.create({ baseURL: '/api' }) const api = axios.create({ baseURL: '/api' })
@@ -122,6 +124,7 @@ interface InstrumentConfig {
regime_labels: string[] regime_labels: string[]
chart: { ma_periods: number[]; show_volume: boolean } chart: { ma_periods: number[]; show_volume: boolean }
correlation_instruments: string[] correlation_instruments: string[]
saxo_quote_symbol?: string | null
} }
interface PriceCandle { time: string; open: number; high: number; low: number; close: number; volume: number } interface PriceCandle { time: string; open: number; high: number; low: number; close: number; volume: number }
@@ -182,6 +185,7 @@ interface Snapshot {
trend: TrendMetrics trend: TrendMetrics
events: SnapshotEvent[] events: SnapshotEvent[]
current_price: number; change_pct: number; change_abs: number; period: string current_price: number; change_pct: number; change_abs: number; period: string
source?: 'saxo' | 'yfinance'
} }
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
@@ -1419,6 +1423,7 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
const [period, setPeriod] = useState('1y') const [period, setPeriod] = useState('1y')
const [chartStyle, setChartStyle] = useState<'candles' | 'line'>('candles') const [chartStyle, setChartStyle] = useState<'candles' | 'line'>('candles')
const [instruments, setInstruments] = useState<InstrumentConfig[]>([]) const [instruments, setInstruments] = useState<InstrumentConfig[]>([])
const setSaxoLink = useSetInstrumentSaxoLink()
const [snapshot, setSnapshot] = useState<Snapshot | null>(null) const [snapshot, setSnapshot] = useState<Snapshot | null>(null)
const [narrative, setNarrative] = useState('') const [narrative, setNarrative] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -1641,6 +1646,7 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
try { try {
const path = waveletCausal ? '/wavelet/rolling' : '/wavelet/analyze' const path = waveletCausal ? '/wavelet/rolling' : '/wavelet/analyze'
const params: any = { symbol: selected.yf_ticker, period, levels: waveletLevels, wavelet: waveletFamily, method: waveletMethod } 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 if (waveletCausal) params.lookback = waveletLookback
const { data } = await api.get(path, { params }) const { data } = await api.get(path, { params })
setWaveletData(data) setWaveletData(data)
@@ -1661,6 +1667,7 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
params: { params: {
symbol: selected.yf_ticker, period, levels: waveletLevels, symbol: selected.yf_ticker, period, levels: waveletLevels,
wavelet: waveletFamily, method: waveletMethod, lookback: waveletLookback, wavelet: waveletFamily, method: waveletMethod, lookback: waveletLookback,
...(selected.saxo_quote_symbol ? { saxo_symbol: selected.saxo_quote_symbol } : {}),
}, },
}) })
setWaveletReliability(data) 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(() => { const waveletChartData = useMemo(() => {
if (!waveletData) return [] if (!waveletData) return []
const { dates, original, bands, mean } = waveletData const { dates, original, bands, mean } = waveletData
@@ -1907,6 +1927,11 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
</span> </span>
)} )}
{selected && (
<SaxoLinkPicker ticker={selected.id} kind="quote" saxoSymbol={selected.saxo_quote_symbol ?? null}
isPending={setSaxoLink.isPending} onSave={handleSaxoLinkSave} />
)}
{displayPrice !== undefined && ( {displayPrice !== undefined && (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-xl font-bold text-white"> <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)}%) {(snapshot.change_pct ?? 0) >= 0 ? '+' : ''}{fmtPrice(snapshot.change_abs)} ({(snapshot.change_pct ?? 0) >= 0 ? '+' : ''}{snapshot.change_pct?.toFixed(2)}%)
</span> </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> </div>
)} )}