feat: InstrumentDashboard — event timeline strip + crosshair date-aware cards

- InstrumentChart: onDateHover callback via subscribeCrosshairMove (useRef pattern)
- EventTimelineStrip: 3 rows LT/MT/CT with CSS-% bars aligned to chart X axis
- Cards date-aware: crosshair drives selectedDate; dateTrend + dateSignals computed
  client-side from lookup maps (priceMap/indMap/sortedDates) without extra API calls
- TrendCard: price, RSI, ATR, slopes, momentum, 52W range all at selected date
- RegimeCard: 5 signals recomputed at selected date; regime label from server
- Date badge above cards; blue tint when browsing history, grey on last date
- instrument_service.py: end_date in events; price_data built before events block

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-24 22:21:04 +02:00
parent d47fc8f50d
commit 418d03254d
3 changed files with 447 additions and 231 deletions

View File

@@ -505,11 +505,19 @@ def _get_relevant_events(
asset_hit = any(ra in ev_assets for ra in related)
if keyword_hit or asset_hit:
filtered.append(ev)
filtered.append({
"date": ev_start,
"end_date": ev.get("end_date") or None,
"title": ev.get("name") or ev.get("event_name") or "",
"level": ev.get("level", "medium"),
"category": ev.get("category", ""),
"description": (ev.get("description") or "")[:120],
"impact_score": float(ev.get("impact_score") or 0.5),
})
# Sort by start_date desc, cap at 15
filtered.sort(key=lambda e: str(e.get("start_date", "") or ""), reverse=True)
return filtered[:15]
# Sort by start_date asc for timeline display, cap at 30
filtered.sort(key=lambda e: str(e.get("date", "") or ""))
return filtered[:30]
# ── Main snapshot ──────────────────────────────────────────────────────────────
@@ -557,15 +565,6 @@ async def get_snapshot(
change_abs = _round(last - prev)
change_pct = _round((last - prev) / abs(prev) * 100, 3)
# Events (last 1 year)
try:
from_date = (pd.Timestamp.now() - pd.Timedelta(days=365)).strftime("%Y-%m-%d")
to_date = pd.Timestamp.now().strftime("%Y-%m-%d")
events = _get_relevant_events(config, from_date=from_date, to_date=to_date)
except Exception as e:
logger.warning(f"[instrument_service] Event filtering error: {e}")
events = []
# Price data for chart (time + ohlcv)
price_data = []
if records:
@@ -579,6 +578,15 @@ async def get_snapshot(
"volume": r.get("volume", 0),
})
# Events spanning the chart period (built after price_data so dates are known)
try:
chart_start = price_data[0]["time"] if price_data else None
chart_end = price_data[-1]["time"] if price_data else None
events = _get_relevant_events(config, from_date=chart_start, to_date=chart_end)
except Exception as e:
logger.warning(f"[instrument_service] Event filtering error: {e}")
events = []
return {
"instrument": config,
"price_data": price_data,

View File

@@ -18,21 +18,14 @@ 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
priceData: PriceCandle[]
indicators: Record<string, LinePoint[]>
events?: ChartEvent[]
height?: number
onDateHover?: (date: string | null) => void
}
const MA_COLORS: Record<string, string> = {
@@ -48,9 +41,13 @@ const LEVEL_COLORS: Record<string, string> = {
short: '#10b981',
}
export default function InstrumentChart({ priceData, indicators, events = [], height = 420 }: Props) {
const containerRef = useRef<HTMLDivElement>(null)
const cleanupRef = useRef<(() => void) | null>(null)
export default function InstrumentChart({ priceData, indicators, events = [], height = 420, onDateHover }: Props) {
const containerRef = useRef<HTMLDivElement>(null)
const cleanupRef = useRef<(() => void) | null>(null)
const onHoverRef = useRef(onDateHover)
// Keep callback ref fresh without triggering chart rebuild
useEffect(() => { onHoverRef.current = onDateHover }, [onDateHover])
useEffect(() => {
cleanupRef.current?.()
@@ -90,8 +87,8 @@ export default function InstrumentChart({ priceData, indicators, events = [], he
handleScale: true,
})
// Volume histogram (bottom 18% of chart)
const totalVol = priceData.reduce((s, d) => s + d.volume, 0)
// Volume (bottom 18%)
const totalVol = priceData.reduce((s, d) => s + (d.volume || 0), 0)
if (totalVol > 0) {
const volSeries = chart.addHistogramSeries({
color: 'rgba(148,163,184,0.2)',
@@ -106,29 +103,28 @@ export default function InstrumentChart({ priceData, indicators, events = [], he
})))
}
// Candlestick series
// Candlesticks
const candleSeries = chart.addCandlestickSeries({
upColor: '#10b981',
downColor: '#ef4444',
borderUpColor: '#10b981',
borderDownColor:'#ef4444',
wickUpColor: '#6ee7b7',
wickDownColor: '#fca5a5',
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]
const data = indicators[key]
if (data?.length) {
const s = chart.addLineSeries({
chart.addLineSeries({
color,
lineWidth: key === 'ma200' ? 1.5 : 1,
priceLineVisible: false,
lastValueVisible: true,
crosshairMarkerVisible: false,
})
s.setData(data)
}).setData(data)
}
}
@@ -155,7 +151,7 @@ export default function InstrumentChart({ priceData, indicators, events = [], he
position: 'aboveBar' as const,
color: LEVEL_COLORS[ev.level] ?? '#f59e0b',
shape: 'arrowDown' as const,
text: ev.title.slice(0, 20),
text: ev.title.slice(0, 18),
}))
.sort((a, b) => a.time.localeCompare(b.time))
@@ -163,6 +159,15 @@ export default function InstrumentChart({ priceData, indicators, events = [], he
chart.timeScale().fitContent()
// Crosshair → date callback
chart.subscribeCrosshairMove((param: any) => {
if (param?.time && typeof param.time === 'string') {
onHoverRef.current?.(param.time)
} else if (!param?.point) {
onHoverRef.current?.(null) // mouse left chart
}
})
const ro = new ResizeObserver(() => {
if (containerRef.current) chart.applyOptions({ width: containerRef.current.clientWidth })
})
@@ -187,9 +192,8 @@ export default function InstrumentChart({ priceData, indicators, events = [], he
{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 className="flex items-center gap-1.5 opacity-40">
<span className="inline-block w-5 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]) => (

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import {
Sparkles, RefreshCw, ChevronDown, TrendingUp, TrendingDown,
@@ -10,14 +10,10 @@ import InstrumentChart from '../components/InstrumentChart'
const api = axios.create({ baseURL: '/api' })
// ── Types ────────────────────────────────────────────────────────────────────
// ── Types ────────────────────────────────────────────────────────────────────
interface InstrumentConfig {
id: string
name: string
yf_ticker: string
category: string
currency: string
id: string; name: string; yf_ticker: string; category: string; currency: string
description: string
drivers: { key: string; label: string; weight: number }[]
regime_labels: string[]
@@ -28,48 +24,39 @@ interface InstrumentConfig {
interface PriceCandle { time: string; open: number; high: number; low: number; close: number; volume: number }
interface LinePoint { time: string; value: number }
interface SnapshotEvent {
date: string; end_date: string | null; title: string; level: string
category: string; description: string; impact_score: number
}
interface RegimeSignals {
ma50_above_ma200: boolean | null; ma50_slope_pct: number; ma200_slope_pct: number
momentum_20d_pct: number; dist_ma200_pct: number; vol_ratio_pct: number
}
interface TrendMetrics {
ma50_slope_5d: number; ma200_slope_20d: number; rsi14_current: number
atr14_current: number; atr_vs_3m_avg_pct: number; momentum_1m_pct: number
momentum_3m_pct: number; dist_ma50_pct: number | null; dist_ma200_pct: number | null
current_price: number; high_52w: number; low_52w: number
}
interface 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
}
current: string; confidence: number; scores: Record<string, number>
signals: RegimeSignals
}
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
trend: TrendMetrics
events: SnapshotEvent[]
current_price: number; change_pct: number; change_abs: number; period: string
}
// ── Helpers ──────────────────────────────────────────────────────────────────
// ── 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',
@@ -79,7 +66,7 @@ const CATEGORY_LABELS: Record<string, string> = {
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 (/bear|crash|crunch|shock|squeeze|fear|risk.off|selloff|crisis|deficit/i.test(l)) return 'red'
if (/volatil|spike|stress/i.test(l)) return 'orange'
if (/range|neutral|consolid|compress/i.test(l)) return 'slate'
return 'blue'
@@ -89,9 +76,7 @@ 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'
return pos ? 'text-emerald-400' : neg ? 'text-red-400' : 'text-slate-400'
}
function Arrow({ v }: { v: number }) {
@@ -105,9 +90,101 @@ function fmt(v: number | null, digits = 2): string {
return (v >= 0 ? '+' : '') + v.toFixed(digits)
}
// ── Sub-components ────────────────────────────────────────────────────────────
function fmtDateFR(s: string | null): string {
if (!s) return '—'
const [y, m, d] = s.split('-')
return `${d}/${m}/${y}`
}
function RegimeCard({ regime, config }: { regime: Snapshot['regime']; config: InstrumentConfig }) {
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>
)
}
// ── RegimeCard ────────────────────────────────────────────────────────────────
function RegimeCard({
regime, config, signalsAt, dateLabel,
}: {
regime: Snapshot['regime']; config: InstrumentConfig
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',
@@ -117,61 +194,48 @@ function RegimeCard({ regime, config }: { regime: Snapshot['regime']; config: In
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',
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' },
{ 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' },
]
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 className="flex items-center justify-between">
<div className="flex items-center gap-2">
<BarChart2 className={clsx('w-4 h-4', `text-${col}-400`)} />
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Régime</span>
</div>
<span className="text-xs text-slate-600">{dateLabel}</span>
</div>
<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>
{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">
<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>
@@ -183,37 +247,50 @@ function RegimeCard({ regime, config }: { regime: Snapshot['regime']; config: In
)
}
function TrendCard({ trend }: { trend: Snapshot['trend'] }) {
// ── TrendCard ─────────────────────────────────────────────────────────────────
function TrendCard({ trend, dateLabel }: { trend: TrendMetrics; dateLabel: string }) {
const rsi = trend.rsi14_current ?? 50
const rsiColor = rsi > 70 ? 'text-orange-400' : rsi < 30 ? 'text-cyan-400' : 'text-slate-300'
const rsiZone = rsi > 70 ? 'Suracheté' : rsi < 30 ? 'Survendu' : 'Neutre'
const 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 },
{ 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, bold: false },
{ 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
const pct52w = trend.high_52w && trend.low_52w && (trend.high_52w - trend.low_52w) > 0
? ((trend.current_price - trend.low_52w) / (trend.high_52w - trend.low_52w)) * 100
: null
return (
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4 space-y-3">
<div className="flex items-center 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 className="flex items-center justify-between">
<div className="flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-blue-400" />
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Indicateurs de Tendance</span>
</div>
<span className="text-xs text-slate-600">{dateLabel}</span>
</div>
{/* 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">
{trend.current_price?.toLocaleString('fr-FR', { maximumFractionDigits: 4 })}
</span>
</div>
{items.map(group => (
@@ -222,7 +299,7 @@ function TrendCard({ trend }: { trend: Snapshot['trend'] }) {
{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))}>
<span className={clsx('flex items-center gap-1', (row as any).bold ? 'font-semibold' : '', pctColor(row.arrow ?? 0))}>
<Arrow v={row.arrow ?? 0} />
{row.value}
</span>
@@ -231,7 +308,6 @@ function TrendCard({ trend }: { trend: Snapshot['trend'] }) {
</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>
@@ -240,17 +316,14 @@ function TrendCard({ trend }: { trend: Snapshot['trend'] }) {
<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 className="absolute top-0 h-full w-2 bg-white rounded-full transition-all"
style={{ left: `calc(${Math.min(98, rsi)}% - 4px)` }} />
</div>
<div className="flex justify-between text-xs text-slate-600 mt-0.5">
<span>0 Survendu</span><span>50</span><span>Suracheté 100</span>
</div>
</div>
{/* 52W range */}
{pct52w !== null && (
<div>
<div className="flex items-center justify-between text-xs mb-1">
@@ -258,10 +331,7 @@ function TrendCard({ trend }: { trend: Snapshot['trend'] }) {
<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 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>
@@ -270,7 +340,6 @@ function TrendCard({ trend }: { trend: Snapshot['trend'] }) {
</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')}>
@@ -281,24 +350,27 @@ function TrendCard({ trend }: { trend: Snapshot['trend'] }) {
)
}
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' }
// ── EventsCard ────────────────────────────────────────────────────────────────
function EventsCard({ events }: { events: SnapshotEvent[] }) {
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">
<div className="space-y-2 max-h-[360px] overflow-y-auto pr-1">
{events.map((ev, i) => (
<div
key={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}`)}
>
@@ -310,12 +382,10 @@ function EventsCard({ events }: { events: Snapshot['events'] }) {
</div>
<div className="flex items-center gap-2 mt-1 text-xs text-slate-500">
<Clock className="w-3 h-3" />
{ev.date}
{ev.date}{ev.end_date ? `${ev.end_date}` : ''}
{ev.impact_score > 0.7 && <AlertCircle className="w-3 h-3 text-orange-400" />}
</div>
{ev.description && (
<p className="text-xs text-slate-600 mt-1 line-clamp-1">{ev.description}</p>
)}
{ev.description && <p className="text-xs text-slate-600 mt-1 line-clamp-1">{ev.description}</p>}
</div>
))}
</div>
@@ -324,11 +394,11 @@ function EventsCard({ events }: { events: Snapshot['events'] }) {
)
}
// ── NarrativeCard ─────────────────────────────────────────────────────────────
function NarrativeCard({
narrative, loading, onLoad, instrument,
}: {
narrative: string; loading: boolean; onLoad: () => void; instrument: InstrumentConfig
}) {
}: { 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">
@@ -336,27 +406,22 @@ function NarrativeCard({
<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}
<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]}%` }} />
))}
{[95, 88, 70].map(w => <div key={w} className="h-3 bg-slate-800 rounded animate-pulse" style={{ width: `${w}%` }} />)}
</div>
) : (
<div className="text-sm text-slate-600 italic">
Cliquez "Générer" pour obtenir une analyse IA du contexte macro + technique pour {instrument.name}.
Cliquez "Générer" pour obtenir une analyse IA pour {instrument.name}.
</div>
)}
</div>
@@ -366,38 +431,44 @@ function NarrativeCard({
// ── 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: '3mo', label: '3M' }, { key: '6mo', label: '6M' },
{ 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 { 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 [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 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('')
setSelectedDate(null)
api.get(`/instruments/${instrumentId}/snapshot?period=${period}`)
.then(r => setSnapshot(r.data))
.then(r => {
setSnapshot(r.data)
const pd: PriceCandle[] = r.data.price_data
if (pd?.length) setSelectedDate(pd[pd.length - 1].time)
})
.catch(() => {})
.finally(() => setLoading(false))
}, [instrumentId, period])
@@ -410,27 +481,148 @@ export default function InstrumentDashboard() {
.finally(() => setLoadingNarr(false))
}, [instrumentId])
// Group instruments by category
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) ──────────────────────
const { priceMap, indMap, sortedDates, dateIndex } = useMemo(() => {
if (!snapshot) return {
priceMap: {} as Record<string, PriceCandle>,
indMap: {} as Record<string, Record<string, number>>,
sortedDates: [] as string[],
dateIndex: {} as Record<string, number>,
}
const priceMap: Record<string, PriceCandle> = {}
const indMap: Record<string, Record<string, number>> = {}
const sortedDates: string[] = []
const dateIndex: Record<string, number> = {}
for (const c of snapshot.price_data) {
priceMap[c.time] = c
sortedDates.push(c.time)
}
sortedDates.forEach((d, i) => { dateIndex[d] = i })
for (const [key, pts] of Object.entries(snapshot.indicators)) {
for (const pt of pts) {
if (!indMap[pt.time]) indMap[pt.time] = {}
indMap[pt.time][key] = pt.value
}
}
return { priceMap, indMap, sortedDates, dateIndex }
}, [snapshot])
// 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 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
let high52 = candle.high, low52 = candle.low
for (let i = Math.max(0, idx - 252); i <= idx; i++) {
const c = priceMap[sortedDates[i]]
if (c) { if (c.high > high52) high52 = c.high; if (c.low < low52) low52 = c.low }
}
return {
ma50_slope_5d, 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,
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 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
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,
}
}, [effectiveDate, priceMap, indMap, sortedDates, dateIndex, snapshot])
// ── UI helpers ────────────────────────────────────────────────────────────
const grouped = CATEGORY_ORDER.map(cat => ({
cat,
label: CATEGORY_LABELS[cat] ?? cat,
cat, label: CATEGORY_LABELS[cat] ?? cat,
items: instruments.filter(i => i.category === cat),
})).filter(g => g.items.length > 0)
const selected = instruments.find(i => i.id === instrumentId)
const isLastDate = effectiveDate === sortedDates[sortedDates.length - 1]
const dateLabel = effectiveDate ? fmtDateFR(effectiveDate) : '—'
const changeAbs = snapshot?.change_abs
const changePct = snapshot?.change_pct
// Header price: from computed trend (crosshair date) or snapshot
const displayPrice = dateTrend?.current_price ?? snapshot?.current_price
const displayPricePct = 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)}
<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" />
@@ -438,7 +630,6 @@ export default function InstrumentDashboard() {
<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 => (
@@ -446,11 +637,9 @@ export default function InstrumentDashboard() {
<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}
<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',
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'
@@ -467,36 +656,30 @@ export default function InstrumentDashboard() {
)}
</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 && (
{displayPrice !== undefined && (
<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)}%)
{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>
)}
</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'
<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}
@@ -504,42 +687,67 @@ export default function InstrumentDashboard() {
))}
</div>
{/* Description */}
{selected && (
<p className="w-full text-xs text-slate-500">{selected.description}</p>
)}
</div>
{/* ── Loading state ── */}
{/* ── Loading skeleton ── */}
{loading && (
<div className="space-y-4">
<div className="bg-dark-800/60 rounded-xl border border-slate-700/40 animate-pulse" style={{ height: 420 }} />
<div className="bg-dark-800/60 rounded-xl border border-slate-700/40 animate-pulse h-20" />
<div className="grid grid-cols-3 gap-4">
{[1, 2, 3].map(i => (
<div key={i} className="bg-dark-800/60 rounded-xl border border-slate-700/40 animate-pulse h-64" />
))}
{[1, 2, 3].map(i => <div key={i} className="bg-dark-800/60 rounded-xl border border-slate-700/40 animate-pulse h-72" />)}
</div>
</div>
)}
{/* ── Chart ── */}
{/* ── Content ── */}
{!loading && snapshot && (
<>
{/* Chart */}
<InstrumentChart
priceData={snapshot.price_data}
indicators={snapshot.indicators}
events={snapshot.events}
height={420}
onDateHover={handleDateHover}
/>
{/* ── 3-column grid ── */}
{/* Event timeline strip */}
<EventTimelineStrip events={snapshot.events} priceData={snapshot.price_data} />
{/* Snapshot date badge */}
<div className="flex items-center gap-2">
<div className={clsx(
'flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border',
isLastDate
? 'bg-slate-800 border-slate-700/40 text-slate-400'
: 'bg-blue-900/40 border-blue-700/50 text-blue-300'
)}>
<Calendar className="w-3 h-3" />
Snapshot au {dateLabel}
{!isLastDate && <span className="text-blue-500 ml-1"> survol du graphe</span>}
</div>
</div>
{/* 3-column grid */}
<div className="grid grid-cols-3 gap-4">
<RegimeCard regime={snapshot.regime} config={snapshot.instrument} />
<TrendCard trend={snapshot.trend} />
<RegimeCard
regime={snapshot.regime}
config={snapshot.instrument}
signalsAt={dateSignals}
dateLabel={dateLabel}
/>
<TrendCard
trend={dateTrend ?? snapshot.trend}
dateLabel={dateLabel}
/>
<EventsCard events={snapshot.events} />
</div>
{/* ── AI Narrative ── */}
{/* AI Narrative */}
<NarrativeCard
narrative={narrative}
loading={loadingNarr}
@@ -549,7 +757,6 @@ export default function InstrumentDashboard() {
</>
)}
{/* ── 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" />
@@ -557,10 +764,7 @@ export default function InstrumentDashboard() {
</div>
)}
{/* Close dropdown on outside click */}
{selectorOpen && (
<div className="fixed inset-0 z-40" onClick={() => setSelectorOpen(false)} />
)}
{selectorOpen && <div className="fixed inset-0 z-40" onClick={() => setSelectorOpen(false)} />}
</div>
)
}