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:
16
frontend/package-lock.json
generated
16
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
212
frontend/src/components/InstrumentChart.tsx
Normal file
212
frontend/src/components/InstrumentChart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
566
frontend/src/pages/InstrumentDashboard.tsx
Normal file
566
frontend/src/pages/InstrumentDashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user