feat: Instrument Snapshot Dashboard — 5-layer synchronized view for 20 instruments

- 20 instruments configured (equity indices, metals, energy, bonds, FX, stocks, crypto)
  each with custom drivers, regime labels, MA periods, event keywords, ai_context
- InstrumentChart: TradingView lightweight-charts candlesticks + MA lines + Bollinger
  + volume histogram + macro event markers overlaid on price
- InstrumentDashboard: regime detection card (scores + signals), trend indicators
  (RSI gauge, MA slopes, momentum, 52W range), events card (links to Timeline),
  AI narrative via GPT-4o-mini (cached by day)
- Backend: instrument_service (OHLCV fetch, indicators, regime scoring, GPT narrative)
  + /api/instruments router (3 endpoints)
- Route: /instruments/:id with selector dropdown, period buttons (3M/6M/1Y/2Y/5Y)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-24 21:36:39 +02:00
parent edfa90c9b7
commit 537fea8148
10 changed files with 2127 additions and 7 deletions

View File

@@ -12,6 +12,7 @@
"axios": "^1.7.7",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lightweight-charts": "^4.2.3",
"lucide-react": "^0.447.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -2023,6 +2024,12 @@
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/fancy-canvas": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fancy-canvas/-/fancy-canvas-2.1.0.tgz",
"integrity": "sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==",
"license": "MIT"
},
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
@@ -2396,6 +2403,15 @@
"node": ">=6"
}
},
"node_modules/lightweight-charts": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-4.2.3.tgz",
"integrity": "sha512-5kS/2hY3wNYNzhnS8Gb+GAS07DX8GPF2YVDnd2NMC85gJVQ6RLU6YrXNgNJ6eg0AnWPwCnvaGtYmGky3HiLQEw==",
"license": "Apache-2.0",
"dependencies": {
"fancy-canvas": "2.1.0"
}
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",

View File

