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
|
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.
|
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
|
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
|
global _configs
|
||||||
if _configs is None:
|
if _configs is None:
|
||||||
_load_configs()
|
_load_configs()
|
||||||
@@ -160,6 +164,8 @@ def quick_add_instrument_from_watchlist(ticker: str) -> Dict[str, Any]:
|
|||||||
uid = ticker.strip().upper()
|
uid = ticker.strip().upper()
|
||||||
if uid in _configs:
|
if uid in _configs:
|
||||||
return {"id": uid, "created": False}
|
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
|
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)
|
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
|
"""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
|
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_symbol = config.get("saxo_quote_symbol")
|
||||||
|
saxo_error = None
|
||||||
if saxo_symbol:
|
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:
|
try:
|
||||||
from services.database import get_saxo_catalog_by_symbol
|
|
||||||
from services.saxo_client import get_price_history
|
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)
|
days = _PERIOD_TO_DAYS.get(period, 365)
|
||||||
bars = get_price_history(saxo_symbol, asset_type, days=days)
|
bars = get_price_history(saxo_symbol, asset_type, days=days)
|
||||||
records = [{"date": b["date"], "open": b.get("open"), "high": b.get("high"),
|
records = [{"date": b["date"], "open": b.get("open"), "high": b.get("high"),
|
||||||
"low": b.get("low"), "close": b.get("close"), "volume": b.get("volume")}
|
"low": b.get("low"), "close": b.get("close"), "volume": b.get("volume")}
|
||||||
for b in bars]
|
for b in bars]
|
||||||
return records, "saxo"
|
return records, "saxo", None
|
||||||
except Exception as e:
|
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)
|
yf_ticker = config.get("yf_ticker", instrument_id)
|
||||||
try:
|
try:
|
||||||
@@ -791,7 +804,9 @@ def _fetch_ohlcv(config: Dict[str, Any], instrument_id: str, period: str, interv
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[instrument_service] Data fetch failed for {instrument_id}: {e}")
|
logger.error(f"[instrument_service] Data fetch failed for {instrument_id}: {e}")
|
||||||
records = []
|
records = []
|
||||||
return records, "yfinance"
|
if not records and saxo_error:
|
||||||
|
return records, "yfinance", saxo_error
|
||||||
|
return records, "yfinance", None
|
||||||
|
|
||||||
|
|
||||||
async def get_snapshot(
|
async def get_snapshot(
|
||||||
@@ -807,7 +822,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}"}
|
||||||
|
|
||||||
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)
|
df = _ohlcv_to_df(records)
|
||||||
|
|
||||||
# Compute everything
|
# Compute everything
|
||||||
@@ -885,6 +900,7 @@ async def get_snapshot(
|
|||||||
"change_abs": change_abs,
|
"change_abs": change_abs,
|
||||||
"period": period,
|
"period": period,
|
||||||
"source": source,
|
"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) =>
|
export const useWatchlistHistory = (ticker: string, period: string) =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: ['instruments-watchlist-history', ticker, period],
|
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 { useQuery } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
useGeoRiskScore, useAllQuotes,
|
useGeoRiskScore, useAllQuotes,
|
||||||
useEcoCalendar, usePortfolioSummary, usePortfolioPositions, usePnlHistory, useLastScores, useAllPatterns, useMacroRegime,
|
useEcoCalendar, usePortfolioSummary, usePortfolioPositions, usePnlHistory, useLastScores, useAllPatterns, useMacroRegime,
|
||||||
useRiskDashboard, useRiskRadar, useGeoNews,
|
useRiskDashboard, useRiskRadar, useGeoNews,
|
||||||
useCycleStatus,
|
useCycleStatus,
|
||||||
useInstrumentsWatchlist, useInstrumentsWatchlistQuotes, useWatchlistHistory, useInstrumentCatalogIds, useSaxoIvWatchlist, useLatestCycleReport,
|
useInstrumentsWatchlist, useInstrumentsWatchlistQuotes, useWatchlistHistory, useQuickAddInstrument, useSaxoIvWatchlist, useLatestCycleReport,
|
||||||
useWaveletWatchlistSignals,
|
useWaveletWatchlistSignals,
|
||||||
} from '../hooks/useApi'
|
} from '../hooks/useApi'
|
||||||
import { Clock, Globe, ShieldAlert, ArrowUpRight, Newspaper, Waves, Link2 } from 'lucide-react'
|
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.
|
// and a major G7/FX market alongside US/EUR/China.
|
||||||
const ECO_CURRENCY_FILTERS = ['USD', 'EUR', 'CNY', 'GBP'] as const
|
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 formatDateShort = (dateStr: string) => {
|
||||||
const d = new Date(dateStr + 'T00:00:00Z')
|
const d = new Date(dateStr + 'T00:00:00Z')
|
||||||
return d.toLocaleDateString('fr-FR', { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
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: geoNews } = useGeoNews()
|
||||||
const { data: watchlistItems } = useInstrumentsWatchlist()
|
const { data: watchlistItems } = useInstrumentsWatchlist()
|
||||||
const { data: watchlistQuotesData } = useInstrumentsWatchlistQuotes()
|
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 [ecoImpactFilter, setEcoImpactFilter] = useState<Set<string>>(new Set(['high', 'medium', 'low']))
|
||||||
const [ecoCurrencyFilter, setEcoCurrencyFilter] = useState<Set<string>>(new Set(ECO_CURRENCY_FILTERS))
|
const [ecoCurrencyFilter, setEcoCurrencyFilter] = useState<Set<string>>(new Set(ECO_CURRENCY_FILTERS))
|
||||||
const [ecoShowNextWeek, setEcoShowNextWeek] = useState(false)
|
const [ecoShowNextWeek, setEcoShowNextWeek] = useState(false)
|
||||||
@@ -145,23 +135,20 @@ export default function Dashboard() {
|
|||||||
const { data: waveletSignalsData } = useWaveletWatchlistSignals()
|
const { data: waveletSignalsData } = useWaveletWatchlistSignals()
|
||||||
const { data: saxoIvWatchlistData } = useSaxoIvWatchlist()
|
const { data: saxoIvWatchlistData } = useSaxoIvWatchlist()
|
||||||
|
|
||||||
// Diagnostic for the Watchlist card's "open in Instrument Analysis" arrow/double-click:
|
// Double-click on a Watchlist row opens it in Instrument Analysis. Always quick-adds
|
||||||
// logs whether the catalog query succeeded and how each watchlist ticker resolves
|
// server-side first (instrument_service.quick_add_instrument_from_watchlist is
|
||||||
// against it, so a missing arrow can be traced to "catalog query failed" vs. "ticker
|
// idempotent — a ticker that already resolves, curated or previously quick-added, is
|
||||||
// genuinely isn't registered in instruments.json" without needing a debugger.
|
// returned unchanged) rather than gating on a client-cached catalog id list, since that
|
||||||
useEffect(() => {
|
// cache going stale after a quick-add elsewhere is exactly what made the button silently
|
||||||
if (instrumentCatalogError) {
|
// do nothing for tickers added after the Cockpit page's own catalog query last ran.
|
||||||
console.error('[Watchlist] instrument catalog query failed:', instrumentCatalogErrorObj)
|
const handleOpenInAnalysis = (ticker: string) => {
|
||||||
return
|
setOpeningTicker(ticker)
|
||||||
}
|
quickAddInstrument.mutate(ticker, {
|
||||||
if (!instrumentCatalogIds || !watchlistQuotesData) return
|
onSuccess: ({ id }) => navigate(`/instruments/${encodeURIComponent(id)}`),
|
||||||
const items: any[] = (watchlistQuotesData as any)?.items ?? []
|
onError: (e) => console.error(`[Watchlist] failed to open ${ticker} in Instrument Analysis:`, e),
|
||||||
console.debug('[Watchlist] catalog ids:', Array.from(instrumentCatalogIds as Set<string>))
|
onSettled: () => setOpeningTicker(null),
|
||||||
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])
|
|
||||||
|
|
||||||
// Historical PnL curve (realized, from closed portfolio positions) + latest VaR snapshot
|
// Historical PnL curve (realized, from closed portfolio positions) + latest VaR snapshot
|
||||||
const { data: pnlHistoryData } = usePnlHistory()
|
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">
|
<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) => {
|
{((watchlistQuotesData as any)?.items ?? []).map((it: any) => {
|
||||||
const catalogId = resolveCatalogId(it.ticker, instrumentCatalogIds as Set<string> | undefined)
|
const isOpening = openingTicker === it.ticker
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={it.ticker}
|
key={it.ticker}
|
||||||
onDoubleClick={() => { if (catalogId) navigate(`/instruments/${encodeURIComponent(catalogId)}`) }}
|
onDoubleClick={() => handleOpenInAnalysis(it.ticker)}
|
||||||
className={clsx(
|
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',
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -186,11 +186,11 @@ interface Snapshot {
|
|||||||
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'
|
source?: 'saxo' | 'yfinance'
|
||||||
|
source_error?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const CATEGORY_ORDER = ['equity_index', 'equity_intl', 'metal', 'energy', 'bond', 'credit', 'fx', 'volatility', 'stock', 'crypto']
|
|
||||||
const CATEGORY_LABELS: Record<string, string> = {
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
equity_index: 'Indices US', equity_intl: 'Intl Equity', metal: 'Métaux',
|
equity_index: 'Indices US', equity_intl: 'Intl Equity', metal: 'Métaux',
|
||||||
energy: 'Énergie', bond: 'Obligataire', credit: 'Crédit',
|
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
|
const pd: PriceCandle[] = r.data.price_data
|
||||||
if (pd?.length) setSelectedDate(pd[pd.length - 1].time)
|
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))
|
.finally(() => setLoading(false))
|
||||||
}, [instrumentId, period])
|
}, [instrumentId, period])
|
||||||
|
|
||||||
@@ -1645,16 +1645,10 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
|||||||
}
|
}
|
||||||
}, [effectiveDate, priceMap, indMap, sortedDates, dateIndex, snapshot])
|
}, [effectiveDate, priceMap, indMap, sortedDates, dateIndex, snapshot])
|
||||||
|
|
||||||
const grouped = CATEGORY_ORDER.map(cat => ({
|
// The picker only offers Cockpit Watchlist instruments (Config) — no more browsing the
|
||||||
cat, label: CATEGORY_LABELS[cat] ?? cat,
|
// underlying instruments.json catalog directly. Order matches the Watchlist's own
|
||||||
items: instruments.filter(i => i.category === cat),
|
// sort_order (services.database.get_instruments_watchlist).
|
||||||
})).filter(g => g.items.length > 0)
|
const watchlistOptions = (watchlistItems as any[]) ?? []
|
||||||
|
|
||||||
// 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))
|
|
||||||
|
|
||||||
const selected = instruments.find(i => i.id === instrumentId)
|
const selected = instruments.find(i => i.id === instrumentId)
|
||||||
const isLastDate = effectiveDate === sortedDates[sortedDates.length - 1]
|
const isLastDate = effectiveDate === sortedDates[sortedDates.length - 1]
|
||||||
@@ -1944,45 +1938,29 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
|||||||
</button>
|
</button>
|
||||||
{selectorOpen && (
|
{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">
|
<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 className="text-xs text-slate-500 uppercase tracking-wide mb-1.5 px-1">Watchlist (Config)</div>
|
||||||
<div key={g.cat} className="mb-3">
|
{watchlistOptions.length === 0 ? (
|
||||||
<div className="text-xs text-slate-500 uppercase tracking-wide mb-1.5 px-1">{g.label}</div>
|
<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">
|
) : (
|
||||||
{g.items.map(inst => (
|
<div className="grid grid-cols-3 gap-1">
|
||||||
<button key={inst.id}
|
{watchlistOptions.map((w: any) => (
|
||||||
onClick={() => { navigate(`/instruments/${inst.id}`); setSelectorOpen(false) }}
|
<button key={w.ticker}
|
||||||
className={clsx('text-left px-2.5 py-1.5 rounded-lg text-xs transition-colors',
|
onClick={() => handleSelectWatchlist(w.ticker)}
|
||||||
inst.id === instrumentId
|
disabled={addingTicker === w.ticker}
|
||||||
? 'bg-blue-800/40 text-blue-300 border border-blue-700/40'
|
className={clsx('text-left px-2.5 py-1.5 rounded-lg text-xs transition-colors disabled:opacity-50',
|
||||||
: 'text-slate-400 hover:bg-dark-700 hover:text-white'
|
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'
|
||||||
<div className="font-semibold">{inst.id}</div>
|
)}
|
||||||
<div className="text-slate-500 truncate">{inst.name}</div>
|
title={w.saxo_quote_symbol ? `Lien Saxo auto: ${w.saxo_quote_symbol}` : 'Pas encore lié à Saxo (Config → Instruments Watchlist)'}
|
||||||
</button>
|
>
|
||||||
))}
|
<div className="font-semibold flex items-center gap-1">
|
||||||
</div>
|
{w.ticker}
|
||||||
</div>
|
{w.saxo_quote_symbol && <Link2 className="w-2.5 h-2.5 text-emerald-500 shrink-0" />}
|
||||||
))}
|
</div>
|
||||||
{watchlistCandidates.length > 0 && (
|
<div className="text-slate-500 truncate">{addingTicker === w.ticker ? 'Ajout…' : (w.name || w.ticker)}</div>
|
||||||
<div className="mt-1 pt-2 border-t border-slate-700/40">
|
</button>
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -2063,6 +2041,15 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
|||||||
{/* ── Content ── */}
|
{/* ── Content ── */}
|
||||||
{!loading && snapshot && (
|
{!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
|
<InstrumentChart
|
||||||
priceData={snapshot.price_data}
|
priceData={snapshot.price_data}
|
||||||
indicators={snapshot.indicators}
|
indicators={snapshot.indicators}
|
||||||
|
|||||||
Reference in New Issue
Block a user