- InstrumentChart: remove setMarkers arrowDown (level-colored) Add DOM overlay div with ★ HTML characters positioned via timeToCoordinate + priceToCoordinate(candle.high) - 52px; sized by impact_score; colored by category Label (title, 22 chars) rendered above each star; redrawn on scroll/zoom via subscribeVisibleLogicalRangeChange; cleaned up on unmount ChartEvent interface: add category, impact_score, end_date, description Header legend: replace ▼ LT/MT/CT with ★ category color legend - InstrumentDashboard: remove EventStarsStrip (now internal to chart) Remove unused dateToMs helper Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
928 lines
42 KiB
TypeScript
928 lines
42 KiB
TypeScript
import { useState, useEffect, useCallback, useMemo } from 'react'
|
|
import { useParams, useNavigate } from 'react-router-dom'
|
|
import {
|
|
Sparkles, RefreshCw, ChevronDown, TrendingUp, TrendingDown,
|
|
Minus, BarChart2, Clock, Calendar, AlertCircle, Pencil, Save, X, Plus, Trash2,
|
|
} from 'lucide-react'
|
|
import axios from 'axios'
|
|
import clsx from 'clsx'
|
|
import InstrumentChart from '../components/InstrumentChart'
|
|
|
|
const api = axios.create({ baseURL: '/api' })
|
|
|
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
|
|
interface Driver {
|
|
key: string; label: string; weight: number; keywords: string[]; type?: string
|
|
}
|
|
|
|
interface InstrumentConfig {
|
|
id: string; name: string; yf_ticker: string; category: string; currency: string
|
|
description: string; drivers: Driver[]
|
|
regime_labels: string[]
|
|
chart: { ma_periods: number[]; show_volume: boolean }
|
|
correlation_instruments: string[]
|
|
}
|
|
|
|
interface PriceCandle { time: string; open: number; high: number; low: number; close: number; volume: number }
|
|
interface LinePoint { time: string; value: number }
|
|
|
|
interface SnapshotEvent {
|
|
date: string; end_date: string | null; title: string; level: string
|
|
category: string; sub_type?: string; description: string; impact_score: number
|
|
expected_value?: string | null; actual_value?: string | null
|
|
surprise_pct?: number | null; unit?: string | null; absorption_pct?: number | null
|
|
}
|
|
|
|
interface RegimeSignals {
|
|
ma50_above_ma200: boolean | null; ma50_slope_pct: number; ma200_slope_pct: number
|
|
momentum_20d_pct: number; dist_ma200_pct: number; vol_ratio_pct: number
|
|
}
|
|
|
|
interface TrendMetrics {
|
|
ma50_slope_5d: number; ma200_slope_20d: number; rsi14_current: number
|
|
atr14_current: number; atr_vs_3m_avg_pct: number; momentum_1m_pct: number
|
|
momentum_3m_pct: number; dist_ma50_pct: number | null; dist_ma200_pct: number | null
|
|
current_price: number; high_52w: number; low_52w: number
|
|
}
|
|
|
|
interface MacroRegime {
|
|
dominant: string
|
|
label: string
|
|
color: string
|
|
emoji: string
|
|
scores: Record<string, number>
|
|
ranked: string[]
|
|
asset_bias: Record<string, any>
|
|
}
|
|
|
|
interface Snapshot {
|
|
instrument: InstrumentConfig
|
|
price_data: PriceCandle[]
|
|
indicators: Record<string, LinePoint[]>
|
|
regime: { current: string; confidence: number; scores: Record<string, number>; signals: RegimeSignals }
|
|
macro_regime: MacroRegime
|
|
trend: TrendMetrics
|
|
events: SnapshotEvent[]
|
|
current_price: number; change_pct: number; change_abs: number; period: string
|
|
}
|
|
|
|
// ── 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',
|
|
fx: 'Forex', volatility: 'Volatilité', stock: 'Actions', crypto: 'Crypto',
|
|
}
|
|
|
|
function regimeColor(label: string): string {
|
|
const l = label.toLowerCase()
|
|
if (/bull|expan|recov|rally|strength|risk.on|inflow|demand|falling|weakness/i.test(l)) return 'emerald'
|
|
if (/bear|crash|crunch|shock|squeeze|fear|risk.off|selloff|crisis|deficit/i.test(l)) return 'red'
|
|
if (/volatil|spike|stress/i.test(l)) return 'orange'
|
|
if (/range|neutral|consolid|compress/i.test(l)) return 'slate'
|
|
return 'blue'
|
|
}
|
|
|
|
function pctColor(v: number | null, inverse = false): string {
|
|
if (v === null || v === undefined) return 'text-slate-400'
|
|
const pos = inverse ? v < 0 : v > 0
|
|
const neg = inverse ? v > 0 : v < 0
|
|
return pos ? 'text-emerald-400' : neg ? 'text-red-400' : 'text-slate-400'
|
|
}
|
|
|
|
function Arrow({ v }: { v: number }) {
|
|
if (v > 0.3) return <TrendingUp className="w-3.5 h-3.5 text-emerald-400" />
|
|
if (v < -0.3) return <TrendingDown className="w-3.5 h-3.5 text-red-400" />
|
|
return <Minus className="w-3.5 h-3.5 text-slate-500" />
|
|
}
|
|
|
|
function fmt(v: number | null, digits = 2): string {
|
|
if (v === null || v === undefined) return '—'
|
|
return (v >= 0 ? '+' : '') + v.toFixed(digits)
|
|
}
|
|
|
|
function fmtDateFR(s: string | null): string {
|
|
if (!s) return '—'
|
|
const [y, m, d] = s.split('-')
|
|
return `${d}/${m}/${y}`
|
|
}
|
|
|
|
function pctN(a: number | undefined, b: number | undefined): number {
|
|
if (a === undefined || b === undefined || b === 0) return 0
|
|
return ((a - b) / b) * 100
|
|
}
|
|
|
|
// ── Driver type config ────────────────────────────────────────────────────────
|
|
|
|
const DRIVER_TYPES = ['event_calendar', 'report', 'geopolitical', 'fundamental', 'sentiment', 'technical'] as const
|
|
type DriverType = typeof DRIVER_TYPES[number]
|
|
|
|
const DRIVER_TYPE_CFG: Record<string, { short: string; tw: string }> = {
|
|
event_calendar: { short: 'Cal.', tw: 'text-amber-400 bg-amber-900/30 border-amber-700/30' },
|
|
report: { short: 'Rep.', tw: 'text-blue-400 bg-blue-900/30 border-blue-700/30' },
|
|
geopolitical: { short: 'Geo.', tw: 'text-red-400 bg-red-900/30 border-red-700/30' },
|
|
fundamental: { short: 'Fund.', tw: 'text-emerald-400 bg-emerald-900/30 border-emerald-700/30' },
|
|
sentiment: { short: 'Sent.', tw: 'text-violet-400 bg-violet-900/30 border-violet-700/30' },
|
|
technical: { short: 'Tech.', tw: 'text-cyan-400 bg-cyan-900/30 border-cyan-700/30' },
|
|
}
|
|
|
|
// ── Macro regime colour mapping ───────────────────────────────────────────────
|
|
|
|
const MACRO_COLOR_MAP: Record<string, string> = {
|
|
goldilocks: 'emerald',
|
|
desinflation: 'cyan',
|
|
soft_landing: 'blue',
|
|
reflation: 'amber',
|
|
stagflation: 'orange',
|
|
inflation_shock: 'red',
|
|
recession: 'red',
|
|
crise_liquidite: 'red',
|
|
incertain: 'slate',
|
|
}
|
|
|
|
function macroRegimeColor(dominant: string): string {
|
|
return MACRO_COLOR_MAP[dominant] ?? 'slate'
|
|
}
|
|
|
|
// ── RegimeCard ────────────────────────────────────────────────────────────────
|
|
|
|
function RegimeCard({
|
|
regime, macroRegime, signalsAt, dateLabel,
|
|
}: {
|
|
regime: Snapshot['regime']
|
|
macroRegime: MacroRegime | null
|
|
signalsAt: RegimeSignals | null
|
|
dateLabel: string
|
|
}) {
|
|
const signals = signalsAt ?? regime.signals
|
|
const col = regimeColor(regime.current)
|
|
|
|
const borderMap: Record<string, string> = {
|
|
emerald: 'border-emerald-700/40 bg-emerald-950/30',
|
|
red: 'border-red-700/40 bg-red-950/30',
|
|
orange: 'border-orange-700/40 bg-orange-950/30',
|
|
slate: 'border-slate-700/40 bg-slate-900/30',
|
|
blue: 'border-blue-700/40 bg-blue-950/30',
|
|
cyan: 'border-cyan-700/40 bg-cyan-950/30',
|
|
amber: 'border-amber-700/40 bg-amber-950/30',
|
|
}
|
|
|
|
const ma50Color = signals.ma50_above_ma200 ? 'text-emerald-400' : 'text-red-400'
|
|
const volColor = (signals.vol_ratio_pct ?? 0) > 130 ? 'text-orange-400' : (signals.vol_ratio_pct ?? 0) < 70 ? 'text-cyan-400' : 'text-slate-300'
|
|
|
|
const metrics: { label: string; value: string; sub?: string; color: string }[] = [
|
|
{
|
|
label: 'MA50 / MA200',
|
|
value: signals.ma50_above_ma200 === true ? 'Au-dessus' : signals.ma50_above_ma200 === false ? 'En-dessous' : '—',
|
|
sub: signals.ma50_above_ma200 === true ? '↑ Golden cross' : signals.ma50_above_ma200 === false ? '↓ Death cross' : '',
|
|
color: ma50Color,
|
|
},
|
|
{ label: 'Slope MA50 (10j)', value: fmt(signals.ma50_slope_pct) + '%', color: pctColor(signals.ma50_slope_pct) },
|
|
{ label: 'Slope MA200 (10j)', value: fmt(signals.ma200_slope_pct) + '%', color: pctColor(signals.ma200_slope_pct) },
|
|
{ label: 'Momentum 20j', value: fmt(signals.momentum_20d_pct) + '%', color: pctColor(signals.momentum_20d_pct) },
|
|
{
|
|
label: 'Distance MA200',
|
|
value: fmt(signals.dist_ma200_pct) + '%',
|
|
sub: signals.dist_ma200_pct > 10 ? 'Surextension' : signals.dist_ma200_pct < -10 ? 'Survendu' : 'Neutre',
|
|
color: pctColor(signals.dist_ma200_pct),
|
|
},
|
|
{
|
|
label: 'Volatilité ATR',
|
|
value: (signals.vol_ratio_pct ?? 0).toFixed(0) + '%',
|
|
sub: (signals.vol_ratio_pct ?? 0) > 130 ? 'Élevée' : (signals.vol_ratio_pct ?? 0) < 70 ? 'Comprimée' : 'Normale',
|
|
color: volColor,
|
|
},
|
|
]
|
|
|
|
const macroCol = macroRegime ? macroRegimeColor(macroRegime.dominant) : 'slate'
|
|
const top3Macro = macroRegime?.ranked?.slice(0, 3) ?? []
|
|
|
|
return (
|
|
<div className={clsx('rounded-xl border p-4 space-y-3', borderMap[col])}>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<BarChart2 className={clsx('w-4 h-4', `text-${col}-400`)} />
|
|
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Régime</span>
|
|
</div>
|
|
<span className="text-xs text-slate-600">{dateLabel}</span>
|
|
</div>
|
|
|
|
{macroRegime && macroRegime.dominant !== 'incertain' && (
|
|
<div className="rounded-lg border border-slate-700/30 bg-dark-700/40 px-3 py-2 space-y-1.5">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs text-slate-500 uppercase tracking-wide">Cycle macro global</span>
|
|
<span className="text-xs text-slate-600">Fed cycle</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-base">{macroRegime.emoji || '🌍'}</span>
|
|
<span className={clsx('text-sm font-bold', `text-${macroCol}-300`)}>{macroRegime.label || macroRegime.dominant}</span>
|
|
</div>
|
|
{top3Macro.length > 0 && (
|
|
<div className="flex flex-wrap gap-1 pt-0.5">
|
|
{top3Macro.map((key, i) => {
|
|
const score = macroRegime.scores?.[key]
|
|
const c = macroRegimeColor(key)
|
|
return (
|
|
<span key={key} className={clsx(
|
|
'text-xs px-1.5 py-0.5 rounded border',
|
|
i === 0 ? `text-${c}-300 bg-${c}-900/30 border-${c}-700/40` : 'text-slate-500 bg-dark-800/40 border-slate-700/30'
|
|
)}>
|
|
{key} {score !== undefined ? Math.round(score) + '%' : ''}
|
|
</span>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className={clsx('rounded-lg px-3 py-2', `bg-${col}-900/20`)}>
|
|
<div className="text-xs text-slate-500 mb-0.5">Régime technique</div>
|
|
<div className={clsx('text-sm font-bold leading-tight', `text-${col}-300`)}>{regime.current}</div>
|
|
<div className="flex items-center gap-2 mt-1">
|
|
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
|
<div className={clsx('h-full rounded-full', `bg-${col}-500`)}
|
|
style={{ width: `${Math.round(regime.confidence * 100)}%` }} />
|
|
</div>
|
|
<span className="text-xs text-slate-500">{Math.round(regime.confidence * 100)}%</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{metrics.map(m => (
|
|
<div key={m.label} className="rounded-lg bg-dark-700/50 px-2.5 py-2">
|
|
<div className="text-xs text-slate-600 mb-0.5 truncate">{m.label}</div>
|
|
<div className={clsx('text-sm font-bold', m.color)}>{m.value}</div>
|
|
{m.sub && <div className="text-xs text-slate-600 mt-0.5">{m.sub}</div>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── TrendCard ─────────────────────────────────────────────────────────────────
|
|
|
|
function TrendCard({ trend, dateLabel }: { trend: TrendMetrics; dateLabel: string }) {
|
|
const rsi = trend.rsi14_current ?? 50
|
|
const rsiColor = rsi > 70 ? 'text-orange-400' : rsi < 30 ? 'text-cyan-400' : 'text-slate-300'
|
|
const rsiZone = rsi > 70 ? 'Suracheté' : rsi < 30 ? 'Survendu' : 'Neutre'
|
|
|
|
const items = [
|
|
{ group: 'Tendance', rows: [
|
|
{ label: 'Slope MA50 (5j)', value: fmt(trend.ma50_slope_5d) + '%', arrow: trend.ma50_slope_5d },
|
|
{ label: 'Slope MA200 (20j)', value: fmt(trend.ma200_slope_20d) + '%', arrow: trend.ma200_slope_20d },
|
|
{ label: 'Distance MA50', value: trend.dist_ma50_pct != null ? fmt(trend.dist_ma50_pct) + '%' : '—', arrow: trend.dist_ma50_pct ?? 0 },
|
|
{ label: 'Distance MA200', value: trend.dist_ma200_pct != null ? fmt(trend.dist_ma200_pct) + '%' : '—', arrow: trend.dist_ma200_pct ?? 0, bold: true },
|
|
]},
|
|
{ group: 'Momentum', rows: [
|
|
{ label: 'Momentum 1M', value: fmt(trend.momentum_1m_pct) + '%', arrow: trend.momentum_1m_pct },
|
|
{ label: 'Momentum 3M', value: fmt(trend.momentum_3m_pct) + '%', arrow: trend.momentum_3m_pct, bold: true },
|
|
]},
|
|
]
|
|
|
|
const pct52w = trend.high_52w && trend.low_52w && (trend.high_52w - trend.low_52w) > 0
|
|
? ((trend.current_price - trend.low_52w) / (trend.high_52w - trend.low_52w)) * 100 : null
|
|
|
|
return (
|
|
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<TrendingUp className="w-4 h-4 text-blue-400" />
|
|
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Indicateurs de Tendance</span>
|
|
</div>
|
|
<span className="text-xs text-slate-600">{dateLabel}</span>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between bg-dark-700/40 rounded-lg px-3 py-1.5">
|
|
<span className="text-xs text-slate-500">Prix</span>
|
|
<span className="text-sm font-bold text-white">
|
|
{trend.current_price?.toLocaleString('fr-FR', { maximumFractionDigits: 4 })}
|
|
</span>
|
|
</div>
|
|
|
|
{items.map(group => (
|
|
<div key={group.group}>
|
|
<div className="text-xs text-slate-600 uppercase tracking-wide mb-1.5">{group.group}</div>
|
|
{group.rows.map(row => (
|
|
<div key={row.label} className="flex items-center justify-between text-xs py-0.5">
|
|
<span className="text-slate-500">{row.label}</span>
|
|
<span className={clsx('flex items-center gap-1', (row as any).bold ? 'font-semibold' : '', pctColor(row.arrow ?? 0))}>
|
|
<Arrow v={row.arrow ?? 0} />{row.value}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
))}
|
|
|
|
<div className="border-t border-slate-700/30 pt-3">
|
|
<div className="flex items-center justify-between text-xs mb-1.5">
|
|
<span className="text-slate-500">RSI(14)</span>
|
|
<span className={clsx('font-semibold', rsiColor)}>{rsi.toFixed(0)} — {rsiZone}</span>
|
|
</div>
|
|
<div className="relative h-2 bg-slate-800 rounded-full overflow-hidden">
|
|
<div className="absolute left-0 top-0 h-full w-[30%] bg-cyan-900/50" />
|
|
<div className="absolute right-0 top-0 h-full w-[30%] bg-orange-900/50" />
|
|
<div className="absolute top-0 h-full w-2 bg-white rounded-full transition-all"
|
|
style={{ left: `calc(${Math.min(98, rsi)}% - 4px)` }} />
|
|
</div>
|
|
<div className="flex justify-between text-xs text-slate-600 mt-0.5">
|
|
<span>0 Survendu</span><span>50</span><span>Suracheté 100</span>
|
|
</div>
|
|
</div>
|
|
|
|
{pct52w !== null && (
|
|
<div>
|
|
<div className="flex items-center justify-between text-xs mb-1">
|
|
<span className="text-slate-500">Range 52 semaines</span>
|
|
<span className="text-slate-400">{Math.round(pct52w)}e percentile</span>
|
|
</div>
|
|
<div className="relative h-2 bg-slate-800 rounded-full overflow-hidden">
|
|
<div className="absolute top-0 h-full bg-blue-500/60 rounded-full" style={{ width: `${pct52w}%` }} />
|
|
</div>
|
|
<div className="flex justify-between text-xs text-slate-600 mt-0.5">
|
|
<span>{trend.low_52w?.toFixed(2)}</span><span>{trend.high_52w?.toFixed(2)}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center justify-between text-xs">
|
|
<span className="text-slate-500">ATR(14) vs moy. 3M</span>
|
|
<span className={clsx((trend.atr_vs_3m_avg_pct ?? 100) > 130 ? 'text-orange-400' : (trend.atr_vs_3m_avg_pct ?? 100) < 70 ? 'text-cyan-400' : 'text-slate-400')}>
|
|
{(trend.atr_vs_3m_avg_pct ?? 100).toFixed(0)}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── EventsCard ────────────────────────────────────────────────────────────────
|
|
|
|
function EventsCard({ events, selectedDate }: { events: SnapshotEvent[]; selectedDate: string | null }) {
|
|
const navigate = useNavigate()
|
|
const LEVEL_C: Record<string, string> = {
|
|
long: 'text-violet-400 bg-violet-900/30 border-violet-700/30',
|
|
medium: 'text-blue-400 bg-blue-900/30 border-blue-700/30',
|
|
short: 'text-emerald-400 bg-emerald-900/30 border-emerald-700/30',
|
|
}
|
|
|
|
function isActiveAt(ev: SnapshotEvent): boolean {
|
|
if (!selectedDate) return false
|
|
const evStart = ev.date
|
|
const evEnd = ev.end_date
|
|
if (evStart > selectedDate) return false
|
|
if (evEnd) return evEnd >= selectedDate
|
|
// point event: active if within 30 days after
|
|
const diffDays = (new Date(selectedDate).getTime() - new Date(evStart).getTime()) / 86400000
|
|
return diffDays <= 30
|
|
}
|
|
|
|
function surpriseColor(v: number | null | undefined): string {
|
|
if (v === null || v === undefined) return 'text-slate-400'
|
|
if (v > 5) return 'text-emerald-400'
|
|
if (v < -5) return 'text-red-400'
|
|
return 'text-slate-400'
|
|
}
|
|
|
|
return (
|
|
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4 space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<Calendar className="w-4 h-4 text-amber-400" />
|
|
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Événements Macro</span>
|
|
</div>
|
|
{events.length === 0 ? (
|
|
<div className="text-xs text-slate-600 italic py-2">Aucun événement lié trouvé</div>
|
|
) : (
|
|
<div className="space-y-2 max-h-[380px] overflow-y-auto pr-1">
|
|
{events.map((ev, i) => {
|
|
const active = isActiveAt(ev)
|
|
return (
|
|
<div key={i}
|
|
className={clsx(
|
|
'rounded-lg border p-2 cursor-pointer transition-colors',
|
|
active
|
|
? 'border-amber-600/50 bg-amber-900/20 hover:bg-amber-900/30'
|
|
: 'border-slate-700/30 bg-dark-700/40 hover:bg-dark-700/70'
|
|
)}
|
|
onClick={() => navigate(`/timeline?date=${ev.date}`)}
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
{active && <span className="shrink-0 w-1.5 h-1.5 rounded-full bg-amber-400 mt-0.5" />}
|
|
<span className="text-xs font-medium text-slate-300 leading-tight truncate">{ev.title}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
{ev.sub_type && (
|
|
<span className="text-xs px-1 py-0 rounded bg-slate-700/50 text-slate-400 border border-slate-600/30"
|
|
style={{ fontSize: 8, paddingTop: 1, paddingBottom: 1 }}>
|
|
{ev.sub_type}
|
|
</span>
|
|
)}
|
|
<span className={clsx('text-xs px-1.5 py-0.5 rounded border', LEVEL_C[ev.level] ?? 'text-slate-400 bg-slate-800 border-slate-700/30')}>
|
|
{ev.level === 'long' ? 'LT' : ev.level === 'medium' ? 'MT' : 'CT'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Eco calendar fields */}
|
|
{(ev.expected_value || ev.actual_value) && (
|
|
<div className="flex items-center gap-3 mt-1.5 text-xs">
|
|
{ev.expected_value && (
|
|
<span className="text-slate-500">Att. <span className="text-slate-300">{ev.expected_value}</span></span>
|
|
)}
|
|
{ev.actual_value && (
|
|
<span className="text-slate-500">Réel <span className="font-semibold text-white">{ev.actual_value}</span></span>
|
|
)}
|
|
{ev.surprise_pct !== null && ev.surprise_pct !== undefined && (
|
|
<span className={clsx('font-semibold', surpriseColor(ev.surprise_pct))}>
|
|
{ev.surprise_pct > 0 ? '+' : ''}{ev.surprise_pct.toFixed(0)}%
|
|
{ev.surprise_pct > 5 ? ' ↑' : ev.surprise_pct < -5 ? ' ↓' : ''}
|
|
</span>
|
|
)}
|
|
{ev.unit && <span className="text-slate-600">{ev.unit}</span>}
|
|
</div>
|
|
)}
|
|
|
|
{ev.absorption_pct !== null && ev.absorption_pct !== undefined && (
|
|
<div className="mt-1.5">
|
|
<div className="flex items-center justify-between text-xs text-slate-600 mb-0.5">
|
|
<span>Absorption marché</span><span>{ev.absorption_pct}%</span>
|
|
</div>
|
|
<div className="h-1 bg-slate-800 rounded-full overflow-hidden">
|
|
<div className="h-full bg-amber-600/60 rounded-full" style={{ width: `${ev.absorption_pct}%` }} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center gap-2 mt-1 text-xs text-slate-500">
|
|
<Clock className="w-3 h-3" />
|
|
{fmtDateFR(ev.date)}{ev.end_date ? ` → ${fmtDateFR(ev.end_date)}` : ''}
|
|
{ev.impact_score > 0.7 && <AlertCircle className="w-3 h-3 text-orange-400" />}
|
|
</div>
|
|
{ev.description && <p className="text-xs text-slate-600 mt-1 line-clamp-1">{ev.description}</p>}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── NarrativeCard ─────────────────────────────────────────────────────────────
|
|
|
|
function NarrativeCard({ narrative, loading, onLoad, instrument }: {
|
|
narrative: string; loading: boolean; onLoad: () => void; instrument: InstrumentConfig
|
|
}) {
|
|
return (
|
|
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex items-center gap-2">
|
|
<Sparkles className="w-4 h-4 text-violet-400" />
|
|
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Narration IA — {instrument.name}</span>
|
|
</div>
|
|
<button onClick={onLoad} disabled={loading}
|
|
className="flex items-center gap-1.5 text-xs px-3 py-1.5 bg-violet-800/30 hover:bg-violet-800/50 text-violet-300 border border-violet-700/40 rounded-lg transition-colors disabled:opacity-40"
|
|
>
|
|
{loading ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Sparkles className="w-3 h-3" />}
|
|
{loading ? 'Génération...' : narrative ? 'Actualiser' : 'Générer'}
|
|
</button>
|
|
</div>
|
|
{narrative ? (
|
|
<p className="text-sm text-slate-300 leading-relaxed">{narrative}</p>
|
|
) : loading ? (
|
|
<div className="space-y-2">
|
|
{[95, 88, 70].map(w => <div key={w} className="h-3 bg-slate-800 rounded animate-pulse" style={{ width: `${w}%` }} />)}
|
|
</div>
|
|
) : (
|
|
<div className="text-sm text-slate-600 italic">
|
|
Cliquez "Générer" pour une analyse IA pour {instrument.name}.
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── DriversPanel — édition inline ────────────────────────────────────────────
|
|
|
|
function DriversPanel({ instrumentId, drivers, onSave, onClose }: {
|
|
instrumentId: string
|
|
drivers: Driver[]
|
|
onSave: (drivers: Driver[]) => void
|
|
onClose: () => void
|
|
}) {
|
|
const [local, setLocal] = useState<Driver[]>(() => drivers.map(d => ({ ...d, keywords: [...(d.keywords ?? [])] })))
|
|
const [saving, setSaving] = useState(false)
|
|
const [error, setError] = useState('')
|
|
|
|
function updateDriver(i: number, field: keyof Driver, val: any) {
|
|
setLocal(prev => prev.map((d, idx) => idx === i ? { ...d, [field]: val } : d))
|
|
}
|
|
|
|
function updateKeywords(i: number, raw: string) {
|
|
const kws = raw.split(',').map(s => s.trim()).filter(Boolean)
|
|
updateDriver(i, 'keywords', kws)
|
|
}
|
|
|
|
function addDriver() {
|
|
setLocal(prev => [...prev, {
|
|
key: `driver_${Date.now()}`, label: 'Nouveau driver',
|
|
weight: 0.5, keywords: [], type: 'fundamental',
|
|
}])
|
|
}
|
|
|
|
function removeDriver(i: number) {
|
|
setLocal(prev => prev.filter((_, idx) => idx !== i))
|
|
}
|
|
|
|
async function save() {
|
|
setSaving(true); setError('')
|
|
try {
|
|
await api.put(`/instruments/${instrumentId}/drivers`, { drivers: local })
|
|
onSave(local)
|
|
onClose()
|
|
} catch (e: any) {
|
|
setError(e?.response?.data?.detail ?? 'Erreur de sauvegarde')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="rounded-xl border border-blue-700/40 bg-dark-800/80 p-4 space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-semibold text-white">Éditer les drivers — {instrumentId}</span>
|
|
<div className="flex items-center gap-2">
|
|
<button onClick={addDriver}
|
|
className="flex items-center gap-1 text-xs px-2.5 py-1 bg-blue-800/30 hover:bg-blue-800/50 text-blue-300 border border-blue-700/40 rounded-lg transition-colors">
|
|
<Plus className="w-3 h-3" /> Ajouter
|
|
</button>
|
|
<button onClick={save} disabled={saving}
|
|
className="flex items-center gap-1.5 text-xs px-3 py-1 bg-emerald-800/30 hover:bg-emerald-800/50 text-emerald-300 border border-emerald-700/40 rounded-lg transition-colors disabled:opacity-40">
|
|
{saving ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Save className="w-3 h-3" />}
|
|
{saving ? 'Sauvegarde...' : 'Sauvegarder'}
|
|
</button>
|
|
<button onClick={onClose} className="p-1 text-slate-500 hover:text-white transition-colors">
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{error && <div className="text-xs text-red-400 bg-red-900/20 border border-red-700/30 rounded-lg px-3 py-1.5">{error}</div>}
|
|
|
|
<div className="space-y-2 max-h-[420px] overflow-y-auto pr-1">
|
|
{local.map((d, i) => (
|
|
<div key={i} className="rounded-lg border border-slate-700/30 bg-dark-700/40 p-3 space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
className="flex-1 text-xs bg-dark-600 border border-slate-700/40 rounded px-2 py-1 text-white placeholder-slate-600"
|
|
placeholder="Label"
|
|
value={d.label}
|
|
onChange={e => updateDriver(i, 'label', e.target.value)}
|
|
/>
|
|
{/* Type selector */}
|
|
<select
|
|
className="text-xs bg-dark-600 border border-slate-700/40 rounded px-1.5 py-1 text-slate-300"
|
|
value={d.type ?? 'fundamental'}
|
|
onChange={e => updateDriver(i, 'type', e.target.value)}
|
|
>
|
|
{DRIVER_TYPES.map(t => (
|
|
<option key={t} value={t}>{DRIVER_TYPE_CFG[t]?.short ?? t}</option>
|
|
))}
|
|
</select>
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-xs text-slate-500">Poids</span>
|
|
<input
|
|
type="number" min="0" max="1" step="0.05"
|
|
className="w-16 text-xs bg-dark-600 border border-slate-700/40 rounded px-2 py-1 text-white"
|
|
value={d.weight}
|
|
onChange={e => updateDriver(i, 'weight', parseFloat(e.target.value))}
|
|
/>
|
|
</div>
|
|
<button onClick={() => removeDriver(i)} className="p-1 text-slate-600 hover:text-red-400 transition-colors">
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-slate-600 mb-0.5 block">Mots-clés (séparés par virgule)</label>
|
|
<input
|
|
className="w-full text-xs bg-dark-600 border border-slate-700/40 rounded px-2 py-1 text-slate-300 placeholder-slate-600"
|
|
placeholder="Fed, FOMC, pivot, QE, taux directeur..."
|
|
value={(d.keywords ?? []).join(', ')}
|
|
onChange={e => updateKeywords(i, e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Main page ─────────────────────────────────────────────────────────────────
|
|
|
|
const PERIODS = [
|
|
{ key: '3mo', label: '3M' }, { key: '6mo', label: '6M' },
|
|
{ key: '1y', label: '1Y' }, { key: '2y', label: '2Y' }, { key: '5y', label: '5Y' },
|
|
]
|
|
|
|
export default function InstrumentDashboard() {
|
|
const { id = 'SPY' } = useParams<{ id: string }>()
|
|
const navigate = useNavigate()
|
|
const [period, setPeriod] = useState('1y')
|
|
const [instruments, setInstruments] = useState<InstrumentConfig[]>([])
|
|
const [snapshot, setSnapshot] = useState<Snapshot | null>(null)
|
|
const [narrative, setNarrative] = useState('')
|
|
const [loading, setLoading] = useState(false)
|
|
const [loadingNarr, setLoadingNarr] = useState(false)
|
|
const [selectorOpen, setSelectorOpen] = useState(false)
|
|
const [selectedDate, setSelectedDate] = useState<string | null>(null)
|
|
const [editDrivers, setEditDrivers] = useState(false)
|
|
const [localDrivers, setLocalDrivers] = useState<Driver[] | null>(null)
|
|
|
|
const instrumentId = id.toUpperCase()
|
|
|
|
useEffect(() => {
|
|
api.get('/instruments').then(r => setInstruments(r.data)).catch(() => {})
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
setLoading(true)
|
|
setSnapshot(null)
|
|
setNarrative('')
|
|
setSelectedDate(null)
|
|
setLocalDrivers(null)
|
|
setEditDrivers(false)
|
|
api.get(`/instruments/${instrumentId}/snapshot?period=${period}`)
|
|
.then(r => {
|
|
setSnapshot(r.data)
|
|
const pd: PriceCandle[] = r.data.price_data
|
|
if (pd?.length) setSelectedDate(pd[pd.length - 1].time)
|
|
})
|
|
.catch(() => {})
|
|
.finally(() => setLoading(false))
|
|
}, [instrumentId, period])
|
|
|
|
const loadNarrative = useCallback(() => {
|
|
setLoadingNarr(true)
|
|
api.post(`/instruments/${instrumentId}/narrative`)
|
|
.then(r => setNarrative(r.data.narrative))
|
|
.catch(() => {})
|
|
.finally(() => setLoadingNarr(false))
|
|
}, [instrumentId])
|
|
|
|
const handleDateHover = useCallback((date: string | null) => {
|
|
if (date) setSelectedDate(date)
|
|
}, [])
|
|
|
|
const { priceMap, indMap, sortedDates, dateIndex } = useMemo(() => {
|
|
if (!snapshot) return { priceMap: {} as Record<string, PriceCandle>, indMap: {} as Record<string, Record<string, number>>, sortedDates: [] as string[], dateIndex: {} as Record<string, number> }
|
|
const priceMap: Record<string, PriceCandle> = {}
|
|
const indMap: Record<string, Record<string, number>> = {}
|
|
const sortedDates: string[] = []
|
|
const dateIndex: Record<string, number> = {}
|
|
for (const c of snapshot.price_data) { priceMap[c.time] = c; sortedDates.push(c.time) }
|
|
sortedDates.forEach((d, i) => { dateIndex[d] = i })
|
|
for (const [key, pts] of Object.entries(snapshot.indicators)) {
|
|
for (const pt of pts) { if (!indMap[pt.time]) indMap[pt.time] = {}; indMap[pt.time][key] = pt.value }
|
|
}
|
|
return { priceMap, indMap, sortedDates, dateIndex }
|
|
}, [snapshot])
|
|
|
|
const effectiveDate = useMemo(() => {
|
|
if (selectedDate && dateIndex[selectedDate] !== undefined) return selectedDate
|
|
return sortedDates[sortedDates.length - 1] ?? null
|
|
}, [selectedDate, sortedDates, dateIndex])
|
|
|
|
const dateTrend = useMemo((): TrendMetrics | null => {
|
|
if (!effectiveDate || !snapshot) return null
|
|
const candle = priceMap[effectiveDate]; if (!candle) return null
|
|
const idx = dateIndex[effectiveDate], price = candle.close, ind = indMap[effectiveDate] ?? {}
|
|
const ma50 = ind.ma50, ma200 = ind.ma200, atr14 = ind.atr14 ?? 0
|
|
const d5 = idx >= 5 ? sortedDates[idx - 5] : null
|
|
const d20 = idx >= 20 ? sortedDates[idx - 20] : null
|
|
const d21 = idx >= 21 ? sortedDates[idx - 21] : null
|
|
const d63 = idx >= 63 ? sortedDates[idx - 63] : null
|
|
let atrSum = 0, atrCnt = 0
|
|
for (let i = Math.max(0, idx - 65); i <= idx; i++) { const v = indMap[sortedDates[i]]?.atr14; if (v) { atrSum += v; atrCnt++ } }
|
|
let high52 = candle.high, low52 = candle.low
|
|
for (let i = Math.max(0, idx - 252); i <= idx; i++) { const c = priceMap[sortedDates[i]]; if (c) { if (c.high > high52) high52 = c.high; if (c.low < low52) low52 = c.low } }
|
|
return {
|
|
ma50_slope_5d: pctN(ma50, d5 ? indMap[d5]?.ma50 : undefined),
|
|
ma200_slope_20d: pctN(ma200, d20 ? indMap[d20]?.ma200 : undefined),
|
|
rsi14_current: ind.rsi14 ?? 50,
|
|
atr14_current: atr14,
|
|
atr_vs_3m_avg_pct: atrCnt > 0 && atr14 ? (atr14 / (atrSum / atrCnt)) * 100 : 100,
|
|
momentum_1m_pct: d21 && priceMap[d21] ? pctN(price, priceMap[d21].close) : 0,
|
|
momentum_3m_pct: d63 && priceMap[d63] ? pctN(price, priceMap[d63].close) : 0,
|
|
dist_ma50_pct: ma50 ? pctN(price, ma50) : null,
|
|
dist_ma200_pct: ma200 ? pctN(price, ma200) : null,
|
|
current_price: price, high_52w: high52, low_52w: low52,
|
|
}
|
|
}, [effectiveDate, priceMap, indMap, sortedDates, dateIndex, snapshot])
|
|
|
|
const dateSignals = useMemo((): RegimeSignals | null => {
|
|
if (!effectiveDate || !snapshot) return null
|
|
const candle = priceMap[effectiveDate]; if (!candle) return null
|
|
const idx = dateIndex[effectiveDate], price = candle.close, ind = indMap[effectiveDate] ?? {}
|
|
const ma50 = ind.ma50, ma200 = ind.ma200, atr14 = ind.atr14
|
|
const d10 = idx >= 10 ? sortedDates[idx - 10] : null
|
|
const d20 = idx >= 20 ? sortedDates[idx - 20] : null
|
|
let atrSum = 0, atrCnt = 0
|
|
for (let i = Math.max(0, idx - 65); i <= idx; i++) { const v = indMap[sortedDates[i]]?.atr14; if (v) { atrSum += v; atrCnt++ } }
|
|
return {
|
|
ma50_above_ma200: ma50 !== undefined && ma200 !== undefined ? ma50 > ma200 : null,
|
|
ma50_slope_pct: pctN(ma50, d10 ? indMap[d10]?.ma50 : undefined),
|
|
ma200_slope_pct: pctN(ma200, d10 ? indMap[d10]?.ma200 : undefined),
|
|
momentum_20d_pct: d20 && priceMap[d20] ? pctN(price, priceMap[d20].close) : 0,
|
|
dist_ma200_pct: ma200 ? pctN(price, ma200) : 0,
|
|
vol_ratio_pct: atrCnt > 0 && atr14 ? (atr14 / (atrSum / atrCnt)) * 100 : 0,
|
|
}
|
|
}, [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)
|
|
|
|
const selected = instruments.find(i => i.id === instrumentId)
|
|
const isLastDate = effectiveDate === sortedDates[sortedDates.length - 1]
|
|
const dateLabel = effectiveDate ? fmtDateFR(effectiveDate) : '—'
|
|
const displayPrice = dateTrend?.current_price ?? snapshot?.current_price
|
|
|
|
const activeDrivers = localDrivers ?? snapshot?.instrument?.drivers ?? []
|
|
|
|
return (
|
|
<div className="p-6 max-w-screen-xl mx-auto space-y-4">
|
|
|
|
{/* ── Header ── */}
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<div className="relative">
|
|
<button onClick={() => setSelectorOpen(s => !s)}
|
|
className="flex items-center gap-2 px-4 py-2 bg-dark-800 border border-slate-700/40 rounded-xl text-white hover:bg-dark-700 transition-colors"
|
|
>
|
|
<BarChart2 className="w-4 h-4 text-blue-400" />
|
|
<span className="font-semibold">{selected?.name ?? instrumentId}</span>
|
|
<span className="text-xs text-slate-500">{selected?.id}</span>
|
|
<ChevronDown className="w-3.5 h-3.5 text-slate-500" />
|
|
</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>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{selected && (
|
|
<span className="text-xs px-2 py-1 bg-dark-700 border border-slate-700/30 rounded-lg text-slate-400">
|
|
{CATEGORY_LABELS[selected.category] ?? selected.category}
|
|
</span>
|
|
)}
|
|
|
|
{displayPrice !== undefined && (
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-xl font-bold text-white">
|
|
{displayPrice.toLocaleString('fr-FR', { maximumFractionDigits: 4 })}
|
|
</span>
|
|
{isLastDate && snapshot && (
|
|
<span className={clsx('text-sm font-semibold', (snapshot.change_pct ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
|
{(snapshot.change_pct ?? 0) >= 0 ? '+' : ''}{snapshot.change_abs?.toFixed(2)} ({(snapshot.change_pct ?? 0) >= 0 ? '+' : ''}{snapshot.change_pct?.toFixed(2)}%)
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="ml-auto flex items-center gap-2">
|
|
{snapshot && (
|
|
<button onClick={() => setEditDrivers(e => !e)}
|
|
className={clsx('flex items-center gap-1.5 text-xs px-3 py-1.5 border rounded-lg transition-colors',
|
|
editDrivers
|
|
? 'bg-blue-800/40 text-blue-300 border-blue-700/40'
|
|
: 'bg-dark-800 text-slate-400 border-slate-700/40 hover:text-white hover:bg-dark-700'
|
|
)}
|
|
>
|
|
<Pencil className="w-3 h-3" />
|
|
Drivers
|
|
</button>
|
|
)}
|
|
<div className="flex items-center gap-1 bg-dark-800 border border-slate-700/40 rounded-xl p-1">
|
|
{PERIODS.map(p => (
|
|
<button key={p.key} onClick={() => setPeriod(p.key)}
|
|
className={clsx('px-3 py-1 text-xs rounded-lg transition-colors',
|
|
period === p.key ? 'bg-blue-700/50 text-blue-300' : 'text-slate-400 hover:text-white hover:bg-dark-700'
|
|
)}
|
|
>
|
|
{p.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{selected && <p className="w-full text-xs text-slate-500">{selected.description}</p>}
|
|
</div>
|
|
|
|
{/* ── Loading ── */}
|
|
{loading && (
|
|
<div className="space-y-4">
|
|
<div className="bg-dark-800/60 rounded-xl border border-slate-700/40 animate-pulse" style={{ height: 420 }} />
|
|
<div className="bg-dark-800/60 rounded-xl border border-slate-700/40 animate-pulse h-20" />
|
|
<div className="grid grid-cols-3 gap-4">
|
|
{[1, 2, 3].map(i => <div key={i} className="bg-dark-800/60 rounded-xl border border-slate-700/40 animate-pulse h-72" />)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Content ── */}
|
|
{!loading && snapshot && (
|
|
<>
|
|
<InstrumentChart
|
|
priceData={snapshot.price_data}
|
|
indicators={snapshot.indicators}
|
|
events={snapshot.events}
|
|
height={420}
|
|
onDateHover={handleDateHover}
|
|
/>
|
|
|
|
{/* Date badge */}
|
|
<div className="flex items-center gap-2">
|
|
<div className={clsx(
|
|
'flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border',
|
|
isLastDate
|
|
? 'bg-slate-800 border-slate-700/40 text-slate-400'
|
|
: 'bg-blue-900/40 border-blue-700/50 text-blue-300'
|
|
)}>
|
|
<Calendar className="w-3 h-3" />
|
|
Snapshot au {dateLabel}
|
|
{!isLastDate && <span className="text-blue-500 ml-1">← survol du graphe</span>}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 3-column grid */}
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<RegimeCard
|
|
regime={snapshot.regime}
|
|
macroRegime={snapshot.macro_regime ?? null}
|
|
signalsAt={dateSignals}
|
|
dateLabel={dateLabel}
|
|
/>
|
|
<TrendCard
|
|
trend={dateTrend ?? snapshot.trend}
|
|
dateLabel={dateLabel}
|
|
/>
|
|
<EventsCard
|
|
events={snapshot.events}
|
|
selectedDate={effectiveDate}
|
|
/>
|
|
</div>
|
|
|
|
{editDrivers && (
|
|
<DriversPanel
|
|
instrumentId={instrumentId}
|
|
drivers={activeDrivers}
|
|
onSave={saved => { setLocalDrivers(saved); setEditDrivers(false) }}
|
|
onClose={() => setEditDrivers(false)}
|
|
/>
|
|
)}
|
|
|
|
<NarrativeCard
|
|
narrative={narrative}
|
|
loading={loadingNarr}
|
|
onLoad={loadNarrative}
|
|
instrument={snapshot.instrument}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{!loading && !snapshot && (
|
|
<div className="text-center py-20 text-slate-500">
|
|
<BarChart2 className="w-8 h-8 mx-auto mb-3 opacity-30" />
|
|
<p>Aucune donnée disponible pour {instrumentId}</p>
|
|
</div>
|
|
)}
|
|
|
|
{selectorOpen && <div className="fixed inset-0 z-40" onClick={() => setSelectorOpen(false)} />}
|
|
</div>
|
|
)
|
|
}
|