@@ -9,16 +9,17 @@
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.59.0",
"axios": "^1.7.7",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lightweight-charts": "^4.2.3",
"lucide-react": "^0.447.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
"recharts": "^2.13.0",
"@tanstack/react-query": "^5.59.0",
"zustand": "^5.0.0",
"axios": "^1.7.7",
"date-fns": "^4.1.0",
"lucide-react": "^0.447.0",
"clsx": "^2.1.1"
"zustand": "^5.0.0"
},
"devDependencies": {
"@types/react": "^18.3.11",

View File

@@ -25,6 +25,8 @@ import InstitutionalReports from './pages/InstitutionalReports'
import SpecialistDesks from './pages/SpecialistDesks'
import Timeline from './pages/Timeline'
import ExternalSnapshot from './pages/ExternalSnapshot'
import InstrumentDashboard from './pages/InstrumentDashboard'
import { Navigate } from 'react-router-dom'
import { useCycleWatcher } from './hooks/useApi'
function GlobalWatcher() {
@@ -65,6 +67,8 @@ export default function App() {
<Route path="/specialist-desks" element={<SpecialistDesks />} />
<Route path="/timeline" element={<Timeline />} />
<Route path="/snapshot" element={<ExternalSnapshot />} />
<Route path="/instruments" element={<Navigate to="/instruments/SPY" replace />} />
<Route path="/instruments/:id" element={<InstrumentDashboard />} />
</Routes>
</main>
</div>

View File

@@ -0,0 +1,212 @@
import { useRef, useEffect } from 'react'
interface PriceCandle {
time: string
open: number
high: number
low: number
close: number
volume: number
}
interface LinePoint {
time: string
value: number
}
interface ChartEvent {
date: string
title: string
level: string
impact_score?: number
}
interface Props {
priceData: PriceCandle[]
indicators: {
ma20?: LinePoint[]
ma50?: LinePoint[]
ma100?: LinePoint[]
ma200?: LinePoint[]
bb_upper?: LinePoint[]
bb_lower?: LinePoint[]
}
events?: ChartEvent[]
height?: number
}
const MA_COLORS: Record<string, string> = {
ma20: '#f59e0b',
ma50: '#3b82f6',
ma100: '#22d3ee',
ma200: '#a855f7',
}
const LEVEL_COLORS: Record<string, string> = {
long: '#7c3aed',
medium: '#3b82f6',
short: '#10b981',
}
export default function InstrumentChart({ priceData, indicators, events = [], height = 420 }: Props) {
const containerRef = useRef<HTMLDivElement>(null)
const cleanupRef = useRef<(() => void) | null>(null)
useEffect(() => {
cleanupRef.current?.()
cleanupRef.current = null
if (!containerRef.current || !priceData.length) return
let active = true
import('lightweight-charts').then((lc: any) => {
if (!active || !containerRef.current) return
const { createChart, CrosshairMode, ColorType, LineStyle } = lc
const chart = createChart(containerRef.current, {
width: containerRef.current.clientWidth,
height,
layout: {
background: { type: ColorType.Solid, color: 'transparent' },
textColor: '#64748b',
fontSize: 11,
},
grid: {
vertLines: { color: 'rgba(255,255,255,0.04)' },
horzLines: { color: 'rgba(255,255,255,0.04)' },
},
crosshair: { mode: CrosshairMode.Normal },
rightPriceScale: { borderColor: 'rgba(255,255,255,0.1)' },
timeScale: {
borderColor: 'rgba(255,255,255,0.1)',
timeVisible: true,
secondsVisible: false,
fixLeftEdge: true,
fixRightEdge: true,
},
handleScroll: true,
handleScale: true,
})
// Volume histogram (bottom 18% of chart)
const totalVol = priceData.reduce((s, d) => s + d.volume, 0)
if (totalVol > 0) {
const volSeries = chart.addHistogramSeries({
color: 'rgba(148,163,184,0.2)',
priceFormat: { type: 'volume' },
priceScaleId: 'volume',
})
chart.priceScale('volume').applyOptions({ scaleMargins: { top: 0.82, bottom: 0 } })
volSeries.setData(priceData.map(d => ({
time: d.time,
value: d.volume,
color: d.close >= d.open ? 'rgba(16,185,129,0.25)' : 'rgba(239,68,68,0.25)',
})))
}
// Candlestick series
const candleSeries = chart.addCandlestickSeries({
upColor: '#10b981',
downColor: '#ef4444',
borderUpColor: '#10b981',
borderDownColor:'#ef4444',
wickUpColor: '#6ee7b7',
wickDownColor: '#fca5a5',
})
candleSeries.setData(priceData)
// MA lines
for (const [key, color] of Object.entries(MA_COLORS)) {
const data = indicators[key as keyof typeof indicators]
if (data?.length) {
const s = chart.addLineSeries({
color,
lineWidth: key === 'ma200' ? 1.5 : 1,
priceLineVisible: false,
lastValueVisible: true,
crosshairMarkerVisible: false,
})
s.setData(data)
}
}
// Bollinger Bands
if (indicators.bb_upper?.length && indicators.bb_lower?.length) {
const bbOpts = {
color: 'rgba(148,163,184,0.32)',
lineWidth: 1,
lineStyle: LineStyle.Dashed,
priceLineVisible: false,
lastValueVisible: false,
crosshairMarkerVisible: false,
}
chart.addLineSeries(bbOpts).setData(indicators.bb_upper)
chart.addLineSeries(bbOpts).setData(indicators.bb_lower)
}
// Event markers
const timeSet = new Set(priceData.map(d => d.time))
const markers = events
.filter(ev => timeSet.has(ev.date))
.map(ev => ({
time: ev.date,
position: 'aboveBar' as const,
color: LEVEL_COLORS[ev.level] ?? '#f59e0b',
shape: 'arrowDown' as const,
text: ev.title.slice(0, 20),
}))
.sort((a, b) => a.time.localeCompare(b.time))
if (markers.length) candleSeries.setMarkers(markers)
chart.timeScale().fitContent()
const ro = new ResizeObserver(() => {
if (containerRef.current) chart.applyOptions({ width: containerRef.current.clientWidth })
})
ro.observe(containerRef.current)
cleanupRef.current = () => { ro.disconnect(); chart.remove() }
})
return () => {
active = false
cleanupRef.current?.()
cleanupRef.current = null
}
}, [priceData, indicators, events, height])
return (
<div className="bg-dark-900/60 rounded-xl border border-slate-700/40 overflow-hidden">
<div className="flex items-center gap-5 px-4 py-2 border-b border-slate-700/30 text-xs text-slate-500">
{Object.entries(MA_COLORS).map(([k, c]) => (
<span key={k} className="flex items-center gap-1.5">
<span className="inline-block w-5 h-0.5 rounded" style={{ background: c }} />
{k.toUpperCase()}
</span>
))}
<span className="flex items-center gap-1.5">
<span className="inline-block w-5 h-0.5 rounded opacity-40 border-t border-dashed border-slate-400" />
BB(20,2)
</span>
<span className="ml-auto flex items-center gap-3">
{Object.entries(LEVEL_COLORS).map(([l, c]) => (
<span key={l} className="flex items-center gap-1">
<span style={{ color: c }}></span>
<span>{l === 'long' ? 'LT' : l === 'medium' ? 'MT' : 'CT'}</span>
</span>
))}
</span>
</div>
{!priceData.length ? (
<div className="flex items-center justify-center" style={{ height }}>
<span className="text-slate-500 text-sm">Chargement...</span>
</div>
) : (
<div ref={containerRef} style={{ height: `${height}px`, width: '100%' }} />
)}
</div>
)
}

View File

@@ -1,7 +1,7 @@
import { NavLink } from 'react-router-dom'
import {
LayoutDashboard, Globe, BarChart2, FlaskConical,
History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users, Layers, ScanEye
History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users, Layers, ScanEye, CandlestickChart
} from 'lucide-react'
import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi'
import clsx from 'clsx'
@@ -27,6 +27,7 @@ const nav = [
{ to: '/calendar', icon: Calendar, label: 'Calendar' },
{ to: '/institutional', icon: Building2, label: 'Inst. Reports' },
{ to: '/specialist-desks', icon: Users, label: 'Specialist Desks' },
{ to: '/instruments', icon: CandlestickChart, label: 'Instrument Snap.' },
{ to: '/snapshot', icon: ScanEye, label: 'Snapshot Externe' },
{ to: '/timeline', icon: Layers, label: 'Timeline' },
{ to: '/logs', icon: ScrollText, label: 'System Logs' },

View File

@@ -0,0 +1,566 @@
import { useState, useEffect, useCallback } 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 from '../components/InstrumentChart'
const api = axios.create({ baseURL: '/api' })
// ── Types ────────────────────────────────────────────────────────────────────
interface InstrumentConfig {
id: string
name: string
yf_ticker: string
category: string
currency: string
description: string
drivers: { key: string; label: string; weight: number }[]
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 Snapshot {
instrument: InstrumentConfig
price_data: PriceCandle[]
indicators: Record<string, LinePoint[]>
regime: {
current: string
confidence: number
scores: Record<string, number>
signals: {
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
}
}
trend: {
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
}
events: { date: string; title: string; level: string; category: string; description: string; impact_score: number }[]
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|crunch|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
if (pos) return 'text-emerald-400'
if (neg) return 'text-red-400'
return '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)
}
// ── Sub-components ────────────────────────────────────────────────────────────
function RegimeCard({ regime, config }: { regime: Snapshot['regime']; config: InstrumentConfig }) {
const col = regimeColor(regime.current)
const colorMap: Record<string, string> = {
emerald: 'text-emerald-400 border-emerald-700/40 bg-emerald-950/30',
red: 'text-red-400 border-red-700/40 bg-red-950/30',
orange: 'text-orange-400 border-orange-700/40 bg-orange-950/30',
slate: 'text-slate-400 border-slate-700/40 bg-slate-900/30',
blue: 'text-blue-400 border-blue-700/40 bg-blue-950/30',
}
const barColorMap: Record<string, string> = {
emerald: 'bg-emerald-500',
red: 'bg-red-500',
orange: 'bg-orange-500',
slate: 'bg-slate-500',
blue: 'bg-blue-500',
}
const { signals } = regime
const sigItems = [
{ label: 'MA50 vs MA200', value: signals.ma50_above_ma200 === true ? 'Au-dessus ↑' : signals.ma50_above_ma200 === false ? 'En-dessous ↓' : '—', color: signals.ma50_above_ma200 ? 'text-emerald-400' : 'text-red-400' },
{ label: 'Slope MA50 (10j)', value: fmt(signals.ma50_slope_pct) + '%', color: pctColor(signals.ma50_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) + '%', color: pctColor(signals.dist_ma200_pct) },
{ label: 'Volatilité (ATR%)', value: (signals.vol_ratio_pct ?? 0).toFixed(1) + '%', color: signals.vol_ratio_pct > 3 ? 'text-orange-400' : 'text-slate-400' },
]
return (
<div className={clsx('rounded-xl border p-4 space-y-3', colorMap[col])}>
<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 Détecté</span>
</div>
<div>
<div className={clsx('text-lg font-bold leading-tight', `text-${col}-400`)}>{regime.current}</div>
<div className="text-xs text-slate-500 mt-0.5">Confiance {Math.round(regime.confidence * 100)}%</div>
</div>
{/* All regime scores */}
<div className="space-y-1.5">
{Object.entries(regime.scores)
.sort(([, a], [, b]) => b - a)
.map(([label, score]) => {
const c2 = regimeColor(label)
return (
<div key={label} className="space-y-0.5">
<div className="flex items-center justify-between text-xs">
<span className={label === regime.current ? `font-semibold text-${c2}-400` : 'text-slate-500'}>
{label}
</span>
<span className="text-slate-500">{Math.round(score * 100)}%</span>
</div>
<div className="h-1 bg-slate-800 rounded-full overflow-hidden">
<div
className={clsx('h-full rounded-full transition-all', barColorMap[c2] || 'bg-blue-500')}
style={{ width: `${Math.round(score * 100)}%` }}
/>
</div>
</div>
)
})}
</div>
{/* Signals */}
<div className="border-t border-slate-700/30 pt-3 space-y-1.5">
{sigItems.map(s => (
<div key={s.label} className="flex items-center justify-between text-xs">
<span className="text-slate-500">{s.label}</span>
<span className={s.color}>{s.value}</span>
</div>
))}
</div>
</div>
)
}
function TrendCard({ trend }: { trend: Snapshot['trend'] }) {
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, bold: false },
{ label: 'Slope MA200 (20j)', value: fmt(trend.ma200_slope_20d) + '%', arrow: trend.ma200_slope_20d, bold: false },
{ label: 'Distance MA50', value: trend.dist_ma50_pct !== null ? fmt(trend.dist_ma50_pct) + '%' : '—', arrow: trend.dist_ma50_pct ?? 0, bold: false },
{ 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, bold: false },
{ 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.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 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>
{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.bold ? 'font-semibold' : '', pctColor(row.arrow ?? 0))}>
<Arrow v={row.arrow ?? 0} />
{row.value}
</span>
</div>
))}
</div>
))}
{/* RSI gauge */}
<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(${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>
{/* 52W range */}
{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>
)}
{/* Volatility */}
<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>
)
}
function EventsCard({ events }: { events: Snapshot['events'] }) {
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' }
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-[320px] overflow-y-auto pr-1">
{events.map((ev, i) => (
<div
key={i}
className="rounded-lg border border-slate-700/30 bg-dark-700/40 p-2 cursor-pointer hover:bg-dark-700/70 transition-colors"
onClick={() => navigate(`/timeline?date=${ev.date}`)}
>
<div className="flex items-start justify-between gap-2">
<span className="text-xs font-medium text-slate-300 leading-tight">{ev.title}</span>
<span className={clsx('shrink-0 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 className="flex items-center gap-2 mt-1 text-xs text-slate-500">
<Clock className="w-3 h-3" />
{ev.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>
)
}
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">
{[1, 2, 3].map(i => (
<div key={i} className="h-3 bg-slate-800 rounded animate-pulse" style={{ width: `${[95, 88, 70][i - 1]}%` }} />
))}
</div>
) : (
<div className="text-sm text-slate-600 italic">
Cliquez "Générer" pour obtenir une analyse IA du contexte macro + technique pour {instrument.name}.
</div>
)}
</div>
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
const PERIODS = [
{ key: '3mo', label: '3M' },
{ key: '6mo', label: '6M' },
{ key: '1y', label: '1Y' },
{ key: '2y', label: '2Y' },
{ key: '5y', label: '5Y' },
]
export default function InstrumentDashboard() {
const { id = 'SPY' } = useParams<{ id: string }>()
const navigate = useNavigate()
const [period, setPeriod] = useState('1y')
const [instruments, setInstruments] = useState<InstrumentConfig[]>([])
const [snapshot, setSnapshot] = useState<Snapshot | null>(null)
const [narrative, setNarrative] = useState('')
const [loading, setLoading] = useState(false)
const [loadingNarr, setLoadingNarr] = useState(false)
const [selectorOpen, setSelectorOpen] = useState(false)
const instrumentId = id.toUpperCase()
// Load instrument list
useEffect(() => {
api.get('/instruments').then(r => setInstruments(r.data)).catch(() => {})
}, [])
// Load snapshot when instrument or period changes
useEffect(() => {
setLoading(true)
setSnapshot(null)
setNarrative('')
api.get(`/instruments/${instrumentId}/snapshot?period=${period}`)
.then(r => setSnapshot(r.data))
.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])
// Group instruments by category
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 changeAbs = snapshot?.change_abs
const changePct = snapshot?.change_pct
return (
<div className="p-6 max-w-screen-xl mx-auto space-y-4">
{/* ── Header ── */}
<div className="flex flex-wrap items-center gap-3">
{/* Instrument selector */}
<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>
{/* Category badge */}
{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>
)}
{/* Current price */}
{snapshot && (
<div className="flex items-center gap-3">
<span className="text-xl font-bold text-white">
{snapshot.current_price?.toLocaleString('fr-FR', { maximumFractionDigits: 4 })}
</span>
<span className={clsx('text-sm font-semibold', (changePct ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{(changePct ?? 0) >= 0 ? '+' : ''}{changeAbs?.toFixed(2)} ({(changePct ?? 0) >= 0 ? '+' : ''}{changePct?.toFixed(2)}%)
</span>
</div>
)}
{/* Period selector */}
<div className="ml-auto 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>
{/* Description */}
{selected && (
<p className="w-full text-xs text-slate-500">{selected.description}</p>
)}
</div>
{/* ── Loading state ── */}
{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="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-64" />
))}
</div>
</div>
)}
{/* ── Chart ── */}
{!loading && snapshot && (
<>
<InstrumentChart
priceData={snapshot.price_data}
indicators={snapshot.indicators}
events={snapshot.events}
height={420}
/>
{/* ── 3-column grid ── */}
<div className="grid grid-cols-3 gap-4">
<RegimeCard regime={snapshot.regime} config={snapshot.instrument} />
<TrendCard trend={snapshot.trend} />
<EventsCard events={snapshot.events} />
</div>
{/* ── AI Narrative ── */}
<NarrativeCard
narrative={narrative}
loading={loadingNarr}
onLoad={loadNarrative}
instrument={snapshot.instrument}
/>
</>
)}
{/* ── Empty/error state ── */}
{!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>
)}
{/* Close dropdown on outside click */}
{selectorOpen && (
<div className="fixed inset-0 z-40" onClick={() => setSelectorOpen(false)} />
)}
</div>
)
}