feat: cockpit
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user