Files
OpenFin/frontend/src/pages/InstrumentDashboard.tsx
2026-06-28 23:43:52 +02:00

1460 lines
65 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: any[] }
}
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
}
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
}
// ── 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,
}: {
events: SnapshotEvent[]
templates: CausalTemplate[]
priceData: PriceCandle[]
selectedDate: string | null
causalInsts: string[]
chartDateToX?: (d: string) => number | null
chartCanvasLeft?: number
chartReady?: number
}) {
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 endDate = ev.end_date ?? (() => {
const d = new Date(ev.date); d.setDate(d.getDate() + 30)
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.5 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) : '+30j'}
</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>
<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, selectedDate, causalInsts,
}: {
events: SnapshotEvent[]
templates: CausalTemplate[]
selectedDate: string | null
causalInsts: string[]
}) {
// Templates from events that have a causal graph touching this instrument
const linkedEvents = events.filter(ev => {
if (!ev.template_id) return false
const tmpl = templates.find(t => t.id === ev.template_id)
return tmpl != null && causalInsts.some(ci => tmpl.instruments.includes(ci))
})
const linkedTemplateIds = new Set(linkedEvents.map(ev => ev.template_id!))
const periodTmpl = templates.filter(t => linkedTemplateIds.has(t.id))
const touchedActive = periodTmpl.filter(t =>
linkedEvents.filter(ev => ev.template_id === t.id).some(ev => isActiveAt(ev, selectedDate))
)
const score = periodTmpl.length > 0 ? (touchedActive.length / periodTmpl.length) * 100 : 0
const verdict = score >= 60 ? 'Bien expliqué' : score >= 30 ? 'Partiellement' : 'Peu lisible'
const badgeCls = score >= 60
? 'text-emerald-400 bg-emerald-900/30 border-emerald-700/40'
: score >= 30
? 'text-amber-400 bg-amber-900/30 border-amber-700/40'
: 'text-slate-400 bg-slate-800/60 border-slate-700/30'
const barCls = score >= 60 ? 'bg-emerald-500' : score >= 30 ? 'bg-amber-500' : 'bg-slate-600'
return (
<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: `${score}%` }} />
</div>
<span className={clsx('text-xs font-semibold px-2 py-1 rounded-lg border whitespace-nowrap', badgeCls)}>
{Math.round(score)}% {verdict}
</span>
{periodTmpl.length > 0 && (
<span className="text-[10px] text-slate-600 whitespace-nowrap">
{touchedActive.length}/{periodTmpl.length} graphes
</span>
)}
</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() {
const { id = 'SPY' } = 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 [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 [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
const instrumentId = id.toUpperCase()
useEffect(() => {
api.get('/instruments').then(r => setInstruments(r.data)).catch(() => {})
api.get('/causal-lab/templates').then(r => setTemplates(r.data)).catch(() => {})
}, [])
useEffect(() => {
setLoading(true)
setSnapshot(null)
setNarrative('')
setSelectedDate(null)
setMacroAtDate(null)
setTheoryCurve(null)
setShowTheory(false)
chartDateToXRef.current = null
setChartReady(0)
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 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}
/>
</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}
selectedDate={effectiveDate}
causalInsts={causalInsts}
/>
</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>
)
}