Files
OpenFin/frontend/src/pages/InstrumentDashboard.tsx
2026-06-30 17:23:51 +02:00

1700 lines
78 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import {
Sparkles, RefreshCw, ChevronDown, TrendingUp, TrendingDown,
Minus, BarChart2, Clock, Calendar, AlertCircle,
} from 'lucide-react'
import axios from 'axios'
import clsx from 'clsx'
import InstrumentChart, { TheoPoint } from '../components/InstrumentChart'
const api = axios.create({ baseURL: '/api' })
// Stable empty array — prevents InstrumentChart useEffect from re-running on every render
const NO_CHART_EVENTS: never[] = []
// ── Types ─────────────────────────────────────────────────────────────────────
interface CausalTemplate {
id: number; name: string; category: string; sub_type: string
instruments: string[]; description: string
graph_json: {
nodes: { id: string; type: string; label: string; instrument?: string }[]
edges: { from: string; to: string; lag_days?: number; lag_min?: number; sign?: string }[]
}
}
interface InstrumentConfig {
id: string; name: string; yf_ticker: string; category: string; currency: string
description: string
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 {
id?: number; template_id?: number | null
analyzed_instruments?: string | null // comma-sep: "EURUSD,SP500" — set when causal analysis exists
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
prediction_json?: string | null // JSON: {node_id: pips} from causal_event_analyses
actual_json?: string | null // JSON: {instrument: pips} from causal_event_analyses
activation_score?: number | null // stored directional accuracy from causal_event_analyses
}
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 GaugeValue {
id: string; label: string; value: number | null; change_pct: number | null
unit: string; bloc: string; note?: string
}
interface MacroGaugeSnap {
snapshot_date: string
dominant: string
regime_scores: Record<string, number>
gauges: Record<string, GaugeValue>
}
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
}
function isActiveAt(ev: SnapshotEvent, selectedDate: string | null): boolean {
if (!selectedDate) return false
if (ev.date > selectedDate) return false
if (ev.end_date) return ev.end_date >= selectedDate
const diffDays = (new Date(selectedDate).getTime() - new Date(ev.date).getTime()) / 86400000
return diffDays <= 30
}
// ── Causal graph config ───────────────────────────────────────────────────────
// Maps snapshot event category → causal template category
// market_events.category → causal_graph_templates.category
// event_calendar & fundamental cover both US and EU; geopolitical/report/sentiment map 1-to-1
const EVENT_TO_CAUSAL_CAT: Record<string, string[]> = {
event_calendar: ['macro_us', 'macro_eu'],
fundamental: ['macro_us', 'macro_eu'],
geopolitical: ['geopolitical'],
report: ['report'],
sentiment: ['sentiment'],
commodity: ['commodity'],
technical: [],
}
// Maps instrument dashboard category → causal lab instrument keys
const CAT_TO_CAUSAL_INST: Record<string, string[]> = {
equity_index: ['SP500'],
equity_intl: ['SP500'],
metal: ['XAUUSD'],
energy: ['BRENT'],
fx: ['EURUSD'],
bond: ['US10Y', 'EU10Y'],
credit: ['US10Y'],
volatility: ['SP500'],
stock: ['SP500'],
crypto: [],
}
// Keyed by market_event.category — same palette as the ★ stars on the chart
const EV_CAT_TW: Record<string, string> = {
event_calendar: 'text-amber-400 border-amber-700/40 bg-amber-900/20',
geopolitical: 'text-red-400 border-red-700/40 bg-red-900/20',
fundamental: 'text-emerald-400 border-emerald-700/40 bg-emerald-900/20',
report: 'text-blue-400 border-blue-700/40 bg-blue-900/20',
sentiment: 'text-violet-400 border-violet-700/40 bg-violet-900/20',
technical: 'text-cyan-400 border-cyan-700/40 bg-cyan-900/20',
}
// ── 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>
)
}
// ── Macro gauge helpers ───────────────────────────────────────────────────────
const SCENARIO_META: Record<string, { label: string; emoji: string; color: string }> = {
goldilocks: { label: 'Goldilocks', emoji: '🟢', color: '#10b981' },
desinflation: { label: 'Désinflation', emoji: '🔵', color: '#3b82f6' },
soft_landing: { label: 'Soft Landing', emoji: '🔷', color: '#06b6d4' },
reflation: { label: 'Reflation', emoji: '🟠', color: '#f97316' },
stagflation: { label: 'Stagflation', emoji: '🟡', color: '#f59e0b' },
inflation_shock: { label: 'Choc Inflationniste', emoji: '🔥', color: '#dc2626' },
recession: { label: 'Récession', emoji: '🔴', color: '#ef4444' },
crise_liquidite: { label: 'Crise de liquidité', emoji: '🟣', color: '#7c3aed' },
incertain: { label: 'Incertain', emoji: '⬜', color: '#64748b' },
}
function snapToMacroRegime(snap: MacroGaugeSnap): MacroRegime {
const scores = snap.regime_scores ?? {}
const meta = SCENARIO_META[snap.dominant] ?? SCENARIO_META.incertain
const ranked = Object.entries(scores)
.sort(([, a], [, b]) => b - a)
.map(([k]) => k)
return {
dominant: snap.dominant,
label: meta.label,
color: meta.color,
emoji: meta.emoji,
scores,
ranked,
asset_bias: {},
}
}
const BLOC_LABELS: Record<string, string> = {
liquidite: 'Liquidité / Taux',
credit: 'Crédit / Vol',
energie: 'Énergie',
metaux: 'Métaux',
croissance: 'Croissance US',
secteurs: 'Secteurs',
volatilite: 'Volatilité surf.',
global: 'Global / EM',
forex_ro: 'Forex Risk-Off',
derive: 'Dérivés',
}
function MacroGaugePanel({ snap, dateLabel }: { snap: MacroGaugeSnap; dateLabel: string }) {
const byBloc: Record<string, GaugeValue[]> = {}
for (const g of Object.values(snap.gauges)) {
if (g.value === null && g.change_pct === null) continue
const b = g.bloc ?? 'derive'
if (!byBloc[b]) byBloc[b] = []
byBloc[b].push(g)
}
const meta = SCENARIO_META[snap.dominant] ?? SCENARIO_META.incertain
return (
<div className="bg-dark-800/60 rounded-xl border border-slate-700/30 p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-base">{meta.emoji}</span>
<span className="text-sm font-semibold text-white">{meta.label}</span>
<span className="text-xs text-slate-500"> contexte macro au {dateLabel}</span>
</div>
<span className="text-xs text-slate-600">{snap.snapshot_date}</span>
</div>
{/* Regime scores mini bar */}
<div className="flex gap-1 h-1.5">
{Object.entries(snap.regime_scores)
.sort(([,a],[,b]) => b - a)
.slice(0, 6)
.map(([k, v]) => (
<div key={k} title={`${SCENARIO_META[k]?.label ?? k}: ${(v*100).toFixed(0)}%`}
style={{ width: `${v*100}%`, background: SCENARIO_META[k]?.color ?? '#64748b' }}
className="rounded-full transition-all"
/>
))}
</div>
{/* Gauges by bloc */}
<div className="grid grid-cols-2 gap-x-6 gap-y-3">
{Object.entries(byBloc).map(([bloc, gauges]) => (
<div key={bloc}>
<div className="text-xs text-slate-600 uppercase tracking-wide mb-1">{BLOC_LABELS[bloc] ?? bloc}</div>
<div className="space-y-0.5">
{gauges.map(g => (
<div key={g.id} className="flex items-center justify-between text-xs">
<span className="text-slate-400 truncate max-w-[120px]" title={g.label}>{g.label}</span>
<div className="flex items-center gap-2">
{g.value !== null && (
<span className="text-white font-mono">
{g.unit === '%' ? g.value.toFixed(2) + '%'
: g.unit === 'pts' ? g.value.toFixed(1)
: g.unit === 'ratio' ? g.value.toFixed(3)
: g.value.toFixed(2)}
</span>
)}
{g.change_pct !== null && g.change_pct !== undefined && (
<span className={clsx('font-mono', g.change_pct >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{g.change_pct >= 0 ? '+' : ''}{g.change_pct.toFixed(1)}%
</span>
)}
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
)
}
// ── Shared: trading-day-index x-coordinate ────────────────────────────────────
// Uses the same time scale as LightweightCharts (trading days, weekends skipped).
// For a date D, snaps to the nearest available trading day index.
function makeTdToX(priceData: PriceCandle[], width: number): (d: string) => number {
const dates = priceData.map(c => c.time)
const N = dates.length
if (N < 2) return () => 0
function snap(d: string): number {
if (d <= dates[0]) return 0
if (d >= dates[N - 1]) return N - 1
let lo = 0, hi = N - 1
while (lo < hi) {
const mid = (lo + hi) >> 1
if (dates[mid] < d) lo = mid + 1
else hi = mid
}
return lo
}
return (d: string) => (snap(d) / (N - 1)) * width
}
// ── Comprehension scoring ─────────────────────────────────────────────────────
function templateAbsorptionDays(tmpl: CausalTemplate): number {
const maxLag = Math.max(0, ...tmpl.graph_json.edges.map(e => e.lag_days || 0))
return maxLag > 0 ? maxLag : 30
}
/** Score 0-100 measuring how well the graph predicted actual pips for an instrument. */
function comprehensionScore(ev: SnapshotEvent, tmpl: CausalTemplate, instrument: string): number | null {
// Prefer the stored activation_score from causal_event_analyses (consistent with MarketEvents panel)
if (ev.activation_score != null) return Math.round(ev.activation_score * 100)
if (!ev.prediction_json || !ev.actual_json) return null
try {
const preds: Record<string, number> = JSON.parse(ev.prediction_json)
const actuals: Record<string, number> = JSON.parse(ev.actual_json)
const actual = actuals[instrument] ?? actuals[instrument.toUpperCase()] ?? actuals[instrument.toLowerCase()]
if (actual == null) return null
const outputNode = tmpl.graph_json.nodes.find(n =>
(n.type === 'market_asset' || n.type === 'output') && n.instrument?.toUpperCase() === instrument.toUpperCase()
)
const predicted = outputNode ? (preds[outputNode.id] ?? null) : null
if (predicted == null) return null
if (predicted === 0 && actual === 0) return 100
if (predicted === 0) return 40
const sameDir = predicted * actual > 0
if (!sameDir) return 0
const ratio = Math.min(Math.abs(actual), Math.abs(predicted)) / Math.max(Math.abs(actual), Math.abs(predicted))
return Math.round(50 + ratio * 50)
} catch { return null }
}
// ── CausalFrise ───────────────────────────────────────────────────────────────
const FRISE_LANE_H = 24 // px par lane
const FRISE_CHIP_H = 18 // hauteur d'un chip
const FRISE_CHIP_PAD = (FRISE_LANE_H - FRISE_CHIP_H) / 2
const FRISE_AXIS_H = 20 // axe temporel en bas
const FRISE_MIN_W = 44 // largeur minimale d'un chip (en px)
const FRISE_POPUP_W = 220 // largeur du popup
function CausalFrise({
events, templates, priceData, selectedDate, causalInsts,
chartDateToX, chartCanvasLeft, chartReady, causalScores,
}: {
events: SnapshotEvent[]
templates: CausalTemplate[]
priceData: PriceCandle[]
selectedDate: string | null
causalInsts: string[]
chartDateToX?: (d: string) => number | null
chartCanvasLeft?: number
chartReady?: number
causalScores: Record<number, number | null>
}) {
const navigate = useNavigate()
const containerRef = useRef<HTMLDivElement>(null)
const [contWidth, setContWidth] = useState(400)
const [contLeft, setContLeft] = useState(0)
const [activeChip, setActiveChip] = useState<{
tmpl: CausalTemplate; ev: SnapshotEvent; chipX: number; chipY: number
} | null>(null)
useEffect(() => {
const el = containerRef.current; if (!el) return
const update = () => {
const rect = el.getBoundingClientRect()
setContWidth(rect.width)
setContLeft(rect.left)
}
update()
const obs = new ResizeObserver(update)
obs.observe(el)
return () => obs.disconnect()
}, [chartReady]) // re-measure when chart is ready (layout may have changed)
// Close popup on outside click (tiny delay avoids self-close)
useEffect(() => {
if (!activeChip) return
let tid: ReturnType<typeof setTimeout>
const close = () => setActiveChip(null)
tid = setTimeout(() => document.addEventListener('mousedown', close), 60)
return () => { clearTimeout(tid); document.removeEventListener('mousedown', close) }
}, [activeChip])
const linked = events.filter(ev => {
if (!ev.analyzed_instruments || ev.template_id == null) return false
const insts = ev.analyzed_instruments.split(',')
return causalInsts.some(ci => insts.includes(ci))
})
if (!priceData.length || !linked.length) {
return (
<div ref={containerRef} className="h-12 flex items-center justify-center text-xs text-slate-600 italic">
Aucun graphe causal lié pour cet instrument
</div>
)
}
const minDate = priceData[0].time
const maxDate = priceData[priceData.length - 1].time
const usable = Math.max(contWidth, 1)
// If chart coordinate bridge is available, use it for pixel-perfect alignment.
// chart.timeToCoordinate() gives x relative to chart canvas left edge.
// We subtract (chartCanvasLeft - contLeft) to convert to frise container coords.
// canvasOffset > 0 means chart canvas starts to the right of the frise container.
const canvasOffset = (chartCanvasLeft ?? 0) - contLeft
const tdToX: (d: string) => number = chartDateToX
? (d: string) => {
const x = chartDateToX(d)
if (x !== null) return x - canvasOffset
// Fallback for out-of-range dates: clamp to edges
return d <= minDate ? -canvasOffset : usable - canvasOffset
}
: makeTdToX(priceData, usable)
// Build chips (one per event × template)
type Chip = {
ev: SnapshotEvent; tmpl: CausalTemplate
x1: number; x2: number; w: number; active: boolean
}
const chips: Chip[] = linked
.filter(ev => ev.date <= maxDate)
.map(ev => {
const tmpl = templates.find(t => t.id === ev.template_id)
if (!tmpl) return null
const absorptionDays = templateAbsorptionDays(tmpl)
const endDate = ev.end_date ?? (() => {
const d = new Date(ev.date); d.setDate(d.getDate() + absorptionDays)
return d.toISOString().slice(0, 10)
})()
const x1 = tdToX(ev.date)
const raw = tdToX(endDate) - x1
const w = Math.max(raw, FRISE_MIN_W)
return { ev, tmpl, x1, x2: x1 + w, w, active: isActiveAt(ev, selectedDate) }
})
.filter((c): c is Chip => c !== null)
.sort((a, b) => a.x1 - b.x1)
// Greedy lane assignment — no overlap
const laneEnds: number[] = []
const placed = chips.map(chip => {
const GAP = 3
let lane = laneEnds.findIndex(end => end + GAP <= chip.x1)
if (lane === -1) { lane = laneEnds.length; laneEnds.push(0) }
laneEnds[lane] = chip.x2
return { ...chip, lane }
})
const numLanes = Math.max(laneEnds.length, 1)
const containerH = numLanes * FRISE_LANE_H + FRISE_AXIS_H + 4
// Month tick marks — skip some if too crowded
const ticks: { label: string; x: number }[] = []
{
const start = new Date(minDate)
let cur = new Date(start.getFullYear(), start.getMonth() + 1, 1)
while (cur.toISOString().slice(0, 10) <= maxDate) {
ticks.push({
label: cur.toLocaleDateString('fr-FR', {
month: 'short',
...(cur.getFullYear() !== start.getFullYear() ? { year: '2-digit' } : {}),
}),
x: tdToX(cur.toISOString().slice(0, 10)),
})
cur = new Date(cur.getFullYear(), cur.getMonth() + 1, 1)
}
}
// Keep 1 label every ~70px to avoid crowding
const tickStep = Math.max(1, Math.ceil(ticks.length / (usable / 70)))
const visTicks = ticks.filter((_, i) => i % tickStep === 0)
const crossX = selectedDate && selectedDate >= minDate && selectedDate <= maxDate
? tdToX(selectedDate) : null
return (
<div
ref={containerRef}
className="relative w-full select-none"
style={{ height: containerH }}
onClick={() => setActiveChip(null)}
>
{/* Month grid lines */}
{visTicks.map((t, i) => (
<div key={i} className="absolute top-0 pointer-events-none"
style={{ left: t.x, bottom: FRISE_AXIS_H, width: 1, background: 'rgba(100,116,139,0.12)' }} />
))}
{/* Crosshair */}
{crossX !== null && (
<div className="absolute top-0 bottom-0 pointer-events-none"
style={{ left: crossX, width: 1, background: 'rgba(96,165,250,0.3)' }} />
)}
{/* Chips */}
{placed.map(({ ev, tmpl, x1, w, lane, active }) => {
const catTw = EV_CAT_TW[ev.category] ?? 'text-slate-400 border-slate-700/30 bg-slate-800/40'
const chipY = lane * FRISE_LANE_H + FRISE_CHIP_PAD
const isOpen = activeChip?.ev.id === ev.id && activeChip?.tmpl.id === tmpl.id
const charsFit = Math.floor((w - 18) / 5.5)
const label = charsFit < 3 ? '' : tmpl.name.length > charsFit
? tmpl.name.slice(0, charsFit - 1) + '…' : tmpl.name
return (
<div
key={`${tmpl.id}-${ev.id ?? ev.date}`}
className={clsx(
'absolute rounded border cursor-pointer transition-all flex items-center gap-1 overflow-hidden px-1.5',
'text-[9px] font-medium leading-none',
active
? catTw + (isOpen ? ' ring-1 ring-current/40 brightness-125' : ' hover:brightness-110')
: clsx('text-slate-500 border-slate-700/40 bg-slate-800/25',
'hover:text-slate-300 hover:border-slate-600/60 hover:bg-slate-800/50'),
)}
style={{ left: x1, top: chipY, width: w, height: FRISE_CHIP_H }}
onMouseDown={e => e.stopPropagation()}
onClick={e => {
e.stopPropagation()
setActiveChip(isOpen ? null : { tmpl, ev, chipX: x1, chipY })
}}
title={`${tmpl.name} · ${ev.title}\n${fmtDateFR(ev.date)}${ev.end_date ? fmtDateFR(ev.end_date) : '+30j'}`}
>
<span className={clsx('w-1.5 h-1.5 rounded-full bg-current shrink-0', !active && 'opacity-40')} />
{label && <span className="truncate">{label}</span>}
</div>
)
})}
{/* Popup */}
{activeChip && (() => {
const { tmpl, ev, chipX, chipY } = activeChip
const popH = 108
const popTop = chipY - popH - 6 >= 0 ? chipY - popH - 6 : chipY + FRISE_CHIP_H + 4
const popLeft = Math.max(0, Math.min(chipX, contWidth - FRISE_POPUP_W - 4))
return (
<div
className="absolute z-30 bg-dark-800 border border-slate-600/40 rounded-lg shadow-2xl p-3"
style={{ left: popLeft, top: popTop, width: FRISE_POPUP_W }}
onMouseDown={e => e.stopPropagation()}
onClick={e => e.stopPropagation()}
>
<button
onClick={() => setActiveChip(null)}
className="absolute top-1.5 right-1.5 w-4 h-4 flex items-center justify-center text-slate-600 hover:text-slate-300 text-xs leading-none"
>×</button>
<div className="flex items-start gap-2 mb-1.5 pr-4">
<span className="flex-1 text-xs font-semibold text-slate-200 leading-tight line-clamp-2">{tmpl.name}</span>
<span className={clsx('text-[9px] px-1 py-0.5 rounded border shrink-0',
EV_CAT_TW[ev.category] ?? 'text-slate-500 border-slate-700/30'
)}>{ev.category}</span>
</div>
<div className="text-[10px] text-slate-400 truncate mb-1.5">{ev.title}</div>
<div className="flex items-center gap-3 mb-2 text-[9px] text-slate-600">
<span className="flex items-center gap-1">
<Clock className="w-2.5 h-2.5" />
{fmtDateFR(ev.date)} {ev.end_date ? fmtDateFR(ev.end_date) : `+${templateAbsorptionDays(tmpl)}j`}
</span>
{ev.impact_score != null && (
<span className="text-amber-600"> {ev.impact_score.toFixed(1)}</span>
)}
{ev.surprise_pct != null && (
<span className={ev.surprise_pct > 0 ? 'text-emerald-600' : 'text-red-600'}>
{ev.surprise_pct > 0 ? '+' : ''}{ev.surprise_pct.toFixed(1)}%
</span>
)}
</div>
{/* Précision — causalScores (API fraîche) avec fallback sur activation_score du snapshot */}
{(() => {
const snapScore = ev.activation_score != null ? Math.round(ev.activation_score * 100) : null
const s = ev.id != null ? (causalScores[ev.id] ?? snapScore) : snapScore
if (s == null) return null
const barCls = s >= 70 ? 'bg-emerald-500' : s >= 40 ? 'bg-amber-500' : 'bg-red-500'
const txtCls = s >= 70 ? 'text-emerald-400' : s >= 40 ? 'text-amber-400' : 'text-red-400'
return (
<div className="mb-2">
<div className="flex items-center justify-between text-[9px] text-slate-600 mb-0.5">
<span>Précision prédiction</span>
<span className={txtCls}>{s}%</span>
</div>
<div className="h-1 bg-slate-800 rounded-full overflow-hidden">
<div className={clsx('h-full rounded-full', barCls)} style={{ width: `${s}%` }} />
</div>
</div>
)
})()}
<div className="flex gap-1.5">
<button
onClick={() => { if (ev.id) navigate(`/market-events?event=${ev.id}`); setActiveChip(null) }}
className="flex-1 text-[10px] text-slate-400 hover:text-slate-200 bg-slate-700/40 hover:bg-slate-700/60 rounded px-2 py-1 transition-colors"
>Événement </button>
<button
onClick={() => { navigate(`/causal-lab?template=${tmpl.id}`); setActiveChip(null) }}
className="flex-1 text-[10px] text-violet-400 hover:text-violet-200 bg-violet-900/20 hover:bg-violet-900/40 rounded px-2 py-1 transition-colors"
>CausalLab </button>
</div>
</div>
)
})()}
{/* X-axis */}
<div className="absolute left-0 right-0 border-t border-slate-700/30" style={{ bottom: FRISE_AXIS_H }}>
{visTicks.map((t, i) => (
<span key={i} className="absolute text-[9px] text-slate-600 -translate-x-1/2 pt-0.5" style={{ left: t.x }}>
{t.label}
</span>
))}
</div>
</div>
)
}
// ── ExplanationScore ──────────────────────────────────────────────────────────
function ExplanationScore({
events, templates, causalInsts, onRefreshDone, onDebugResult, debugInfo, causalScores,
}: {
events: SnapshotEvent[]
templates: CausalTemplate[]
causalInsts: string[]
causalScores: Record<number, number | null>
onRefreshDone?: () => void
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onDebugResult?: (data: any) => void
// eslint-disable-next-line @typescript-eslint/no-explicit-any
debugInfo?: any
}) {
const [refreshing, setRefreshing] = useState(false)
const [refreshDone, setRefreshDone] = useState(false)
// For each linked event, compute the comprehension score and collect scored ones
const scored: number[] = []
const totalLinked: number[] = []
const debugRows: {id?: number; name: string; pred: string; actual: string; score: number | null; reason: string; surprisePct?: number | null; rawPred?: string}[] = []
for (const ev of events) {
if (!ev.template_id) continue
const tmpl = templates.find(t => t.id === ev.template_id)
if (!tmpl) continue
const inst = causalInsts.find(ci => tmpl.instruments.includes(ci))
if (!inst) continue
totalLinked.push(ev.id ?? 0)
// Detailed debug per event
const hasPred = !!(ev.prediction_json && ev.prediction_json !== '{}')
const hasActual = !!(ev.actual_json && ev.actual_json !== '{}')
let reason = ''
if (!hasPred) reason = 'prediction_json vide'
else if (!hasActual) reason = 'actual_json vide'
else {
try {
const preds = JSON.parse(ev.prediction_json!)
const actuals = JSON.parse(ev.actual_json!)
const outputNode = tmpl.graph_json.nodes.find(n =>
(n.type === 'market_asset' || n.type === 'output') && n.instrument?.toUpperCase() === inst.toUpperCase()
)
if (!outputNode) reason = `nœud output "${inst}" introuvable (types: ${[...new Set(tmpl.graph_json.nodes.map(n => n.type))].join(',')})`
else if (preds[outputNode.id] == null) reason = `preds["${outputNode.id}"] absent`
else if (actuals[inst] == null && actuals[inst.toUpperCase()] == null) reason = `actuals["${inst}"] absent — clés: ${Object.keys(actuals).join(',')}`
else reason = 'ok'
} catch (e) { reason = `parse error: ${e}` }
}
debugRows.push({
id: ev.id, name: ev.title?.slice(0, 30) ?? '?',
pred: hasPred ? '✓' : '✗',
actual: hasActual ? '✓' : '✗',
score: null, reason,
surprisePct: ev.surprise_pct ?? null,
rawPred: (ev.prediction_json ?? 'null').slice(0, 30),
})
const snapScore = ev.activation_score != null ? Math.round(ev.activation_score * 100) : null
const s = ev.id != null ? (causalScores[ev.id] ?? snapScore) : snapScore
if (debugRows.length > 0) debugRows[debugRows.length - 1].score = s
if (s != null) scored.push(s)
}
const globalScore = scored.length > 0
? Math.round(scored.reduce((a, b) => a + b, 0) / scored.length)
: null
const verdict = globalScore == null ? 'En attente'
: globalScore >= 70 ? 'Bien expliqué' : globalScore >= 40 ? 'Partiellement' : 'Peu lisible'
const badgeCls = globalScore == null
? 'text-slate-500 bg-slate-800/60 border-slate-700/30'
: globalScore >= 70
? 'text-emerald-400 bg-emerald-900/30 border-emerald-700/40'
: globalScore >= 40
? 'text-amber-400 bg-amber-900/30 border-amber-700/40'
: 'text-red-400 bg-red-900/20 border-red-700/30'
const barCls = globalScore == null ? 'bg-slate-600'
: globalScore >= 70 ? 'bg-emerald-500' : globalScore >= 40 ? 'bg-amber-500' : 'bg-red-500'
const handleRefresh = async () => {
onDebugResult?.(null)
setRefreshing(true)
try {
const res = await api.post('/causal-lab/auto-analyze/refresh')
const data = res.data
onDebugResult?.(data)
setRefreshDone(true)
onRefreshDone?.()
setTimeout(() => setRefreshDone(false), 8000)
} catch (err: any) {
onDebugResult?.({ error: String(err) })
} finally {
setRefreshing(false)
}
}
const needsRefresh = totalLinked.length > 0 && scored.length === 0 && !refreshDone
return (
<div className="space-y-2">
<div className="flex items-center gap-3 px-4 py-2.5 rounded-xl border border-slate-700/30 bg-dark-800/50">
<span className="text-xs text-slate-500 whitespace-nowrap">Note globale</span>
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div className={clsx('h-full rounded-full transition-all', barCls)} style={{ width: `${globalScore ?? 0}%` }} />
</div>
<span className={clsx('text-xs font-semibold px-2 py-1 rounded-lg border whitespace-nowrap', badgeCls)}>
{globalScore != null ? `${globalScore}% — ` : ''}{verdict}
</span>
<span className="text-[10px] text-slate-600 whitespace-nowrap">
{scored.length}/{totalLinked.length} graphes
</span>
{(needsRefresh || scored.length === 0) && !refreshDone && totalLinked.length > 0 && (
<button
onClick={handleRefresh}
disabled={refreshing}
className="text-[10px] text-violet-400 hover:text-violet-200 bg-violet-900/20 hover:bg-violet-900/40 border border-violet-800/40 rounded px-2 py-1 whitespace-nowrap disabled:opacity-50 transition-colors"
>
{refreshing ? '…' : 'Recalculer'}
</button>
)}
</div>
{/* Debug panel — score diagnostics per event */}
{debugRows.length > 0 && scored.length === 0 && (
<div className="rounded-lg border border-slate-700/30 bg-dark-900/60 p-3 text-[10px] font-mono">
<div className="text-slate-500 mb-1.5 uppercase tracking-wide">Diagnostic scores ({debugRows.length} graphes)</div>
{debugRows.map((r, i) => (
<div key={i} className="flex gap-2 py-0.5 border-b border-slate-800/40 last:border-0">
<span className={clsx('shrink-0 w-4', r.pred === '✓' ? 'text-emerald-600' : 'text-red-600')}>{r.pred}</span>
<span className={clsx('shrink-0 w-4', r.actual === '✓' ? 'text-emerald-600' : 'text-red-600')}>{r.actual}</span>
<span className="text-slate-400 w-32 shrink-0 truncate" title={r.name}>{r.name}</span>
<span className={clsx('flex-1', r.reason === 'ok' ? 'text-emerald-600' : 'text-amber-600')}>{r.reason}</span>
{r.surprisePct != null
? <span className="text-cyan-600 shrink-0">{r.surprisePct > 0 ? '+' : ''}{r.surprisePct.toFixed(1)}%</span>
: <span className="text-red-800 shrink-0">surp?</span>}
{r.score != null && <span className="text-violet-400 shrink-0">{r.score}%</span>}
<span className="text-slate-700 shrink-0 ml-1" title="raw prediction_json">{r.rawPred}</span>
</div>
))}
{debugInfo && (
<div className="mt-2 pt-2 border-t border-slate-800/40 text-slate-600">
{debugInfo.error
? <span className="text-red-500">Erreur: {debugInfo.error}</span>
: <span>Refresh: {debugInfo.refreshed} ok / {debugInfo.failed} err / {debugInfo.total} total</span>
}
{debugInfo.details?.map((d: any, i: number) => (
<div key={i} className="mt-1 border-t border-slate-800/40 pt-1">
<span className={d.result === 'ok' ? 'text-emerald-500' : 'text-red-500'}>{d.result}</span>
<span className="text-slate-500 ml-1">#{d.event_id} {(d.event_name ?? '').slice(0, 20)}</span>
{d.run_error && <span className="text-red-400 ml-1">err:{d.run_error.slice(0, 40)}</span>}
<div className="ml-2 text-slate-600">
surp:{d.surprise_pct != null ? Number(d.surprise_pct).toFixed(1)+'%' : `null (act=${d.actual_value} exp=${d.expected_value})`}
{' | '}inputs:{JSON.stringify(d.inputs ?? {})}
{' | '}pred_keys:[{(d.node_keys ?? []).join(',')}]
{' | '}actual:{JSON.stringify(d.actual_moves ?? {})}
</div>
</div>
))}
{/* DB state: show rows not in refresh (non-empty pred/actual but still 'vide' in snapshot) */}
{debugInfo.db_state?.length > 0 && (
<div className="mt-2 pt-1 border-t border-slate-800/40">
<div className="text-slate-600 mb-0.5">DB state ({debugInfo.db_state.length} analyses)</div>
{debugInfo.db_state.map((r: any, i: number) => (
<div key={i} className="text-slate-700">
#{r.event_id} {(r.event_name ?? '').slice(0, 18)}
<span className={r.pred_empty ? 'text-red-800' : 'text-emerald-800'}> pred:{r.pred_empty ? '∅' : `[${(r.pred_keys ?? []).join(',')}]`}</span>
<span className={r.actual_empty ? 'text-red-800' : 'text-emerald-800'}> act:{r.actual_empty ? '∅' : JSON.stringify(r.actual_keys)}</span>
</div>
))}
</div>
)}
</div>
)}
</div>
)}
</div>
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
const PERIODS = [
{ key: '5d', label: '5D' },
{ key: '1mo', label: '1M' },
{ key: '3mo', label: '3M' },
{ key: '6mo', label: '6M' },
{ key: '1y', label: '1Y' },
{ key: '2y', label: '2Y' },
{ key: '5y', label: '5Y' },
]
export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { instrumentIdProp?: string; isVisible?: boolean } = {}) {
const { id: paramId = localStorage.getItem('last_instrument') || 'EURUSD=X' } = useParams<{ id: string }>()
const navigate = useNavigate()
const [period, setPeriod] = useState('1y')
const [chartStyle, setChartStyle] = useState<'candles' | 'line'>('candles')
const [instruments, setInstruments] = useState<InstrumentConfig[]>([])
const [snapshot, setSnapshot] = useState<Snapshot | null>(null)
const [narrative, setNarrative] = useState('')
const [loading, setLoading] = useState(false)
const [refreshDebug, setRefreshDebug] = useState<any>(null)
const [loadingNarr, setLoadingNarr] = useState(false)
const [selectorOpen, setSelectorOpen] = useState(false)
const [selectedDate, setSelectedDate] = useState<string | null>(null)
const [tabUnder, setTabUnder] = useState<'counters' | 'analyse'>('counters')
const [templates, setTemplates] = useState<CausalTemplate[]>([])
const [causalScores, setCausalScores] = useState<Record<number, number | null>>({}) // eventId → activation_score*100
const [macroAtDate, setMacroAtDate] = useState<MacroGaugeSnap | null>(null)
const [theoryCurve, setTheoryCurve] = useState<TheoPoint[] | null>(null)
const [loadingTheory, setLoadingTheory] = useState(false)
const [showTheory, setShowTheory] = useState(false)
// Chart coordinate bridge — lets CausalFrise align with the chart's x-axis
const chartDateToXRef = useRef<((d: string) => number | null) | null>(null)
const chartCanvasLeftRef = useRef<number>(0)
const [chartReady, setChartReady] = useState(0) // bump to trigger frise re-render
// instrumentIdProp is set when mounted as a keep-alive tab (so URL params don't bleed across instances)
const instrumentId = (instrumentIdProp ?? paramId).toUpperCase()
useEffect(() => {
api.get('/instruments').then(r => setInstruments(r.data)).catch(() => {})
api.get('/causal-lab/templates').then(r => setTemplates(r.data)).catch(() => {})
}, [])
// Silent refresh when the tab becomes visible again (e.g. after analysis updated from MarketEvents)
const mountedRef = useRef(false)
useEffect(() => {
if (!mountedRef.current) { mountedRef.current = true; return }
if (isVisible) fetchSnapshotSilent()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isVisible])
const fetchSnapshot = useCallback(() => {
setLoading(true)
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])
// Silent re-fetch (no loading spinner) — used after Recalculer so ExplanationScore keeps its state
const fetchSnapshotSilent = useCallback(() => {
api.get(`/instruments/${instrumentId}/snapshot?period=${period}`)
.then(r => { setSnapshot(r.data) })
.catch(() => {})
}, [instrumentId, period])
// Fetch causal scores directly from the analyses API (not via snapshot) so the popup
// always shows the DB-stored activation_score, independent of snapshot staleness
const refreshCausalScores = useCallback((events: SnapshotEvent[]) => {
const ids = events.map(e => e.id).filter(Boolean) as number[]
if (!ids.length) return
Promise.all(ids.map(id =>
api.get(`/causal-lab/analyses?market_event_id=${id}&limit=1`)
.then(r => ({ id, score: r.data[0]?.activation_score ?? null }))
.catch(() => ({ id, score: null }))
)).then(results => {
setCausalScores(Object.fromEntries(results.map(r => [r.id, r.score != null ? Math.round(r.score * 100) : null])))
})
}, [])
useEffect(() => {
setSnapshot(null)
setNarrative('')
setSelectedDate(null)
setMacroAtDate(null)
setTheoryCurve(null)
setShowTheory(false)
chartDateToXRef.current = null
setChartReady(0)
fetchSnapshot()
}, [instrumentId, period])
// Refresh causal scores whenever the snapshot events change
useEffect(() => {
if (snapshot?.events?.length) refreshCausalScores(snapshot.events)
}, [snapshot?.events, refreshCausalScores])
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 handleChartReady = useCallback((fn: ((d: string) => number | null) | null, left: number) => {
chartDateToXRef.current = fn
chartCanvasLeftRef.current = left
setChartReady(k => k + 1)
}, [])
const toggleTheory = useCallback(() => {
if (showTheory) {
setShowTheory(false)
setTheoryCurve(null)
return
}
if (theoryCurve) { setShowTheory(true); return }
setLoadingTheory(true)
api.get(`/instruments/${instrumentId}/theoretical-curve?period=${period}`)
.then(r => { setTheoryCurve(r.data); setShowTheory(true) })
.catch(() => {})
.finally(() => setLoadingTheory(false))
}, [showTheory, theoryCurve, instrumentId, period])
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])
// Fetch macro gauge context when crosshair date changes (after sortedDates is available)
useEffect(() => {
if (!selectedDate || !sortedDates.length) return
const isLast = selectedDate === sortedDates[sortedDates.length - 1]
if (isLast) { setMacroAtDate(null); return }
api.get(`/market/macro-gauges/at?date=${selectedDate}`)
.then(r => { if (r.data?.snapshot_date) setMacroAtDate(r.data) })
.catch(() => {})
}, [selectedDate, sortedDates])
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
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">
{/* Chart style toggle */}
<div className="flex items-center bg-dark-800 border border-slate-700/40 rounded-xl p-1">
<button
onClick={() => setChartStyle('candles')}
title="Chandeliers"
className={clsx('p-1.5 rounded-lg transition-colors', chartStyle === 'candles' ? 'bg-blue-700/50 text-blue-300' : 'text-slate-500 hover:text-white hover:bg-dark-700')}
><BarChart2 className="w-3.5 h-3.5" /></button>
<button
onClick={() => setChartStyle('line')}
title="Courbe lisse"
className={clsx('p-1.5 rounded-lg transition-colors', chartStyle === 'line' ? 'bg-blue-700/50 text-blue-300' : 'text-slate-500 hover:text-white hover:bg-dark-700')}
><TrendingUp className="w-3.5 h-3.5" /></button>
</div>
{/* Period selector */}
<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={NO_CHART_EVENTS}
height={420}
chartType={chartStyle}
onDateHover={handleDateHover}
theoryCurve={showTheory && theoryCurve ? theoryCurve : undefined}
onChartReady={handleChartReady}
/>
{/* 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>
{/* ── Tabs sous la courbe ── */}
<div className="border-b border-slate-700/40 flex gap-1">
{([
{ key: 'counters', label: 'Compteurs' },
{ key: 'analyse', label: 'Analyse de la courbe' },
] as const).map(t => (
<button
key={t.key}
onClick={() => setTabUnder(t.key)}
className={clsx(
'px-4 py-2 text-xs font-medium border-b-2 -mb-px transition-colors',
tabUnder === t.key
? 'text-blue-300 border-blue-500'
: 'text-slate-500 border-transparent hover:text-slate-300 hover:border-slate-600'
)}
>
{t.label}
</button>
))}
</div>
{tabUnder === 'counters' && (
<>
<div className="grid grid-cols-3 gap-4">
<RegimeCard
regime={snapshot.regime}
macroRegime={macroAtDate ? snapToMacroRegime(macroAtDate) : (snapshot.macro_regime ?? null)}
signalsAt={dateSignals}
dateLabel={dateLabel}
/>
<TrendCard
trend={dateTrend ?? snapshot.trend}
dateLabel={dateLabel}
/>
<EventsCard
events={snapshot.events}
selectedDate={effectiveDate}
/>
</div>
{macroAtDate && <MacroGaugePanel snap={macroAtDate} dateLabel={dateLabel} />}
</>
)}
{tabUnder === 'analyse' && (() => {
const causalInsts = CAT_TO_CAUSAL_INST[selected?.category ?? ''] ?? []
const theoPt = effectiveDate && theoryCurve
? theoryCurve.find(p => p.date === effectiveDate) ?? null
: null
return (
<div className="space-y-3">
{/* Frise des graphes causaux (inclut l'event) */}
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4">
<div className="flex items-center gap-2 mb-3">
<BarChart2 className="w-4 h-4 text-violet-400" />
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Frise des graphes</span>
<button
onClick={toggleTheory}
disabled={loadingTheory}
className={`ml-auto px-2.5 py-1 rounded text-xs font-medium border transition-colors ${
showTheory
? 'bg-violet-700/60 border-violet-600/60 text-violet-200'
: 'bg-dark-900/40 border-slate-600/40 text-slate-400 hover:border-violet-600/40 hover:text-violet-300'
}`}
>
{loadingTheory ? '↻ Chargement…' : showTheory ? '⟁ Théorie ON' : '⟁ Courbe théorique'}
</button>
</div>
<CausalFrise
events={snapshot.events}
templates={templates}
priceData={snapshot.price_data}
selectedDate={effectiveDate}
causalInsts={causalInsts}
chartDateToX={chartDateToXRef.current ?? undefined}
chartCanvasLeft={chartCanvasLeftRef.current}
chartReady={chartReady}
causalScores={causalScores}
/>
</div>
{/* Décomposition théorique au curseur */}
{showTheory && theoPt && (
<div className="rounded-xl border border-violet-800/40 bg-violet-950/20 p-4">
<div className="flex items-center gap-2 mb-3">
<span className="text-violet-400 text-xs"></span>
<span className="text-xs font-semibold text-violet-300 uppercase tracking-wide">
Contributions théoriques {dateLabel}
</span>
<span className={`ml-auto text-sm font-mono font-bold ${theoPt.cumulative_pips >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{theoPt.cumulative_pips >= 0 ? '+' : ''}{theoPt.cumulative_pips} pips
</span>
</div>
{theoPt.contributions.length === 0 ? (
<p className="text-xs text-slate-500 italic">Aucun événement actif à cette date.</p>
) : (
<div className="space-y-2">
{theoPt.contributions.map((c, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
<div className="flex-1 min-w-0">
<span className="text-slate-300 font-medium truncate block">{c.event_name}</span>
<span className="text-slate-500">{c.template_name} · depuis {c.event_date} · {Math.round(c.decay_factor * 100)}% actif</span>
</div>
<span className={`font-mono font-semibold shrink-0 ${c.pips >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{c.pips >= 0 ? '+' : ''}{c.pips} pip
</span>
</div>
))}
</div>
)}
</div>
)}
{showTheory && !theoPt && theoryCurve && (
<div className="rounded-xl border border-violet-800/30 bg-violet-950/10 p-3 text-xs text-slate-500 text-center">
Aucune contribution théorique pour cette date
</div>
)}
{/* Note globale */}
<ExplanationScore
events={snapshot.events}
templates={templates}
causalInsts={causalInsts}
causalScores={causalScores}
onRefreshDone={fetchSnapshotSilent}
onDebugResult={setRefreshDebug}
debugInfo={refreshDebug}
/>
</div>
)
})()}
<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>
)
}