From a2e770aac9cf5681ac2699566d15c22e4b457978 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Fri, 24 Jul 2026 12:23:14 +0200 Subject: [PATCH] feat: cockpit --- backend/services/instrument_service.py | 36 ++++++--- frontend/src/hooks/useApi.ts | 18 ----- frontend/src/pages/Dashboard.tsx | 59 ++++++-------- frontend/src/pages/InstrumentDashboard.tsx | 89 +++++++++------------- 4 files changed, 87 insertions(+), 115 deletions(-) diff --git a/backend/services/instrument_service.py b/backend/services/instrument_service.py index 9f64369..ee1b6e0 100644 --- a/backend/services/instrument_service.py +++ b/backend/services/instrument_service.py @@ -152,7 +152,11 @@ def quick_add_instrument_from_watchlist(ticker: str) -> Dict[str, Any]: entry. Its saxo_quote_symbol (already linked in Config -> Instruments Watchlist) is copied over automatically — no need to re-link it a second time in Instrument Analysis. Idempotent: if instrument_id already resolves (either a real catalog entry or an - earlier quick add), returns it unchanged rather than overwriting name/category.""" + earlier quick add), returns it unchanged rather than overwriting name/category. Also + tries the yfinance "=X" FX suffix (EURUSD -> EURUSD=X) before creating anything, so a + watchlist ticker that already has a curated catalog counterpart reuses it instead of + fragmenting into a second, duplicate id — this check has to live server-side (not just + in the frontend picker) since _configs is the only always-fresh source of truth.""" global _configs if _configs is None: _load_configs() @@ -160,6 +164,8 @@ def quick_add_instrument_from_watchlist(ticker: str) -> Dict[str, Any]: uid = ticker.strip().upper() if uid in _configs: return {"id": uid, "created": False} + if f"{uid}=X" in _configs: + return {"id": f"{uid}=X", "created": False} from services.database import get_instruments_watchlist, set_instrument_override_quick_add row = next((r for r in get_instruments_watchlist() if r["ticker"].upper() == uid), None) @@ -764,25 +770,32 @@ _PERIOD_TO_DAYS = { } -def _fetch_ohlcv(config: Dict[str, Any], instrument_id: str, period: str, interval: str) -> Tuple[List[Dict], str]: +def _fetch_ohlcv(config: Dict[str, Any], instrument_id: str, period: str, interval: str) -> Tuple[List[Dict], str, Optional[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).""" + failure). Returns (records, source, error) — error is the Saxo failure reason, kept + even when the yfinance fallback also comes up empty (a quick-added instrument's + yf_ticker is just its Cockpit ticker, e.g. "BRENT" — that's never a real yfinance + symbol, so surfacing *why Saxo failed* is the only actionable diagnostic in that case, + rather than a bare empty chart with no explanation).""" saxo_symbol = config.get("saxo_quote_symbol") + saxo_error = None if saxo_symbol: + from services.database import get_saxo_catalog_by_symbol + entry = get_saxo_catalog_by_symbol(saxo_symbol) + asset_type = entry["asset_type"] if entry else "FxSpot" 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" + return records, "saxo", None except Exception as e: - logger.warning(f"[instrument_service] Saxo fetch failed for {instrument_id} ({saxo_symbol}), falling back to yfinance: {e}") + catalog_note = " — pas dans le catalogue Saxo local, asset_type par défaut" if not entry else "" + saxo_error = f"Saxo ({saxo_symbol}, asset_type={asset_type}{catalog_note}): {e}" + logger.warning(f"[instrument_service] Saxo fetch failed for {instrument_id} ({saxo_symbol}, asset_type={asset_type}): {e}") yf_ticker = config.get("yf_ticker", instrument_id) try: @@ -791,7 +804,9 @@ def _fetch_ohlcv(config: Dict[str, Any], instrument_id: str, period: str, interv except Exception as e: logger.error(f"[instrument_service] Data fetch failed for {instrument_id}: {e}") records = [] - return records, "yfinance" + if not records and saxo_error: + return records, "yfinance", saxo_error + return records, "yfinance", None async def get_snapshot( @@ -807,7 +822,7 @@ async def get_snapshot( if not config: return {"error": f"Unknown instrument: {instrument_id}"} - records, source = _fetch_ohlcv(config, instrument_id, period, interval) + records, source, source_error = _fetch_ohlcv(config, instrument_id, period, interval) df = _ohlcv_to_df(records) # Compute everything @@ -885,6 +900,7 @@ async def get_snapshot( "change_abs": change_abs, "period": period, "source": source, + "source_error": source_error if not price_data else None, } diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 8c62a27..0f96967 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -140,24 +140,6 @@ export const useAddWatchlistInstrument = () => { }) } -// Instrument Analysis runs on its own separate catalog (backend config/instruments.json, -// not services.database.instruments_watchlist) — used to gate navigation links so we -// don't send the user to a 404/error page for a ticker that isn't registered there. -export const useInstrumentCatalogIds = () => - useQuery({ - queryKey: ['instrument-analysis-catalog'], - // No trailing slash: the backend route is registered at "/api/instruments" exactly - // (router.get("")) — a trailing slash still resolves via FastAPI's redirect_slashes, - // but calling the exact path avoids depending on that 307 round-trip surviving the - // nginx proxy_pass unchanged. - queryFn: () => api.get('/instruments').then(r => { - const ids = new Set((r.data ?? []).map((i: any) => String(i.id).toUpperCase())) - console.debug('[useInstrumentCatalogIds] loaded', ids.size, 'catalog ids:', Array.from(ids)) - return ids - }), - staleTime: 10 * 60_000, - }) - export const useWatchlistHistory = (ticker: string, period: string) => useQuery({ queryKey: ['instruments-watchlist-history', ticker, period], diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 51905c6..e54adc5 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,11 +1,11 @@ -import { useMemo, useState, useRef, useLayoutEffect, useEffect } from 'react' +import { useMemo, useState, useRef, useLayoutEffect } from 'react' import { useQuery } from '@tanstack/react-query' import { useGeoRiskScore, useAllQuotes, useEcoCalendar, usePortfolioSummary, usePortfolioPositions, usePnlHistory, useLastScores, useAllPatterns, useMacroRegime, useRiskDashboard, useRiskRadar, useGeoNews, useCycleStatus, - useInstrumentsWatchlist, useInstrumentsWatchlistQuotes, useWatchlistHistory, useInstrumentCatalogIds, useSaxoIvWatchlist, useLatestCycleReport, + useInstrumentsWatchlist, useInstrumentsWatchlistQuotes, useWatchlistHistory, useQuickAddInstrument, useSaxoIvWatchlist, useLatestCycleReport, useWaveletWatchlistSignals, } from '../hooks/useApi' import { Clock, Globe, ShieldAlert, ArrowUpRight, Newspaper, Waves, Link2 } from 'lucide-react' @@ -57,17 +57,6 @@ const IMPACT_DOT: Record = { // and a major G7/FX market alongside US/EUR/China. const ECO_CURRENCY_FILTERS = ['USD', 'EUR', 'CNY', 'GBP'] as const -// Instrument Analysis's catalog keys FX pairs with yfinance's "=X" suffix (EURUSD=X), -// while the Cockpit watchlist stores the plain ticker (EURUSD) — try both forms before -// concluding an instrument isn't registered there. -const resolveCatalogId = (ticker: string, catalogIds?: Set): string | undefined => { - if (!catalogIds) return undefined - const upper = ticker.toUpperCase() - if (catalogIds.has(upper)) return upper - if (catalogIds.has(`${upper}=X`)) return `${upper}=X` - return undefined -} - const formatDateShort = (dateStr: string) => { const d = new Date(dateStr + 'T00:00:00Z') return d.toLocaleDateString('fr-FR', { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) @@ -132,7 +121,8 @@ export default function Dashboard() { const { data: geoNews } = useGeoNews() const { data: watchlistItems } = useInstrumentsWatchlist() const { data: watchlistQuotesData } = useInstrumentsWatchlistQuotes() - const { data: instrumentCatalogIds, isError: instrumentCatalogError, error: instrumentCatalogErrorObj } = useInstrumentCatalogIds() + const quickAddInstrument = useQuickAddInstrument() + const [openingTicker, setOpeningTicker] = useState(null) const [ecoImpactFilter, setEcoImpactFilter] = useState>(new Set(['high', 'medium', 'low'])) const [ecoCurrencyFilter, setEcoCurrencyFilter] = useState>(new Set(ECO_CURRENCY_FILTERS)) const [ecoShowNextWeek, setEcoShowNextWeek] = useState(false) @@ -145,23 +135,20 @@ export default function Dashboard() { const { data: waveletSignalsData } = useWaveletWatchlistSignals() const { data: saxoIvWatchlistData } = useSaxoIvWatchlist() - // Diagnostic for the Watchlist card's "open in Instrument Analysis" arrow/double-click: - // logs whether the catalog query succeeded and how each watchlist ticker resolves - // against it, so a missing arrow can be traced to "catalog query failed" vs. "ticker - // genuinely isn't registered in instruments.json" without needing a debugger. - useEffect(() => { - if (instrumentCatalogError) { - console.error('[Watchlist] instrument catalog query failed:', instrumentCatalogErrorObj) - return - } - if (!instrumentCatalogIds || !watchlistQuotesData) return - const items: any[] = (watchlistQuotesData as any)?.items ?? [] - console.debug('[Watchlist] catalog ids:', Array.from(instrumentCatalogIds as Set)) - console.debug('[Watchlist] ticker -> catalogId resolution:', items.map((it: any) => ({ - ticker: it.ticker, - resolved: resolveCatalogId(it.ticker, instrumentCatalogIds as Set) ?? null, - }))) - }, [instrumentCatalogIds, instrumentCatalogError, instrumentCatalogErrorObj, watchlistQuotesData]) + // Double-click on a Watchlist row opens it in Instrument Analysis. Always quick-adds + // server-side first (instrument_service.quick_add_instrument_from_watchlist is + // idempotent — a ticker that already resolves, curated or previously quick-added, is + // returned unchanged) rather than gating on a client-cached catalog id list, since that + // cache going stale after a quick-add elsewhere is exactly what made the button silently + // do nothing for tickers added after the Cockpit page's own catalog query last ran. + const handleOpenInAnalysis = (ticker: string) => { + setOpeningTicker(ticker) + quickAddInstrument.mutate(ticker, { + onSuccess: ({ id }) => navigate(`/instruments/${encodeURIComponent(id)}`), + onError: (e) => console.error(`[Watchlist] failed to open ${ticker} in Instrument Analysis:`, e), + onSettled: () => setOpeningTicker(null), + }) + } // Historical PnL curve (realized, from closed portfolio positions) + latest VaR snapshot const { data: pnlHistoryData } = usePnlHistory() @@ -447,17 +434,17 @@ export default function Dashboard() { )}
{((watchlistQuotesData as any)?.items ?? []).map((it: any) => { - const catalogId = resolveCatalogId(it.ticker, instrumentCatalogIds as Set | undefined) + const isOpening = openingTicker === it.ticker return (
{ if (catalogId) navigate(`/instruments/${encodeURIComponent(catalogId)}`) }} + onDoubleClick={() => handleOpenInAnalysis(it.ticker)} className={clsx( - 'flex items-center justify-between gap-1.5 text-[10px] whitespace-nowrap rounded px-1 -mx-1 py-0.5 transition-colors', + 'flex items-center justify-between gap-1.5 text-[10px] whitespace-nowrap rounded px-1 -mx-1 py-0.5 transition-colors cursor-pointer', it.ticker === activeWatchlistTicker ? 'bg-blue-900/20' : 'hover:bg-dark-700/40', - catalogId && 'cursor-pointer' + isOpening && 'opacity-50' )} - title={catalogId ? `Double-click to open ${it.name || it.ticker} in Instrument Analysis` : undefined} + title={`Double-click to open ${it.name || it.ticker} in Instrument Analysis`} > {selectorOpen && (
- {grouped.map(g => ( -
-
{g.label}
-
- {g.items.map(inst => ( - - ))} -
-
- ))} - {watchlistCandidates.length > 0 && ( -
-
Depuis la Watchlist
-
- {watchlistCandidates.map((w: any) => ( - - ))} -
+
Watchlist (Config)
+ {watchlistOptions.length === 0 ? ( +
Aucun instrument dans la Watchlist du Cockpit.
+ ) : ( +
+ {watchlistOptions.map((w: any) => ( + + ))}
)}
@@ -2063,6 +2041,15 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i {/* ── Content ── */} {!loading && snapshot && ( <> + {snapshot.price_data.length === 0 && ( +
+ +

Aucune donnée de prix pour {instrumentId}

+ {snapshot.source_error && ( +

{snapshot.source_error}

+ )} +
+ )}