feat: driver-based timeline strip, regime signal metrics, drivers editor

- instruments.json: add keywords array to every driver across 20 instruments
  (Fed, BCE, BOJ, OPEC, CPI, AI, EIA, etc.) for event-to-driver matching
- instrument_service.py: add update_instrument_drivers() persisting changes to JSON
  and refreshing in-memory cache
- instruments.py: add PUT /api/instruments/{id}/drivers endpoint (DriverUpdate model)
- InstrumentDashboard:
  * RegimeCard: replace regime score bars with 6-metric signal grid
    (MA50/MA200 position, MA50 slope, MA200 slope, momentum 20j, dist MA200, ATR vol ratio)
    with colour-coded values and contextual sub-labels (Golden cross, Surextension, etc.)
  * EventTimelineStrip: rows now keyed by top-4 instrument drivers (by weight)
    instead of LT/MT/CT; events matched via case-insensitive keyword scan against
    title + description + category; fallback dashed line when no events match
  * DriversPanel: inline edit panel (toggle via Drivers button in header);
    edit label, weight, keywords (comma-separated) per driver; add/remove drivers;
    saves via PUT /api/instruments/{id}/drivers; optimistic local state update

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-24 22:48:10 +02:00
parent 418d03254d
commit aa81598278
4 changed files with 635 additions and 579 deletions

View File

