feat: cockpit

This commit is contained in:
OpenSquared
2026-07-24 12:23:14 +02:00
parent 35a00f43c3
commit a2e770aac9
4 changed files with 87 additions and 115 deletions

View File

@@ -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<string>((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],

View File

@@ -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<string, string> = {
// 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>): 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<string | null>(null)
const [ecoImpactFilter, setEcoImpactFilter] = useState<Set<string>>(new Set(['high', 'medium', 'low']))
const [ecoCurrencyFilter, setEcoCurrencyFilter] = useState<Set<string>>(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<string>))
console.debug('[Watchlist] ticker -> catalogId resolution:', items.map((it: any) => ({
ticker: it.ticker,
resolved: resolveCatalogId(it.ticker, instrumentCatalogIds as Set<string>) ?? 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() {
)}
<div className="mt-1.5 pt-1.5 border-t border-slate-700/30 space-y-0.5">
{((watchlistQuotesData as any)?.items ?? []).map((it: any) => {
const catalogId = resolveCatalogId(it.ticker, instrumentCatalogIds as Set<string> | undefined)
const isOpening = openingTicker === it.ticker
return (
<div
key={it.ticker}
onDoubleClick={() => { 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`}
>
<button
type="button"

View File

@@ -186,11 +186,11 @@ interface Snapshot {
events: SnapshotEvent[]
current_price: number; change_pct: number; change_abs: number; period: string
source?: 'saxo' | 'yfinance'
source_error?: string | null
}
// ── Helpers ───────────────────────────────────────────────────────────────────
const CATEGORY_ORDER = ['equity_index', 'equity_intl', 'metal', 'energy', 'bond', 'credit', 'fx', 'volatility', 'stock', 'crypto']
const CATEGORY_LABELS: Record<string, string> = {
equity_index: 'Indices US', equity_intl: 'Intl Equity', metal: 'Métaux',
energy: 'Énergie', bond: 'Obligataire', credit: 'Crédit',
@@ -1496,7 +1496,7 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
const pd: PriceCandle[] = r.data.price_data
if (pd?.length) setSelectedDate(pd[pd.length - 1].time)
})
.catch(() => {})
.catch(e => { console.error(`[InstrumentDashboard] snapshot fetch failed for ${instrumentId}:`, e); setSnapshot(null) })
.finally(() => setLoading(false))
}, [instrumentId, period])
@@ -1645,16 +1645,10 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
}
}, [effectiveDate, priceMap, indMap, sortedDates, dateIndex, snapshot])
const grouped = CATEGORY_ORDER.map(cat => ({
cat, label: CATEGORY_LABELS[cat] ?? cat,
items: instruments.filter(i => i.category === cat),
})).filter(g => g.items.length > 0)
// Cockpit Watchlist tickers that don't already resolve to a catalog entry — offered as
// "quick add" picks (see resolveWatchlistCatalogId below for the resolution itself).
// Excludes anything already resolvable so picking it never creates a duplicate id.
const watchlistCandidates = ((watchlistItems as any[]) ?? [])
.filter(w => !resolveWatchlistCatalogId(w.ticker, instruments))
// The picker only offers Cockpit Watchlist instruments (Config) — no more browsing the
// underlying instruments.json catalog directly. Order matches the Watchlist's own
// sort_order (services.database.get_instruments_watchlist).
const watchlistOptions = (watchlistItems as any[]) ?? []
const selected = instruments.find(i => i.id === instrumentId)
const isLastDate = effectiveDate === sortedDates[sortedDates.length - 1]
@@ -1944,45 +1938,29 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
</button>
{selectorOpen && (
<div className="absolute top-full left-0 mt-1 z-50 bg-dark-800 border border-slate-700/40 rounded-xl shadow-2xl p-3 w-[480px] max-h-[60vh] overflow-y-auto">
{grouped.map(g => (
<div key={g.cat} className="mb-3">
<div className="text-xs text-slate-500 uppercase tracking-wide mb-1.5 px-1">{g.label}</div>
<div className="grid grid-cols-3 gap-1">
{g.items.map(inst => (
<button key={inst.id}
onClick={() => { navigate(`/instruments/${inst.id}`); setSelectorOpen(false) }}
className={clsx('text-left px-2.5 py-1.5 rounded-lg text-xs transition-colors',
inst.id === instrumentId
? 'bg-blue-800/40 text-blue-300 border border-blue-700/40'
: 'text-slate-400 hover:bg-dark-700 hover:text-white'
)}
>
<div className="font-semibold">{inst.id}</div>
<div className="text-slate-500 truncate">{inst.name}</div>
</button>
))}
</div>
</div>
))}
{watchlistCandidates.length > 0 && (
<div className="mt-1 pt-2 border-t border-slate-700/40">
<div className="text-xs text-slate-500 uppercase tracking-wide mb-1.5 px-1">Depuis la Watchlist</div>
<div className="grid grid-cols-3 gap-1">
{watchlistCandidates.map((w: any) => (
<button key={w.ticker}
onClick={() => handleSelectWatchlist(w.ticker)}
disabled={addingTicker === w.ticker}
className="text-left px-2.5 py-1.5 rounded-lg text-xs transition-colors text-slate-400 hover:bg-dark-700 hover:text-white disabled:opacity-50"
title={w.saxo_quote_symbol ? `Lien Saxo auto: ${w.saxo_quote_symbol}` : 'Pas encore lié à Saxo (Config → Instruments Watchlist)'}
>
<div className="font-semibold flex items-center gap-1">
{w.ticker}
{w.saxo_quote_symbol && <Link2 className="w-2.5 h-2.5 text-emerald-500 shrink-0" />}
</div>
<div className="text-slate-500 truncate">{addingTicker === w.ticker ? 'Ajout…' : (w.name || w.ticker)}</div>
</button>
))}
</div>
<div className="text-xs text-slate-500 uppercase tracking-wide mb-1.5 px-1">Watchlist (Config)</div>
{watchlistOptions.length === 0 ? (
<div className="text-xs text-slate-500 px-1 py-2">Aucun instrument dans la Watchlist du Cockpit.</div>
) : (
<div className="grid grid-cols-3 gap-1">
{watchlistOptions.map((w: any) => (
<button key={w.ticker}
onClick={() => handleSelectWatchlist(w.ticker)}
disabled={addingTicker === w.ticker}
className={clsx('text-left px-2.5 py-1.5 rounded-lg text-xs transition-colors disabled:opacity-50',
w.ticker === instrumentId || `${w.ticker}=X` === instrumentId
? 'bg-blue-800/40 text-blue-300 border border-blue-700/40'
: 'text-slate-400 hover:bg-dark-700 hover:text-white'
)}
title={w.saxo_quote_symbol ? `Lien Saxo auto: ${w.saxo_quote_symbol}` : 'Pas encore lié à Saxo (Config → Instruments Watchlist)'}
>
<div className="font-semibold flex items-center gap-1">
{w.ticker}
{w.saxo_quote_symbol && <Link2 className="w-2.5 h-2.5 text-emerald-500 shrink-0" />}
</div>
<div className="text-slate-500 truncate">{addingTicker === w.ticker ? 'Ajout…' : (w.name || w.ticker)}</div>
</button>
))}
</div>
)}
</div>
@@ -2063,6 +2041,15 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
{/* ── Content ── */}
{!loading && snapshot && (
<>
{snapshot.price_data.length === 0 && (
<div className="text-center py-16 text-slate-500 bg-dark-800/40 rounded-xl border border-slate-700/40">
<BarChart2 className="w-8 h-8 mx-auto mb-3 opacity-30" />
<p>Aucune donnée de prix pour {instrumentId}</p>
{snapshot.source_error && (
<p className="text-xs text-slate-600 mt-2 max-w-lg mx-auto">{snapshot.source_error}</p>
)}
</div>
)}
<InstrumentChart
priceData={snapshot.price_data}
indicators={snapshot.indicators}