@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import {
Sparkles, RefreshCw, ChevronDown, TrendingUp, TrendingDown,
Minus, BarChart2, Clock, Calendar, AlertCircle,
Minus, BarChart2, Clock, Calendar, AlertCircle, Pencil, Save, X, Plus, Trash2,
} from 'lucide-react'
import axios from 'axios'
import clsx from 'clsx'
@@ -12,10 +12,13 @@ const api = axios.create({ baseURL: '/api' })
// ── Types ─────────────────────────────────────────────────────────────────────
interface Driver {
key: string; label: string; weight: number; keywords: string[]
}
interface InstrumentConfig {
id: string; name: string; yf_ticker: string; category: string; currency: string
description: string
drivers: { key: string; label: string; weight: number }[]
description: string; drivers: Driver[]
regime_labels: string[]
chart: { ma_periods: number[]; show_volume: boolean }
correlation_instruments: string[]
@@ -45,10 +48,7 @@ interface Snapshot {
instrument: InstrumentConfig
price_data: PriceCandle[]
indicators: Record<string, LinePoint[]>
regime: {
current: string; confidence: number; scores: Record<string, number>
signals: RegimeSignals
}
regime: { current: string; confidence: number; scores: Record<string, number>; signals: RegimeSignals }
trend: TrendMetrics
events: SnapshotEvent[]
current_price: number; change_pct: number; change_abs: number; period: string
@@ -98,148 +98,101 @@ function fmtDateFR(s: string | null): string {
function dateToMs(s: string) { return new Date(s).getTime() }
// ── EventTimelineStrip ────────────────────────────────────────────────────────
const LEVEL_STRIP = ['long', 'medium', 'short'] as const
type LevelKey = typeof LEVEL_STRIP[number]
const STRIP_COLORS: Record<LevelKey, { bg: string; border: string; text: string; label: string }> = {
long: { bg: 'bg-violet-800/60', border: 'border-violet-700/60', text: 'text-violet-100', label: 'text-violet-400' },
medium: { bg: 'bg-blue-800/60', border: 'border-blue-700/60', text: 'text-blue-100', label: 'text-blue-400' },
short: { bg: 'bg-emerald-800/60',border: 'border-emerald-700/60',text: 'text-emerald-100', label: 'text-emerald-400'},
}
const STRIP_LABELS = { long: 'LT', medium: 'MT', short: 'CT' }
const DEFAULT_DAYS: Record<LevelKey, number> = { long: 60, medium: 21, short: 10 }
function EventTimelineStrip({ events, priceData }: { events: SnapshotEvent[]; priceData: PriceCandle[] }) {
if (priceData.length < 2) return null
const chartStart = dateToMs(priceData[0].time)
const chartEnd = dateToMs(priceData[priceData.length - 1].time)
const totalMs = chartEnd - chartStart
if (totalMs <= 0) return null
function leftPct(dateStr: string): number {
return Math.max(0, Math.min(99, ((dateToMs(dateStr) - chartStart) / totalMs) * 100))
}
function widthPct(startStr: string, endStr: string | null, level: LevelKey): number {
const endMs = endStr
? dateToMs(endStr)
: dateToMs(startStr) + DEFAULT_DAYS[level] * 86400000
const w = ((endMs - dateToMs(startStr)) / totalMs) * 100
return Math.min(100, Math.max(0.4, w))
}
return (
<div className="bg-dark-900/40 rounded-xl border border-slate-700/40 px-4 py-3">
<div className="text-xs text-slate-500 uppercase tracking-wide mb-2.5">Événements sur la période</div>
<div className="space-y-1.5">
{LEVEL_STRIP.map(level => {
const evs = events.filter(e => e.level === level)
const cols = STRIP_COLORS[level]
return (
<div key={level} className="flex items-center gap-2 h-6">
<span className={clsx('text-xs font-bold w-5 shrink-0', cols.label)}>{STRIP_LABELS[level]}</span>
{/* paddingRight aligns with lightweight-charts right price scale (~60px) */}
<div className="relative flex-1 h-full" style={{ paddingRight: 62 }}>
{evs.map((ev, i) => {
const left = leftPct(ev.date)
const width = widthPct(ev.date, ev.end_date, level)
return (
<div
key={i}
title={`${ev.title}\n${ev.date}${ev.end_date ? ' → ' + ev.end_date : ''}`}
className={clsx('absolute top-0 h-full rounded border overflow-hidden', cols.bg, cols.border)}
style={{ left: `${left}%`, width: `${width}%` }}
>
{width > 3 && (
<span className={clsx('absolute inset-0 flex items-center px-1 truncate pointer-events-none', cols.text)}
style={{ fontSize: 9 }}>
{ev.title}
</span>
)}
</div>
)
})}
{evs.length === 0 && (
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-dashed border-slate-700/40" />
</div>
)}
</div>
</div>
)
})}
</div>
</div>
)
function pctN(a: number | undefined, b: number | undefined): number {
if (a === undefined || b === undefined || b === 0) return 0
return ((a - b) / b) * 100
}
// ── RegimeCard ────────────────────────────────────────────────────────────────
// ── RegimeCard (métriques de signaux) ─────────────────────────────────────────
function RegimeCard({
regime, config, signalsAt, dateLabel,
regime, signalsAt, dateLabel,
}: {
regime: Snapshot['regime']; config: InstrumentConfig
signalsAt: RegimeSignals | null; dateLabel: string
regime: Snapshot['regime']
signalsAt: RegimeSignals | null
dateLabel: string
}) {
const signals = signalsAt ?? regime.signals
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 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',
}
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 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 > 130 ? 'text-orange-400' : 'text-slate-400' },
// 6 signal metrics displayed as a compact grid
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,
},
]
return (
<div className={clsx('rounded-xl border p-4 space-y-3', colorMap[col])}>
<div className={clsx('rounded-xl border p-4 space-y-3', borderMap[col])}>
{/* Header */}
<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>
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Régime Détecté</span>
</div>
<span className="text-xs text-slate-600">{dateLabel}</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>
{/* Regime label + confidence */}
<div className={clsx('rounded-lg px-3 py-2', `bg-${col}-900/20`)}>
<div className={clsx('text-base 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="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>
<div className="border-t border-slate-700/30 pt-3 space-y-1.5">
<div className="text-xs text-slate-600 uppercase tracking-wide mb-1">Signaux {dateLabel}</div>
{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>
{/* 6 signal metrics — 2 column grid */}
<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>
@@ -255,25 +208,20 @@ function TrendCard({ trend, dateLabel }: { trend: TrendMetrics; dateLabel: strin
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 },
]
},
{ 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
? ((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">
@@ -285,7 +233,6 @@ function TrendCard({ trend, dateLabel }: { trend: TrendMetrics; dateLabel: strin
<span className="text-xs text-slate-600">{dateLabel}</span>
</div>
{/* Prix au snapshot */}
<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">
@@ -300,8 +247,7 @@ function TrendCard({ trend, dateLabel }: { trend: TrendMetrics; dateLabel: strin
<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}
<Arrow v={row.arrow ?? 0} />{row.value}
</span>
</div>
))}
@@ -334,8 +280,7 @@ function TrendCard({ trend, dateLabel }: { trend: TrendMetrics; dateLabel: strin
<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>
<span>{trend.low_52w?.toFixed(2)}</span><span>{trend.high_52w?.toFixed(2)}</span>
</div>
</div>
)}
@@ -368,7 +313,7 @@ function EventsCard({ events }: { events: SnapshotEvent[] }) {
{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-[360px] overflow-y-auto pr-1">
<div className="space-y-2 max-h-[380px] 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"
@@ -396,9 +341,9 @@ function EventsCard({ events }: { events: SnapshotEvent[] }) {
// ── NarrativeCard ─────────────────────────────────────────────────────────────
function NarrativeCard({
narrative, loading, onLoad, instrument,
}: { narrative: string; loading: boolean; onLoad: () => void; instrument: InstrumentConfig }) {
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">
@@ -421,36 +366,222 @@ function NarrativeCard({
</div>
) : (
<div className="text-sm text-slate-600 italic">
Cliquez "Générer" pour obtenir une analyse IA pour {instrument.name}.
Cliquez "Générer" pour une analyse IA pour {instrument.name}.
</div>
)}
</div>
)
}
// ── EventTimelineStrip — by drivers ──────────────────────────────────────────
const DRIVER_PALETTE = [
{ bg: 'bg-violet-800/60', border: 'border-violet-700/60', text: 'text-violet-100', label: 'text-violet-400' },
{ bg: 'bg-blue-800/60', border: 'border-blue-700/60', text: 'text-blue-100', label: 'text-blue-400' },
{ bg: 'bg-emerald-800/60', border: 'border-emerald-700/60', text: 'text-emerald-100', label: 'text-emerald-400' },
{ bg: 'bg-amber-800/60', border: 'border-amber-700/60', text: 'text-amber-100', label: 'text-amber-400' },
]
function eventMatchesDriver(ev: SnapshotEvent, keywords: string[]): boolean {
const text = `${ev.title} ${ev.description ?? ''} ${ev.category ?? ''}`.toLowerCase()
return keywords.some(kw => text.includes(kw.toLowerCase()))
}
function EventTimelineStrip({
events, priceData, drivers,
}: {
events: SnapshotEvent[]; priceData: PriceCandle[]; drivers: Driver[]
}) {
if (priceData.length < 2) return null
const chartStart = dateToMs(priceData[0].time)
const chartEnd = dateToMs(priceData[priceData.length - 1].time)
const totalMs = chartEnd - chartStart
if (totalMs <= 0) return null
function leftPct(d: string) { return Math.max(0, Math.min(99, ((dateToMs(d) - chartStart) / totalMs) * 100)) }
function widthPct(start: string, end: string | null): number {
// Default width = 3% of period if no end date
const endMs = end ? dateToMs(end) : dateToMs(start) + totalMs * 0.03
return Math.min(100, Math.max(0.4, ((endMs - dateToMs(start)) / totalMs) * 100))
}
// Top 4 drivers by weight
const topDrivers = [...drivers].sort((a, b) => b.weight - a.weight).slice(0, 4)
return (
<div className="bg-dark-900/40 rounded-xl border border-slate-700/40 px-4 py-3">
<div className="text-xs text-slate-500 uppercase tracking-wide mb-2.5">Événements par driver</div>
<div className="space-y-1.5">
{topDrivers.map((driver, di) => {
const cols = DRIVER_PALETTE[di % DRIVER_PALETTE.length]
const matchedEvs = events.filter(ev => eventMatchesDriver(ev, driver.keywords ?? []))
return (
<div key={driver.key} className="flex items-center gap-2 h-6">
<span className={clsx('text-xs font-semibold shrink-0 truncate w-28', cols.label)}
style={{ fontSize: 10 }}
title={driver.label}
>
{driver.label}
</span>
{/* paddingRight aligns with chart right price scale (~60px) */}
<div className="relative flex-1 h-full" style={{ paddingRight: 62 }}>
{matchedEvs.map((ev, i) => {
const left = leftPct(ev.date)
const width = widthPct(ev.date, ev.end_date)
return (
<div key={i}
title={`${ev.title}\n${ev.date}${ev.end_date ? ' → ' + ev.end_date : ''}\n${ev.description ?? ''}`}
className={clsx('absolute top-0 h-full rounded border overflow-hidden', cols.bg, cols.border)}
style={{ left: `${left}%`, width: `${width}%` }}
>
{width > 3 && (
<span className={clsx('absolute inset-0 flex items-center px-1 truncate pointer-events-none', cols.text)}
style={{ fontSize: 9 }}>
{ev.title}
</span>
)}
</div>
)
})}
{matchedEvs.length === 0 && (
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-dashed border-slate-700/30" />
</div>
)}
</div>
</div>
)
})}
</div>
</div>
)
}
// ── DriversPanel — édition inline ────────────────────────────────────────────
function DriversPanel({ instrumentId, drivers, onSave, onClose }: {
instrumentId: string
drivers: Driver[]
onSave: (drivers: Driver[]) => void
onClose: () => void
}) {
const [local, setLocal] = useState<Driver[]>(() => drivers.map(d => ({ ...d, keywords: [...(d.keywords ?? [])] })))
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
function updateDriver(i: number, field: keyof Driver, val: any) {
setLocal(prev => prev.map((d, idx) => idx === i ? { ...d, [field]: val } : d))
}
function updateKeywords(i: number, raw: string) {
const kws = raw.split(',').map(s => s.trim()).filter(Boolean)
updateDriver(i, 'keywords', kws)
}
function addDriver() {
setLocal(prev => [...prev, { key: `driver_${Date.now()}`, label: 'Nouveau driver', weight: 0.5, keywords: [] }])
}
function removeDriver(i: number) {
setLocal(prev => prev.filter((_, idx) => idx !== i))
}
async function save() {
setSaving(true); setError('')
try {
await api.put(`/instruments/${instrumentId}/drivers`, { drivers: local })
onSave(local)
onClose()
} catch (e: any) {
setError(e?.response?.data?.detail ?? 'Erreur de sauvegarde')
} finally {
setSaving(false)
}
}
return (
<div className="rounded-xl border border-blue-700/40 bg-dark-800/80 p-4 space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-white">Éditer les drivers {instrumentId}</span>
<div className="flex items-center gap-2">
<button onClick={addDriver}
className="flex items-center gap-1 text-xs px-2.5 py-1 bg-blue-800/30 hover:bg-blue-800/50 text-blue-300 border border-blue-700/40 rounded-lg transition-colors">
<Plus className="w-3 h-3" /> Ajouter
</button>
<button onClick={save} disabled={saving}
className="flex items-center gap-1.5 text-xs px-3 py-1 bg-emerald-800/30 hover:bg-emerald-800/50 text-emerald-300 border border-emerald-700/40 rounded-lg transition-colors disabled:opacity-40">
{saving ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Save className="w-3 h-3" />}
{saving ? 'Sauvegarde...' : 'Sauvegarder'}
</button>
<button onClick={onClose} className="p-1 text-slate-500 hover:text-white transition-colors">
<X className="w-4 h-4" />
</button>
</div>
</div>
{error && <div className="text-xs text-red-400 bg-red-900/20 border border-red-700/30 rounded-lg px-3 py-1.5">{error}</div>}
<div className="space-y-2 max-h-[420px] overflow-y-auto pr-1">
{local.map((d, i) => (
<div key={i} className="rounded-lg border border-slate-700/30 bg-dark-700/40 p-3 space-y-2">
<div className="flex items-center gap-2">
<input
className="flex-1 text-xs bg-dark-600 border border-slate-700/40 rounded px-2 py-1 text-white placeholder-slate-600"
placeholder="Label"
value={d.label}
onChange={e => updateDriver(i, 'label', e.target.value)}
/>
<div className="flex items-center gap-1.5">
<span className="text-xs text-slate-500">Poids</span>
<input
type="number" min="0" max="1" step="0.05"
className="w-16 text-xs bg-dark-600 border border-slate-700/40 rounded px-2 py-1 text-white"
value={d.weight}
onChange={e => updateDriver(i, 'weight', parseFloat(e.target.value))}
/>
</div>
<button onClick={() => removeDriver(i)} className="p-1 text-slate-600 hover:text-red-400 transition-colors">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<div>
<label className="text-xs text-slate-600 mb-0.5 block">Mots-clés (séparés par virgule)</label>
<input
className="w-full text-xs bg-dark-600 border border-slate-700/40 rounded px-2 py-1 text-slate-300 placeholder-slate-600"
placeholder="Fed, FOMC, pivot, QE, taux directeur..."
value={(d.keywords ?? []).join(', ')}
onChange={e => updateKeywords(i, e.target.value)}
/>
</div>
</div>
))}
</div>
</div>
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
const PERIODS = [
{ key: '3mo', label: '3M' }, { key: '6mo', label: '6M' },
{ key: '1y', label: '1Y' }, { key: '2y', label: '2Y' }, { key: '5y', label: '5Y' },
{ key: '1y', label: '1Y' }, { key: '2y', label: '2Y' }, { key: '5y', label: '5Y' },
]
function pctN(a: number | undefined, b: number | undefined): number {
if (a === undefined || b === undefined || b === 0) return 0
return ((a - b) / b) * 100
}
export default function InstrumentDashboard() {
const { id = 'SPY' } = useParams<{ id: string }>()
const navigate = useNavigate()
const [period, setPeriod] = useState('1y')
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 [snapshot, setSnapshot] = useState<Snapshot | null>(null)
const [narrative, setNarrative] = useState('')
const [loading, setLoading] = useState(false)
const [loadingNarr, setLoadingNarr] = useState(false)
const [selectorOpen, setSelectorOpen] = useState(false)
const [selectedDate, setSelectedDate] = useState<string | null>(null)
const [editDrivers, setEditDrivers] = useState(false)
// Local drivers (updated optimistically after save)
const [localDrivers, setLocalDrivers] = useState<Driver[] | null>(null)
const instrumentId = id.toUpperCase()
@@ -463,6 +594,8 @@ export default function InstrumentDashboard() {
setSnapshot(null)
setNarrative('')
setSelectedDate(null)
setLocalDrivers(null)
setEditDrivers(false)
api.get(`/instruments/${instrumentId}/snapshot?period=${period}`)
.then(r => {
setSnapshot(r.data)
@@ -483,138 +616,87 @@ export default function InstrumentDashboard() {
const handleDateHover = useCallback((date: string | null) => {
if (date) setSelectedDate(date)
// null = mouse left chart area → keep last hovered date
}, [])
// ── Lookup maps (rebuilt only when snapshot changes) ──────────────────────
// ── Lookup maps ───────────────────────────────────────────────────────────
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>,
}
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)
}
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
}
for (const pt of pts) { if (!indMap[pt.time]) indMap[pt.time] = {}; indMap[pt.time][key] = pt.value }
}
return { priceMap, indMap, sortedDates, dateIndex }
}, [snapshot])
// Resolve effective date (fall back to last if selectedDate not in index)
const effectiveDate = useMemo(() => {
if (selectedDate && dateIndex[selectedDate] !== undefined) return selectedDate
return sortedDates[sortedDates.length - 1] ?? null
}, [selectedDate, sortedDates, dateIndex])
// ── Compute trend metrics at selectedDate ─────────────────────────────────
const dateTrend = useMemo((): TrendMetrics | null => {
if (!effectiveDate || !snapshot) return null
const candle = priceMap[effectiveDate]
if (!candle) return null
const idx = dateIndex[effectiveDate]
const price = candle.close
const ind = indMap[effectiveDate] ?? {}
const ma50 = ind.ma50
const ma200 = ind.ma200
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
const ma50_slope_5d = pctN(ma50, d5 ? indMap[d5]?.ma50 : undefined)
const ma200_slope_20d = pctN(ma200, d20 ? indMap[d20]?.ma200 : undefined)
const momentum_1m_pct = d21 && priceMap[d21] ? pctN(price, priceMap[d21].close) : 0
const momentum_3m_pct = d63 && priceMap[d63] ? pctN(price, priceMap[d63].close) : 0
const dist_ma50_pct = ma50 ? pctN(price, ma50) : null
const dist_ma200_pct = ma200 ? pctN(price, ma200) : null
const atr14 = ind.atr14 ?? 0
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++ }
}
const atr_vs_3m_avg_pct = atrCnt > 0 && atr14 ? (atr14 / (atrSum / atrCnt)) * 100 : 100
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 }
}
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, ma200_slope_20d,
rsi14_current: ind.rsi14 ?? 50,
atr14_current: atr14,
atr_vs_3m_avg_pct, momentum_1m_pct, momentum_3m_pct,
dist_ma50_pct, dist_ma200_pct,
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])
// ── Compute regime signals at selectedDate ────────────────────────────────
const dateSignals = useMemo((): RegimeSignals | null => {
if (!effectiveDate || !snapshot) return null
const candle = priceMap[effectiveDate]
if (!candle) return null
const idx = dateIndex[effectiveDate]
const price = candle.close
const ind = indMap[effectiveDate] ?? {}
const ma50 = ind.ma50
const ma200 = ind.ma200
const atr14 = ind.atr14
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
const ma50_slope_pct = pctN(ma50, d10 ? indMap[d10]?.ma50 : undefined)
const ma200_slope_pct = pctN(ma200, d10 ? indMap[d10]?.ma200 : undefined)
const momentum_20d_pct = d20 && priceMap[d20] ? pctN(price, priceMap[d20].close) : 0
const dist_ma200_pct = ma200 ? pctN(price, ma200) : 0
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++ }
}
const vol_ratio_pct = atrCnt > 0 && atr14 ? (atr14 / (atrSum / atrCnt)) * 100 : 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, ma200_slope_pct, momentum_20d_pct, dist_ma200_pct, vol_ratio_pct,
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])
// ── UI helpers ────────────────────────────────────────────────────────────
// ── UI ────────────────────────────────────────────────────────────────────
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) : '—'
// Header price: from computed trend (crosshair date) or snapshot
const selected = instruments.find(i => i.id === instrumentId)
const isLastDate = effectiveDate === sortedDates[sortedDates.length - 1]
const dateLabel = effectiveDate ? fmtDateFR(effectiveDate) : '—'
const displayPrice = dateTrend?.current_price ?? snapshot?.current_price
const displayPricePct = snapshot?.change_pct
// Drivers to use (local override after edit, else from snapshot)
const activeDrivers = localDrivers ?? snapshot?.instrument?.drivers ?? []
return (
<div className="p-6 max-w-screen-xl mx-auto space-y-4">
@@ -668,31 +750,45 @@ export default function InstrumentDashboard() {
{displayPrice.toLocaleString('fr-FR', { maximumFractionDigits: 4 })}
</span>
{isLastDate && snapshot && (
<span className={clsx('text-sm font-semibold', (displayPricePct ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{(displayPricePct ?? 0) >= 0 ? '+' : ''}{snapshot.change_abs?.toFixed(2)} ({(displayPricePct ?? 0) >= 0 ? '+' : ''}{displayPricePct?.toFixed(2)}%)
<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-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'
<div className="ml-auto flex items-center gap-2">
{/* Drivers edit button */}
{snapshot && (
<button onClick={() => setEditDrivers(e => !e)}
className={clsx('flex items-center gap-1.5 text-xs px-3 py-1.5 border rounded-lg transition-colors',
editDrivers
? 'bg-blue-800/40 text-blue-300 border-blue-700/40'
: 'bg-dark-800 text-slate-400 border-slate-700/40 hover:text-white hover:bg-dark-700'
)}
>
{p.label}
<Pencil className="w-3 h-3" />
Drivers
</button>
))}
)}
{/* 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>
)}
{selected && <p className="w-full text-xs text-slate-500">{selected.description}</p>}
</div>
{/* ── Loading skeleton ── */}
{/* ── 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 }} />
@@ -706,7 +802,6 @@ export default function InstrumentDashboard() {
{/* ── Content ── */}
{!loading && snapshot && (
<>
{/* Chart */}
<InstrumentChart
priceData={snapshot.price_data}
indicators={snapshot.indicators}
@@ -715,10 +810,13 @@ export default function InstrumentDashboard() {
onDateHover={handleDateHover}
/>
{/* Event timeline strip */}
<EventTimelineStrip events={snapshot.events} priceData={snapshot.price_data} />
<EventTimelineStrip
events={snapshot.events}
priceData={snapshot.price_data}
drivers={activeDrivers}
/>
{/* Snapshot date badge */}
{/* 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',
@@ -736,7 +834,6 @@ export default function InstrumentDashboard() {
<div className="grid grid-cols-3 gap-4">
<RegimeCard
regime={snapshot.regime}
config={snapshot.instrument}
signalsAt={dateSignals}
dateLabel={dateLabel}
/>
@@ -747,6 +844,16 @@ export default function InstrumentDashboard() {
<EventsCard events={snapshot.events} />
</div>
{/* Drivers edit panel */}
{editDrivers && (
<DriversPanel
instrumentId={instrumentId}
drivers={activeDrivers}
onSave={saved => { setLocalDrivers(saved); setEditDrivers(false) }}
onClose={() => setEditDrivers(false)}
/>
)}
{/* AI Narrative */}
<NarrativeCard
narrative={narrative}