1260 lines
55 KiB
TypeScript
1260 lines
55 KiB
TypeScript
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
|
import { useParams, useNavigate } from 'react-router-dom'
|
|
import {
|
|
Sparkles, RefreshCw, ChevronDown, TrendingUp, TrendingDown,
|
|
Minus, BarChart2, Clock, Calendar, AlertCircle,
|
|
} from 'lucide-react'
|
|
import axios from 'axios'
|
|
import clsx from 'clsx'
|
|
import InstrumentChart from '../components/InstrumentChart'
|
|
|
|
const api = axios.create({ baseURL: '/api' })
|
|
|
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
|
|
interface CausalTemplate {
|
|
id: number; name: string; category: string; sub_type: string
|
|
instruments: string[]; description: string
|
|
graph_json: { nodes: { id: string; type: string; label: string; instrument?: string }[]; edges: any[] }
|
|
}
|
|
|
|
interface InstrumentConfig {
|
|
id: string; name: string; yf_ticker: string; category: string; currency: string
|
|
description: string
|
|
regime_labels: string[]
|
|
chart: { ma_periods: number[]; show_volume: boolean }
|
|
correlation_instruments: string[]
|
|
}
|
|
|
|
interface PriceCandle { time: string; open: number; high: number; low: number; close: number; volume: number }
|
|
interface LinePoint { time: string; value: number }
|
|
|
|
interface SnapshotEvent {
|
|
date: string; end_date: string | null; title: string; level: string
|
|
category: string; sub_type?: string; description: string; impact_score: number
|
|
expected_value?: string | null; actual_value?: string | null
|
|
surprise_pct?: number | null; unit?: string | null; absorption_pct?: number | null
|
|
}
|
|
|
|
interface RegimeSignals {
|
|
ma50_above_ma200: boolean | null; ma50_slope_pct: number; ma200_slope_pct: number
|
|
momentum_20d_pct: number; dist_ma200_pct: number; vol_ratio_pct: number
|
|
}
|
|
|
|
interface TrendMetrics {
|
|
ma50_slope_5d: number; ma200_slope_20d: number; rsi14_current: number
|
|
atr14_current: number; atr_vs_3m_avg_pct: number; momentum_1m_pct: number
|
|
momentum_3m_pct: number; dist_ma50_pct: number | null; dist_ma200_pct: number | null
|
|
current_price: number; high_52w: number; low_52w: number
|
|
}
|
|
|
|
interface MacroRegime {
|
|
dominant: string
|
|
label: string
|
|
color: string
|
|
emoji: string
|
|
scores: Record<string, number>
|
|
ranked: string[]
|
|
asset_bias: Record<string, any>
|
|
}
|
|
|
|
interface GaugeValue {
|
|
id: string; label: string; value: number | null; change_pct: number | null
|
|
unit: string; bloc: string; note?: string
|
|
}
|
|
|
|
interface MacroGaugeSnap {
|
|
snapshot_date: string
|
|
dominant: string
|
|
regime_scores: Record<string, number>
|
|
gauges: Record<string, GaugeValue>
|
|
}
|
|
|
|
interface Snapshot {
|
|
instrument: InstrumentConfig
|
|
price_data: PriceCandle[]
|
|
indicators: Record<string, LinePoint[]>
|
|
regime: { current: string; confidence: number; scores: Record<string, number>; signals: RegimeSignals }
|
|
macro_regime: MacroRegime
|
|
trend: TrendMetrics
|
|
events: SnapshotEvent[]
|
|
current_price: number; change_pct: number; change_abs: number; period: string
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
const CATEGORY_ORDER = ['equity_index', 'equity_intl', 'metal', 'energy', 'bond', 'credit', 'fx', 'volatility', 'stock', 'crypto']
|
|
const CATEGORY_LABELS: Record<string, string> = {
|
|
equity_index: 'Indices US', equity_intl: 'Intl Equity', metal: 'Métaux',
|
|
energy: 'Énergie', bond: 'Obligataire', credit: 'Crédit',
|
|
fx: 'Forex', volatility: 'Volatilité', stock: 'Actions', crypto: 'Crypto',
|
|
}
|
|
|
|
function regimeColor(label: string): string {
|
|
const l = label.toLowerCase()
|
|
if (/bull|expan|recov|rally|strength|risk.on|inflow|demand|falling|weakness/i.test(l)) return 'emerald'
|
|
if (/bear|crash|crunch|shock|squeeze|fear|risk.off|selloff|crisis|deficit/i.test(l)) return 'red'
|
|
if (/volatil|spike|stress/i.test(l)) return 'orange'
|
|
if (/range|neutral|consolid|compress/i.test(l)) return 'slate'
|
|
return 'blue'
|
|
}
|
|
|
|
function pctColor(v: number | null, inverse = false): string {
|
|
if (v === null || v === undefined) return 'text-slate-400'
|
|
const pos = inverse ? v < 0 : v > 0
|
|
const neg = inverse ? v > 0 : v < 0
|
|
return pos ? 'text-emerald-400' : neg ? 'text-red-400' : 'text-slate-400'
|
|
}
|
|
|
|
function Arrow({ v }: { v: number }) {
|
|
if (v > 0.3) return <TrendingUp className="w-3.5 h-3.5 text-emerald-400" />
|
|
if (v < -0.3) return <TrendingDown className="w-3.5 h-3.5 text-red-400" />
|
|
return <Minus className="w-3.5 h-3.5 text-slate-500" />
|
|
}
|
|
|
|
function fmt(v: number | null, digits = 2): string {
|
|
if (v === null || v === undefined) return '—'
|
|
return (v >= 0 ? '+' : '') + v.toFixed(digits)
|
|
}
|
|
|
|
function fmtDateFR(s: string | null): string {
|
|
if (!s) return '—'
|
|
const [y, m, d] = s.split('-')
|
|
return `${d}/${m}/${y}`
|
|
}
|
|
|
|
function pctN(a: number | undefined, b: number | undefined): number {
|
|
if (a === undefined || b === undefined || b === 0) return 0
|
|
return ((a - b) / b) * 100
|
|
}
|
|
|
|
function isActiveAt(ev: SnapshotEvent, selectedDate: string | null): boolean {
|
|
if (!selectedDate) return false
|
|
if (ev.date > selectedDate) return false
|
|
if (ev.end_date) return ev.end_date >= selectedDate
|
|
const diffDays = (new Date(selectedDate).getTime() - new Date(ev.date).getTime()) / 86400000
|
|
return diffDays <= 30
|
|
}
|
|
|
|
// ── Causal graph config ───────────────────────────────────────────────────────
|
|
|
|
// Maps snapshot event category → causal template category
|
|
const EVENT_TO_CAUSAL_CAT: Record<string, string> = {
|
|
macro_us: 'macro_us',
|
|
macro_eu: 'macro_eu',
|
|
geopolitical: 'geopolitical',
|
|
report: 'report',
|
|
sentiment: 'sentiment',
|
|
commodity: 'commodity',
|
|
fundamental: 'macro_us',
|
|
event_calendar: 'macro_us',
|
|
}
|
|
|
|
// Maps instrument dashboard category → causal lab instrument keys
|
|
const CAT_TO_CAUSAL_INST: Record<string, string[]> = {
|
|
equity_index: ['SP500'],
|
|
equity_intl: ['SP500'],
|
|
metal: ['XAUUSD'],
|
|
energy: ['BRENT'],
|
|
fx: ['EURUSD'],
|
|
bond: ['US10Y', 'EU10Y'],
|
|
credit: ['US10Y'],
|
|
volatility: ['SP500'],
|
|
stock: ['SP500'],
|
|
crypto: [],
|
|
}
|
|
|
|
const CAUSAL_CAT_TW: Record<string, string> = {
|
|
macro_us: 'text-blue-400 border-blue-700/30 bg-blue-900/20',
|
|
macro_eu: 'text-cyan-400 border-cyan-700/30 bg-cyan-900/20',
|
|
geopolitical: 'text-red-400 border-red-700/30 bg-red-900/20',
|
|
report: 'text-amber-400 border-amber-700/30 bg-amber-900/20',
|
|
sentiment: 'text-violet-400 border-violet-700/30 bg-violet-900/20',
|
|
commodity: 'text-orange-400 border-orange-700/30 bg-orange-900/20',
|
|
}
|
|
|
|
// ── Macro regime colour mapping ───────────────────────────────────────────────
|
|
|
|
const MACRO_COLOR_MAP: Record<string, string> = {
|
|
goldilocks: 'emerald',
|
|
desinflation: 'cyan',
|
|
soft_landing: 'blue',
|
|
reflation: 'amber',
|
|
stagflation: 'orange',
|
|
inflation_shock: 'red',
|
|
recession: 'red',
|
|
crise_liquidite: 'red',
|
|
incertain: 'slate',
|
|
}
|
|
|
|
function macroRegimeColor(dominant: string): string {
|
|
return MACRO_COLOR_MAP[dominant] ?? 'slate'
|
|
}
|
|
|
|
// ── RegimeCard ────────────────────────────────────────────────────────────────
|
|
|
|
function RegimeCard({
|
|
regime, macroRegime, signalsAt, dateLabel,
|
|
}: {
|
|
regime: Snapshot['regime']
|
|
macroRegime: MacroRegime | null
|
|
signalsAt: RegimeSignals | null
|
|
dateLabel: string
|
|
}) {
|
|
const signals = signalsAt ?? regime.signals
|
|
const col = regimeColor(regime.current)
|
|
|
|
const borderMap: Record<string, string> = {
|
|
emerald: 'border-emerald-700/40 bg-emerald-950/30',
|
|
red: 'border-red-700/40 bg-red-950/30',
|
|
orange: 'border-orange-700/40 bg-orange-950/30',
|
|
slate: 'border-slate-700/40 bg-slate-900/30',
|
|
blue: 'border-blue-700/40 bg-blue-950/30',
|
|
cyan: 'border-cyan-700/40 bg-cyan-950/30',
|
|
amber: 'border-amber-700/40 bg-amber-950/30',
|
|
}
|
|
|
|
const ma50Color = signals.ma50_above_ma200 ? 'text-emerald-400' : 'text-red-400'
|
|
const volColor = (signals.vol_ratio_pct ?? 0) > 130 ? 'text-orange-400' : (signals.vol_ratio_pct ?? 0) < 70 ? 'text-cyan-400' : 'text-slate-300'
|
|
|
|
const metrics: { label: string; value: string; sub?: string; color: string }[] = [
|
|
{
|
|
label: 'MA50 / MA200',
|
|
value: signals.ma50_above_ma200 === true ? 'Au-dessus' : signals.ma50_above_ma200 === false ? 'En-dessous' : '—',
|
|
sub: signals.ma50_above_ma200 === true ? '↑ Golden cross' : signals.ma50_above_ma200 === false ? '↓ Death cross' : '',
|
|
color: ma50Color,
|
|
},
|
|
{ label: 'Slope MA50 (10j)', value: fmt(signals.ma50_slope_pct) + '%', color: pctColor(signals.ma50_slope_pct) },
|
|
{ label: 'Slope MA200 (10j)', value: fmt(signals.ma200_slope_pct) + '%', color: pctColor(signals.ma200_slope_pct) },
|
|
{ label: 'Momentum 20j', value: fmt(signals.momentum_20d_pct) + '%', color: pctColor(signals.momentum_20d_pct) },
|
|
{
|
|
label: 'Distance MA200',
|
|
value: fmt(signals.dist_ma200_pct) + '%',
|
|
sub: signals.dist_ma200_pct > 10 ? 'Surextension' : signals.dist_ma200_pct < -10 ? 'Survendu' : 'Neutre',
|
|
color: pctColor(signals.dist_ma200_pct),
|
|
},
|
|
{
|
|
label: 'Volatilité ATR',
|
|
value: (signals.vol_ratio_pct ?? 0).toFixed(0) + '%',
|
|
sub: (signals.vol_ratio_pct ?? 0) > 130 ? 'Élevée' : (signals.vol_ratio_pct ?? 0) < 70 ? 'Comprimée' : 'Normale',
|
|
color: volColor,
|
|
},
|
|
]
|
|
|
|
const macroCol = macroRegime ? macroRegimeColor(macroRegime.dominant) : 'slate'
|
|
const top3Macro = macroRegime?.ranked?.slice(0, 3) ?? []
|
|
|
|
return (
|
|
<div className={clsx('rounded-xl border p-4 space-y-3', borderMap[col])}>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<BarChart2 className={clsx('w-4 h-4', `text-${col}-400`)} />
|
|
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Régime</span>
|
|
</div>
|
|
<span className="text-xs text-slate-600">{dateLabel}</span>
|
|
</div>
|
|
|
|
{macroRegime && macroRegime.dominant !== 'incertain' && (
|
|
<div className="rounded-lg border border-slate-700/30 bg-dark-700/40 px-3 py-2 space-y-1.5">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs text-slate-500 uppercase tracking-wide">Cycle macro global</span>
|
|
<span className="text-xs text-slate-600">Fed cycle</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-base">{macroRegime.emoji || '🌍'}</span>
|
|
<span className={clsx('text-sm font-bold', `text-${macroCol}-300`)}>{macroRegime.label || macroRegime.dominant}</span>
|
|
</div>
|
|
{top3Macro.length > 0 && (
|
|
<div className="flex flex-wrap gap-1 pt-0.5">
|
|
{top3Macro.map((key, i) => {
|
|
const score = macroRegime.scores?.[key]
|
|
const c = macroRegimeColor(key)
|
|
return (
|
|
<span key={key} className={clsx(
|
|
'text-xs px-1.5 py-0.5 rounded border',
|
|
i === 0 ? `text-${c}-300 bg-${c}-900/30 border-${c}-700/40` : 'text-slate-500 bg-dark-800/40 border-slate-700/30'
|
|
)}>
|
|
{key} {score !== undefined ? Math.round(score) + '%' : ''}
|
|
</span>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className={clsx('rounded-lg px-3 py-2', `bg-${col}-900/20`)}>
|
|
<div className="text-xs text-slate-500 mb-0.5">Régime technique</div>
|
|
<div className={clsx('text-sm font-bold leading-tight', `text-${col}-300`)}>{regime.current}</div>
|
|
<div className="flex items-center gap-2 mt-1">
|
|
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
|
<div className={clsx('h-full rounded-full', `bg-${col}-500`)}
|
|
style={{ width: `${Math.round(regime.confidence * 100)}%` }} />
|
|
</div>
|
|
<span className="text-xs text-slate-500">{Math.round(regime.confidence * 100)}%</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{metrics.map(m => (
|
|
<div key={m.label} className="rounded-lg bg-dark-700/50 px-2.5 py-2">
|
|
<div className="text-xs text-slate-600 mb-0.5 truncate">{m.label}</div>
|
|
<div className={clsx('text-sm font-bold', m.color)}>{m.value}</div>
|
|
{m.sub && <div className="text-xs text-slate-600 mt-0.5">{m.sub}</div>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── TrendCard ─────────────────────────────────────────────────────────────────
|
|
|
|
function TrendCard({ trend, dateLabel }: { trend: TrendMetrics; dateLabel: string }) {
|
|
const rsi = trend.rsi14_current ?? 50
|
|
const rsiColor = rsi > 70 ? 'text-orange-400' : rsi < 30 ? 'text-cyan-400' : 'text-slate-300'
|
|
const rsiZone = rsi > 70 ? 'Suracheté' : rsi < 30 ? 'Survendu' : 'Neutre'
|
|
|
|
const items = [
|
|
{ group: 'Tendance', rows: [
|
|
{ label: 'Slope MA50 (5j)', value: fmt(trend.ma50_slope_5d) + '%', arrow: trend.ma50_slope_5d },
|
|
{ label: 'Slope MA200 (20j)', value: fmt(trend.ma200_slope_20d) + '%', arrow: trend.ma200_slope_20d },
|
|
{ label: 'Distance MA50', value: trend.dist_ma50_pct != null ? fmt(trend.dist_ma50_pct) + '%' : '—', arrow: trend.dist_ma50_pct ?? 0 },
|
|
{ label: 'Distance MA200', value: trend.dist_ma200_pct != null ? fmt(trend.dist_ma200_pct) + '%' : '—', arrow: trend.dist_ma200_pct ?? 0, bold: true },
|
|
]},
|
|
{ group: 'Momentum', rows: [
|
|
{ label: 'Momentum 1M', value: fmt(trend.momentum_1m_pct) + '%', arrow: trend.momentum_1m_pct },
|
|
{ label: 'Momentum 3M', value: fmt(trend.momentum_3m_pct) + '%', arrow: trend.momentum_3m_pct, bold: true },
|
|
]},
|
|
]
|
|
|
|
const pct52w = trend.high_52w && trend.low_52w && (trend.high_52w - trend.low_52w) > 0
|
|
? ((trend.current_price - trend.low_52w) / (trend.high_52w - trend.low_52w)) * 100 : null
|
|
|
|
return (
|
|
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<TrendingUp className="w-4 h-4 text-blue-400" />
|
|
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Indicateurs de Tendance</span>
|
|
</div>
|
|
<span className="text-xs text-slate-600">{dateLabel}</span>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between bg-dark-700/40 rounded-lg px-3 py-1.5">
|
|
<span className="text-xs text-slate-500">Prix</span>
|
|
<span className="text-sm font-bold text-white">
|
|
{trend.current_price?.toLocaleString('fr-FR', { maximumFractionDigits: 4 })}
|
|
</span>
|
|
</div>
|
|
|
|
{items.map(group => (
|
|
<div key={group.group}>
|
|
<div className="text-xs text-slate-600 uppercase tracking-wide mb-1.5">{group.group}</div>
|
|
{group.rows.map(row => (
|
|
<div key={row.label} className="flex items-center justify-between text-xs py-0.5">
|
|
<span className="text-slate-500">{row.label}</span>
|
|
<span className={clsx('flex items-center gap-1', (row as any).bold ? 'font-semibold' : '', pctColor(row.arrow ?? 0))}>
|
|
<Arrow v={row.arrow ?? 0} />{row.value}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
))}
|
|
|
|
<div className="border-t border-slate-700/30 pt-3">
|
|
<div className="flex items-center justify-between text-xs mb-1.5">
|
|
<span className="text-slate-500">RSI(14)</span>
|
|
<span className={clsx('font-semibold', rsiColor)}>{rsi.toFixed(0)} — {rsiZone}</span>
|
|
</div>
|
|
<div className="relative h-2 bg-slate-800 rounded-full overflow-hidden">
|
|
<div className="absolute left-0 top-0 h-full w-[30%] bg-cyan-900/50" />
|
|
<div className="absolute right-0 top-0 h-full w-[30%] bg-orange-900/50" />
|
|
<div className="absolute top-0 h-full w-2 bg-white rounded-full transition-all"
|
|
style={{ left: `calc(${Math.min(98, rsi)}% - 4px)` }} />
|
|
</div>
|
|
<div className="flex justify-between text-xs text-slate-600 mt-0.5">
|
|
<span>0 Survendu</span><span>50</span><span>Suracheté 100</span>
|
|
</div>
|
|
</div>
|
|
|
|
{pct52w !== null && (
|
|
<div>
|
|
<div className="flex items-center justify-between text-xs mb-1">
|
|
<span className="text-slate-500">Range 52 semaines</span>
|
|
<span className="text-slate-400">{Math.round(pct52w)}e percentile</span>
|
|
</div>
|
|
<div className="relative h-2 bg-slate-800 rounded-full overflow-hidden">
|
|
<div className="absolute top-0 h-full bg-blue-500/60 rounded-full" style={{ width: `${pct52w}%` }} />
|
|
</div>
|
|
<div className="flex justify-between text-xs text-slate-600 mt-0.5">
|
|
<span>{trend.low_52w?.toFixed(2)}</span><span>{trend.high_52w?.toFixed(2)}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center justify-between text-xs">
|
|
<span className="text-slate-500">ATR(14) vs moy. 3M</span>
|
|
<span className={clsx((trend.atr_vs_3m_avg_pct ?? 100) > 130 ? 'text-orange-400' : (trend.atr_vs_3m_avg_pct ?? 100) < 70 ? 'text-cyan-400' : 'text-slate-400')}>
|
|
{(trend.atr_vs_3m_avg_pct ?? 100).toFixed(0)}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── EventsCard ────────────────────────────────────────────────────────────────
|
|
|
|
function EventsCard({ events, selectedDate }: { events: SnapshotEvent[]; selectedDate: string | null }) {
|
|
const navigate = useNavigate()
|
|
const LEVEL_C: Record<string, string> = {
|
|
long: 'text-violet-400 bg-violet-900/30 border-violet-700/30',
|
|
medium: 'text-blue-400 bg-blue-900/30 border-blue-700/30',
|
|
short: 'text-emerald-400 bg-emerald-900/30 border-emerald-700/30',
|
|
}
|
|
|
|
function isActiveAt(ev: SnapshotEvent): boolean {
|
|
if (!selectedDate) return false
|
|
const evStart = ev.date
|
|
const evEnd = ev.end_date
|
|
if (evStart > selectedDate) return false
|
|
if (evEnd) return evEnd >= selectedDate
|
|
// point event: active if within 30 days after
|
|
const diffDays = (new Date(selectedDate).getTime() - new Date(evStart).getTime()) / 86400000
|
|
return diffDays <= 30
|
|
}
|
|
|
|
function surpriseColor(v: number | null | undefined): string {
|
|
if (v === null || v === undefined) return 'text-slate-400'
|
|
if (v > 5) return 'text-emerald-400'
|
|
if (v < -5) return 'text-red-400'
|
|
return 'text-slate-400'
|
|
}
|
|
|
|
return (
|
|
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4 space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<Calendar className="w-4 h-4 text-amber-400" />
|
|
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Événements Macro</span>
|
|
</div>
|
|
{events.length === 0 ? (
|
|
<div className="text-xs text-slate-600 italic py-2">Aucun événement lié trouvé</div>
|
|
) : (
|
|
<div className="space-y-2 max-h-[380px] overflow-y-auto pr-1">
|
|
{events.map((ev, i) => {
|
|
const active = isActiveAt(ev)
|
|
return (
|
|
<div key={i}
|
|
className={clsx(
|
|
'rounded-lg border p-2 cursor-pointer transition-colors',
|
|
active
|
|
? 'border-amber-600/50 bg-amber-900/20 hover:bg-amber-900/30'
|
|
: 'border-slate-700/30 bg-dark-700/40 hover:bg-dark-700/70'
|
|
)}
|
|
onClick={() => navigate(`/timeline?date=${ev.date}`)}
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
{active && <span className="shrink-0 w-1.5 h-1.5 rounded-full bg-amber-400 mt-0.5" />}
|
|
<span className="text-xs font-medium text-slate-300 leading-tight truncate">{ev.title}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
{ev.sub_type && (
|
|
<span className="text-xs px-1 py-0 rounded bg-slate-700/50 text-slate-400 border border-slate-600/30"
|
|
style={{ fontSize: 8, paddingTop: 1, paddingBottom: 1 }}>
|
|
{ev.sub_type}
|
|
</span>
|
|
)}
|
|
<span className={clsx('text-xs px-1.5 py-0.5 rounded border', LEVEL_C[ev.level] ?? 'text-slate-400 bg-slate-800 border-slate-700/30')}>
|
|
{ev.level === 'long' ? 'LT' : ev.level === 'medium' ? 'MT' : 'CT'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Eco calendar fields */}
|
|
{(ev.expected_value || ev.actual_value) && (
|
|
<div className="flex items-center gap-3 mt-1.5 text-xs">
|
|
{ev.expected_value && (
|
|
<span className="text-slate-500">Att. <span className="text-slate-300">{ev.expected_value}</span></span>
|
|
)}
|
|
{ev.actual_value && (
|
|
<span className="text-slate-500">Réel <span className="font-semibold text-white">{ev.actual_value}</span></span>
|
|
)}
|
|
{ev.surprise_pct !== null && ev.surprise_pct !== undefined && (
|
|
<span className={clsx('font-semibold', surpriseColor(ev.surprise_pct))}>
|
|
{ev.surprise_pct > 0 ? '+' : ''}{ev.surprise_pct.toFixed(0)}%
|
|
{ev.surprise_pct > 5 ? ' ↑' : ev.surprise_pct < -5 ? ' ↓' : ''}
|
|
</span>
|
|
)}
|
|
{ev.unit && <span className="text-slate-600">{ev.unit}</span>}
|
|
</div>
|
|
)}
|
|
|
|
{ev.absorption_pct !== null && ev.absorption_pct !== undefined && (
|
|
<div className="mt-1.5">
|
|
<div className="flex items-center justify-between text-xs text-slate-600 mb-0.5">
|
|
<span>Absorption marché</span><span>{ev.absorption_pct}%</span>
|
|
</div>
|
|
<div className="h-1 bg-slate-800 rounded-full overflow-hidden">
|
|
<div className="h-full bg-amber-600/60 rounded-full" style={{ width: `${ev.absorption_pct}%` }} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center gap-2 mt-1 text-xs text-slate-500">
|
|
<Clock className="w-3 h-3" />
|
|
{fmtDateFR(ev.date)}{ev.end_date ? ` → ${fmtDateFR(ev.end_date)}` : ''}
|
|
{ev.impact_score > 0.7 && <AlertCircle className="w-3 h-3 text-orange-400" />}
|
|
</div>
|
|
{ev.description && <p className="text-xs text-slate-600 mt-1 line-clamp-1">{ev.description}</p>}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── NarrativeCard ─────────────────────────────────────────────────────────────
|
|
|
|
function NarrativeCard({ narrative, loading, onLoad, instrument }: {
|
|
narrative: string; loading: boolean; onLoad: () => void; instrument: InstrumentConfig
|
|
}) {
|
|
return (
|
|
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex items-center gap-2">
|
|
<Sparkles className="w-4 h-4 text-violet-400" />
|
|
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Narration IA — {instrument.name}</span>
|
|
</div>
|
|
<button onClick={onLoad} disabled={loading}
|
|
className="flex items-center gap-1.5 text-xs px-3 py-1.5 bg-violet-800/30 hover:bg-violet-800/50 text-violet-300 border border-violet-700/40 rounded-lg transition-colors disabled:opacity-40"
|
|
>
|
|
{loading ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Sparkles className="w-3 h-3" />}
|
|
{loading ? 'Génération...' : narrative ? 'Actualiser' : 'Générer'}
|
|
</button>
|
|
</div>
|
|
{narrative ? (
|
|
<p className="text-sm text-slate-300 leading-relaxed">{narrative}</p>
|
|
) : loading ? (
|
|
<div className="space-y-2">
|
|
{[95, 88, 70].map(w => <div key={w} className="h-3 bg-slate-800 rounded animate-pulse" style={{ width: `${w}%` }} />)}
|
|
</div>
|
|
) : (
|
|
<div className="text-sm text-slate-600 italic">
|
|
Cliquez "Générer" pour une analyse IA pour {instrument.name}.
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Macro gauge helpers ───────────────────────────────────────────────────────
|
|
|
|
const SCENARIO_META: Record<string, { label: string; emoji: string; color: string }> = {
|
|
goldilocks: { label: 'Goldilocks', emoji: '🟢', color: '#10b981' },
|
|
desinflation: { label: 'Désinflation', emoji: '🔵', color: '#3b82f6' },
|
|
soft_landing: { label: 'Soft Landing', emoji: '🔷', color: '#06b6d4' },
|
|
reflation: { label: 'Reflation', emoji: '🟠', color: '#f97316' },
|
|
stagflation: { label: 'Stagflation', emoji: '🟡', color: '#f59e0b' },
|
|
inflation_shock: { label: 'Choc Inflationniste', emoji: '🔥', color: '#dc2626' },
|
|
recession: { label: 'Récession', emoji: '🔴', color: '#ef4444' },
|
|
crise_liquidite: { label: 'Crise de liquidité', emoji: '🟣', color: '#7c3aed' },
|
|
incertain: { label: 'Incertain', emoji: '⬜', color: '#64748b' },
|
|
}
|
|
|
|
function snapToMacroRegime(snap: MacroGaugeSnap): MacroRegime {
|
|
const scores = snap.regime_scores ?? {}
|
|
const meta = SCENARIO_META[snap.dominant] ?? SCENARIO_META.incertain
|
|
const ranked = Object.entries(scores)
|
|
.sort(([, a], [, b]) => b - a)
|
|
.map(([k]) => k)
|
|
return {
|
|
dominant: snap.dominant,
|
|
label: meta.label,
|
|
color: meta.color,
|
|
emoji: meta.emoji,
|
|
scores,
|
|
ranked,
|
|
asset_bias: {},
|
|
}
|
|
}
|
|
|
|
const BLOC_LABELS: Record<string, string> = {
|
|
liquidite: 'Liquidité / Taux',
|
|
credit: 'Crédit / Vol',
|
|
energie: 'Énergie',
|
|
metaux: 'Métaux',
|
|
croissance: 'Croissance US',
|
|
secteurs: 'Secteurs',
|
|
volatilite: 'Volatilité surf.',
|
|
global: 'Global / EM',
|
|
forex_ro: 'Forex Risk-Off',
|
|
derive: 'Dérivés',
|
|
}
|
|
|
|
function MacroGaugePanel({ snap, dateLabel }: { snap: MacroGaugeSnap; dateLabel: string }) {
|
|
const byBloc: Record<string, GaugeValue[]> = {}
|
|
for (const g of Object.values(snap.gauges)) {
|
|
if (g.value === null && g.change_pct === null) continue
|
|
const b = g.bloc ?? 'derive'
|
|
if (!byBloc[b]) byBloc[b] = []
|
|
byBloc[b].push(g)
|
|
}
|
|
const meta = SCENARIO_META[snap.dominant] ?? SCENARIO_META.incertain
|
|
|
|
return (
|
|
<div className="bg-dark-800/60 rounded-xl border border-slate-700/30 p-4 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-base">{meta.emoji}</span>
|
|
<span className="text-sm font-semibold text-white">{meta.label}</span>
|
|
<span className="text-xs text-slate-500">— contexte macro au {dateLabel}</span>
|
|
</div>
|
|
<span className="text-xs text-slate-600">{snap.snapshot_date}</span>
|
|
</div>
|
|
|
|
{/* Regime scores mini bar */}
|
|
<div className="flex gap-1 h-1.5">
|
|
{Object.entries(snap.regime_scores)
|
|
.sort(([,a],[,b]) => b - a)
|
|
.slice(0, 6)
|
|
.map(([k, v]) => (
|
|
<div key={k} title={`${SCENARIO_META[k]?.label ?? k}: ${(v*100).toFixed(0)}%`}
|
|
style={{ width: `${v*100}%`, background: SCENARIO_META[k]?.color ?? '#64748b' }}
|
|
className="rounded-full transition-all"
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Gauges by bloc */}
|
|
<div className="grid grid-cols-2 gap-x-6 gap-y-3">
|
|
{Object.entries(byBloc).map(([bloc, gauges]) => (
|
|
<div key={bloc}>
|
|
<div className="text-xs text-slate-600 uppercase tracking-wide mb-1">{BLOC_LABELS[bloc] ?? bloc}</div>
|
|
<div className="space-y-0.5">
|
|
{gauges.map(g => (
|
|
<div key={g.id} className="flex items-center justify-between text-xs">
|
|
<span className="text-slate-400 truncate max-w-[120px]" title={g.label}>{g.label}</span>
|
|
<div className="flex items-center gap-2">
|
|
{g.value !== null && (
|
|
<span className="text-white font-mono">
|
|
{g.unit === '%' ? g.value.toFixed(2) + '%'
|
|
: g.unit === 'pts' ? g.value.toFixed(1)
|
|
: g.unit === 'ratio' ? g.value.toFixed(3)
|
|
: g.value.toFixed(2)}
|
|
</span>
|
|
)}
|
|
{g.change_pct !== null && g.change_pct !== undefined && (
|
|
<span className={clsx('font-mono', g.change_pct >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
|
{g.change_pct >= 0 ? '+' : ''}{g.change_pct.toFixed(1)}%
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── EventTimeline ─────────────────────────────────────────────────────────────
|
|
|
|
function EventTimeline({
|
|
events, priceData, selectedDate, onNavigate,
|
|
}: {
|
|
events: SnapshotEvent[]
|
|
priceData: PriceCandle[]
|
|
selectedDate: string | null
|
|
onNavigate: (date: string) => void
|
|
}) {
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const [contWidth, setContWidth] = useState(800)
|
|
|
|
useEffect(() => {
|
|
const el = containerRef.current; if (!el) return
|
|
const obs = new ResizeObserver(entries => setContWidth(entries[0].contentRect.width))
|
|
obs.observe(el)
|
|
return () => obs.disconnect()
|
|
}, [])
|
|
|
|
if (!priceData.length || !events.length) {
|
|
return (
|
|
<div ref={containerRef} className="h-20 flex items-center justify-center text-xs text-slate-600 italic">
|
|
Aucun événement sur cette période
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const minDate = priceData[0].time
|
|
const maxDate = priceData[priceData.length - 1].time
|
|
const minTs = new Date(minDate).getTime()
|
|
const maxTs = new Date(maxDate).getTime()
|
|
const PAD = 24
|
|
const usable = Math.max(contWidth - PAD * 2, 1)
|
|
|
|
function tsToX(d: string): number {
|
|
return PAD + ((new Date(d).getTime() - minTs) / (maxTs - minTs)) * usable
|
|
}
|
|
|
|
const visible = events.filter(ev => ev.date >= minDate && ev.date <= maxDate)
|
|
|
|
const HALF_W = 48
|
|
const ROW_H = 28
|
|
const MAX_ROWS = 5
|
|
const rowEnds: number[] = new Array(MAX_ROWS).fill(-Infinity)
|
|
|
|
const placed = visible.map(ev => {
|
|
const x = tsToX(ev.date)
|
|
let row = 0
|
|
for (let r = 0; r < MAX_ROWS; r++) {
|
|
if (rowEnds[r] <= x - HALF_W) { row = r; break }
|
|
row = r
|
|
}
|
|
rowEnds[row] = x + HALF_W
|
|
return { ev, x, row }
|
|
})
|
|
|
|
const maxRow = placed.length ? Math.max(...placed.map(p => p.row)) : 0
|
|
const containerH = (maxRow + 1) * ROW_H + 28
|
|
const crossX = selectedDate && selectedDate >= minDate && selectedDate <= maxDate
|
|
? tsToX(selectedDate) : null
|
|
|
|
return (
|
|
<div ref={containerRef} className="relative w-full overflow-hidden select-none" style={{ height: containerH }}>
|
|
<div className="absolute bottom-7 left-0 right-0 h-px bg-slate-700/40" />
|
|
{crossX !== null && (
|
|
<div className="absolute top-0 bottom-0 w-px bg-blue-400/40 pointer-events-none" style={{ left: crossX }} />
|
|
)}
|
|
{placed.map(({ ev, x, row }) => {
|
|
const active = isActiveAt(ev, selectedDate)
|
|
return (
|
|
<div
|
|
key={`${ev.date}-${ev.title}`}
|
|
onClick={() => onNavigate(ev.date)}
|
|
title={`${ev.title}\n${fmtDateFR(ev.date)}`}
|
|
className="absolute flex flex-col items-center cursor-pointer group"
|
|
style={{ left: x, top: row * ROW_H, transform: 'translateX(-50%)' }}
|
|
>
|
|
<span className={clsx('text-sm leading-none', active ? 'text-amber-400' : 'text-slate-600 group-hover:text-slate-400')}>★</span>
|
|
<span className={clsx(
|
|
'absolute top-full mt-0.5 text-[9px] whitespace-nowrap px-1 rounded bg-dark-800/90 border border-slate-700/40 opacity-0 group-hover:opacity-100 transition-opacity z-10 pointer-events-none',
|
|
active ? 'text-amber-300 border-amber-800/40' : 'text-slate-400'
|
|
)}>
|
|
{ev.title.length > 24 ? ev.title.slice(0, 24) + '…' : ev.title}
|
|
</span>
|
|
</div>
|
|
)
|
|
})}
|
|
<div className="absolute bottom-0 left-0 right-0 flex justify-between px-6">
|
|
<span className="text-[10px] text-slate-700">{fmtDateFR(minDate)}</span>
|
|
<span className="text-[10px] text-slate-700">{fmtDateFR(maxDate)}</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── EventGraphCards ───────────────────────────────────────────────────────────
|
|
|
|
function EventGraphCards({
|
|
events, templates, selectedDate, instrumentCategory,
|
|
}: {
|
|
events: SnapshotEvent[]
|
|
templates: CausalTemplate[]
|
|
selectedDate: string | null
|
|
instrumentCategory: string
|
|
}) {
|
|
const causalInsts = CAT_TO_CAUSAL_INST[instrumentCategory] ?? []
|
|
|
|
const allCats = new Set(
|
|
events.map(ev => EVENT_TO_CAUSAL_CAT[ev.category]).filter(Boolean)
|
|
)
|
|
const activeCats = new Set(
|
|
events.filter(ev => isActiveAt(ev, selectedDate))
|
|
.map(ev => EVENT_TO_CAUSAL_CAT[ev.category]).filter(Boolean)
|
|
)
|
|
|
|
const relevant = templates.filter(t => allCats.has(t.category))
|
|
|
|
if (!relevant.length) {
|
|
return (
|
|
<div className="text-xs text-slate-600 italic py-3">
|
|
Aucun graphe causal associé aux événements de cette période
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
|
{relevant.map(t => {
|
|
const eventActive = activeCats.has(t.category)
|
|
const instrumentTouched = causalInsts.some(ci => t.instruments.includes(ci))
|
|
return (
|
|
<div
|
|
key={t.id}
|
|
className={clsx(
|
|
'rounded-lg border p-2.5 transition-colors',
|
|
eventActive && instrumentTouched
|
|
? 'border-emerald-600/50 bg-emerald-900/20'
|
|
: eventActive
|
|
? 'border-amber-600/40 bg-amber-900/10'
|
|
: 'border-slate-700/30 bg-dark-700/40'
|
|
)}
|
|
>
|
|
<div className="flex items-start justify-between gap-1 mb-1.5">
|
|
<span className={clsx(
|
|
'text-xs font-semibold leading-tight',
|
|
eventActive && instrumentTouched ? 'text-emerald-300'
|
|
: eventActive ? 'text-amber-300'
|
|
: 'text-slate-500'
|
|
)}>
|
|
{t.name}
|
|
</span>
|
|
<span className={clsx(
|
|
'shrink-0 text-[9px] px-1 py-0.5 rounded border',
|
|
CAUSAL_CAT_TW[t.category] ?? 'text-slate-500 border-slate-700/30'
|
|
)}>
|
|
{t.category}
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-wrap gap-1 mt-1 mb-1.5">
|
|
{t.instruments.map(inst => (
|
|
<span key={inst} className={clsx(
|
|
'text-[9px] px-1 py-0 rounded border',
|
|
causalInsts.includes(inst)
|
|
? 'text-emerald-400 bg-emerald-900/30 border-emerald-700/40'
|
|
: 'text-slate-600 border-slate-700/30'
|
|
)}>{inst}</span>
|
|
))}
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className={clsx('text-[9px]', eventActive ? 'text-amber-500' : 'text-slate-700')}>
|
|
{eventActive ? '● actif' : '○ période'}
|
|
</span>
|
|
<span className={clsx(
|
|
'w-2 h-2 rounded-full',
|
|
eventActive && instrumentTouched ? 'bg-emerald-400'
|
|
: eventActive ? 'bg-amber-500'
|
|
: 'bg-slate-700'
|
|
)} />
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── ExplanationScore ──────────────────────────────────────────────────────────
|
|
|
|
function ExplanationScore({
|
|
events, templates, selectedDate, instrumentCategory,
|
|
}: {
|
|
events: SnapshotEvent[]
|
|
templates: CausalTemplate[]
|
|
selectedDate: string | null
|
|
instrumentCategory: string
|
|
}) {
|
|
const causalInsts = CAT_TO_CAUSAL_INST[instrumentCategory] ?? []
|
|
const allCats = new Set(events.map(ev => EVENT_TO_CAUSAL_CAT[ev.category]).filter(Boolean))
|
|
const activeCats = new Set(
|
|
events.filter(ev => isActiveAt(ev, selectedDate))
|
|
.map(ev => EVENT_TO_CAUSAL_CAT[ev.category]).filter(Boolean)
|
|
)
|
|
const periodTmpl = templates.filter(t => allCats.has(t.category))
|
|
const touchedActive = templates.filter(t =>
|
|
activeCats.has(t.category) && causalInsts.some(ci => t.instruments.includes(ci))
|
|
)
|
|
const score = periodTmpl.length > 0 ? (touchedActive.length / periodTmpl.length) * 100 : 0
|
|
const verdict = score >= 60 ? 'Bien expliqué' : score >= 30 ? 'Partiellement' : 'Peu lisible'
|
|
const badgeCls = score >= 60
|
|
? 'text-emerald-400 bg-emerald-900/30 border-emerald-700/40'
|
|
: score >= 30
|
|
? 'text-amber-400 bg-amber-900/30 border-amber-700/40'
|
|
: 'text-slate-400 bg-slate-800/60 border-slate-700/30'
|
|
const barCls = score >= 60 ? 'bg-emerald-500' : score >= 30 ? 'bg-amber-500' : 'bg-slate-600'
|
|
|
|
return (
|
|
<div className="flex items-center gap-3 px-4 py-2.5 rounded-xl border border-slate-700/30 bg-dark-800/50">
|
|
<span className="text-xs text-slate-500 whitespace-nowrap">Note globale</span>
|
|
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
|
<div className={clsx('h-full rounded-full transition-all', barCls)} style={{ width: `${score}%` }} />
|
|
</div>
|
|
<span className={clsx('text-xs font-semibold px-2 py-1 rounded-lg border whitespace-nowrap', badgeCls)}>
|
|
{Math.round(score)}% — {verdict}
|
|
</span>
|
|
{periodTmpl.length > 0 && (
|
|
<span className="text-[10px] text-slate-600 whitespace-nowrap">
|
|
{touchedActive.length}/{periodTmpl.length} graphes
|
|
</span>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Main page ─────────────────────────────────────────────────────────────────
|
|
|
|
const PERIODS = [
|
|
{ key: '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 [selectedDate, setSelectedDate] = useState<string | null>(null)
|
|
const [tabUnder, setTabUnder] = useState<'counters' | 'analyse'>('counters')
|
|
const [templates, setTemplates] = useState<CausalTemplate[]>([])
|
|
const [macroAtDate, setMacroAtDate] = useState<MacroGaugeSnap | null>(null)
|
|
|
|
const instrumentId = id.toUpperCase()
|
|
|
|
useEffect(() => {
|
|
api.get('/instruments').then(r => setInstruments(r.data)).catch(() => {})
|
|
api.get('/causal-lab/templates').then(r => setTemplates(r.data)).catch(() => {})
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
setLoading(true)
|
|
setSnapshot(null)
|
|
setNarrative('')
|
|
setSelectedDate(null)
|
|
setMacroAtDate(null)
|
|
api.get(`/instruments/${instrumentId}/snapshot?period=${period}`)
|
|
.then(r => {
|
|
setSnapshot(r.data)
|
|
const pd: PriceCandle[] = r.data.price_data
|
|
if (pd?.length) setSelectedDate(pd[pd.length - 1].time)
|
|
})
|
|
.catch(() => {})
|
|
.finally(() => setLoading(false))
|
|
}, [instrumentId, period])
|
|
|
|
const loadNarrative = useCallback(() => {
|
|
setLoadingNarr(true)
|
|
api.post(`/instruments/${instrumentId}/narrative`)
|
|
.then(r => setNarrative(r.data.narrative))
|
|
.catch(() => {})
|
|
.finally(() => setLoadingNarr(false))
|
|
}, [instrumentId])
|
|
|
|
const handleDateHover = useCallback((date: string | null) => {
|
|
if (date) setSelectedDate(date)
|
|
}, [])
|
|
|
|
const { priceMap, indMap, sortedDates, dateIndex } = useMemo(() => {
|
|
if (!snapshot) return { priceMap: {} as Record<string, PriceCandle>, indMap: {} as Record<string, Record<string, number>>, sortedDates: [] as string[], dateIndex: {} as Record<string, number> }
|
|
const priceMap: Record<string, PriceCandle> = {}
|
|
const indMap: Record<string, Record<string, number>> = {}
|
|
const sortedDates: string[] = []
|
|
const dateIndex: Record<string, number> = {}
|
|
for (const c of snapshot.price_data) { priceMap[c.time] = c; sortedDates.push(c.time) }
|
|
sortedDates.forEach((d, i) => { dateIndex[d] = i })
|
|
for (const [key, pts] of Object.entries(snapshot.indicators)) {
|
|
for (const pt of pts) { if (!indMap[pt.time]) indMap[pt.time] = {}; indMap[pt.time][key] = pt.value }
|
|
}
|
|
return { priceMap, indMap, sortedDates, dateIndex }
|
|
}, [snapshot])
|
|
|
|
// Fetch macro gauge context when crosshair date changes (after sortedDates is available)
|
|
useEffect(() => {
|
|
if (!selectedDate || !sortedDates.length) return
|
|
const isLast = selectedDate === sortedDates[sortedDates.length - 1]
|
|
if (isLast) { setMacroAtDate(null); return }
|
|
api.get(`/market/macro-gauges/at?date=${selectedDate}`)
|
|
.then(r => { if (r.data?.snapshot_date) setMacroAtDate(r.data) })
|
|
.catch(() => {})
|
|
}, [selectedDate, sortedDates])
|
|
|
|
const effectiveDate = useMemo(() => {
|
|
if (selectedDate && dateIndex[selectedDate] !== undefined) return selectedDate
|
|
return sortedDates[sortedDates.length - 1] ?? null
|
|
}, [selectedDate, sortedDates, dateIndex])
|
|
|
|
const dateTrend = useMemo((): TrendMetrics | null => {
|
|
if (!effectiveDate || !snapshot) return null
|
|
const candle = priceMap[effectiveDate]; if (!candle) return null
|
|
const idx = dateIndex[effectiveDate], price = candle.close, ind = indMap[effectiveDate] ?? {}
|
|
const ma50 = ind.ma50, ma200 = ind.ma200, atr14 = ind.atr14 ?? 0
|
|
const d5 = idx >= 5 ? sortedDates[idx - 5] : null
|
|
const d20 = idx >= 20 ? sortedDates[idx - 20] : null
|
|
const d21 = idx >= 21 ? sortedDates[idx - 21] : null
|
|
const d63 = idx >= 63 ? sortedDates[idx - 63] : null
|
|
let atrSum = 0, atrCnt = 0
|
|
for (let i = Math.max(0, idx - 65); i <= idx; i++) { const v = indMap[sortedDates[i]]?.atr14; if (v) { atrSum += v; atrCnt++ } }
|
|
let high52 = candle.high, low52 = candle.low
|
|
for (let i = Math.max(0, idx - 252); i <= idx; i++) { const c = priceMap[sortedDates[i]]; if (c) { if (c.high > high52) high52 = c.high; if (c.low < low52) low52 = c.low } }
|
|
return {
|
|
ma50_slope_5d: pctN(ma50, d5 ? indMap[d5]?.ma50 : undefined),
|
|
ma200_slope_20d: pctN(ma200, d20 ? indMap[d20]?.ma200 : undefined),
|
|
rsi14_current: ind.rsi14 ?? 50,
|
|
atr14_current: atr14,
|
|
atr_vs_3m_avg_pct: atrCnt > 0 && atr14 ? (atr14 / (atrSum / atrCnt)) * 100 : 100,
|
|
momentum_1m_pct: d21 && priceMap[d21] ? pctN(price, priceMap[d21].close) : 0,
|
|
momentum_3m_pct: d63 && priceMap[d63] ? pctN(price, priceMap[d63].close) : 0,
|
|
dist_ma50_pct: ma50 ? pctN(price, ma50) : null,
|
|
dist_ma200_pct: ma200 ? pctN(price, ma200) : null,
|
|
current_price: price, high_52w: high52, low_52w: low52,
|
|
}
|
|
}, [effectiveDate, priceMap, indMap, sortedDates, dateIndex, snapshot])
|
|
|
|
const dateSignals = useMemo((): RegimeSignals | null => {
|
|
if (!effectiveDate || !snapshot) return null
|
|
const candle = priceMap[effectiveDate]; if (!candle) return null
|
|
const idx = dateIndex[effectiveDate], price = candle.close, ind = indMap[effectiveDate] ?? {}
|
|
const ma50 = ind.ma50, ma200 = ind.ma200, atr14 = ind.atr14
|
|
const d10 = idx >= 10 ? sortedDates[idx - 10] : null
|
|
const d20 = idx >= 20 ? sortedDates[idx - 20] : null
|
|
let atrSum = 0, atrCnt = 0
|
|
for (let i = Math.max(0, idx - 65); i <= idx; i++) { const v = indMap[sortedDates[i]]?.atr14; if (v) { atrSum += v; atrCnt++ } }
|
|
return {
|
|
ma50_above_ma200: ma50 !== undefined && ma200 !== undefined ? ma50 > ma200 : null,
|
|
ma50_slope_pct: pctN(ma50, d10 ? indMap[d10]?.ma50 : undefined),
|
|
ma200_slope_pct: pctN(ma200, d10 ? indMap[d10]?.ma200 : undefined),
|
|
momentum_20d_pct: d20 && priceMap[d20] ? pctN(price, priceMap[d20].close) : 0,
|
|
dist_ma200_pct: ma200 ? pctN(price, ma200) : 0,
|
|
vol_ratio_pct: atrCnt > 0 && atr14 ? (atr14 / (atrSum / atrCnt)) * 100 : 0,
|
|
}
|
|
}, [effectiveDate, priceMap, indMap, sortedDates, dateIndex, snapshot])
|
|
|
|
const grouped = CATEGORY_ORDER.map(cat => ({
|
|
cat, label: CATEGORY_LABELS[cat] ?? cat,
|
|
items: instruments.filter(i => i.category === cat),
|
|
})).filter(g => g.items.length > 0)
|
|
|
|
const selected = instruments.find(i => i.id === instrumentId)
|
|
const isLastDate = effectiveDate === sortedDates[sortedDates.length - 1]
|
|
const dateLabel = effectiveDate ? fmtDateFR(effectiveDate) : '—'
|
|
const displayPrice = dateTrend?.current_price ?? snapshot?.current_price
|
|
|
|
return (
|
|
<div className="p-6 max-w-screen-xl mx-auto space-y-4">
|
|
|
|
{/* ── Header ── */}
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<div className="relative">
|
|
<button onClick={() => setSelectorOpen(s => !s)}
|
|
className="flex items-center gap-2 px-4 py-2 bg-dark-800 border border-slate-700/40 rounded-xl text-white hover:bg-dark-700 transition-colors"
|
|
>
|
|
<BarChart2 className="w-4 h-4 text-blue-400" />
|
|
<span className="font-semibold">{selected?.name ?? instrumentId}</span>
|
|
<span className="text-xs text-slate-500">{selected?.id}</span>
|
|
<ChevronDown className="w-3.5 h-3.5 text-slate-500" />
|
|
</button>
|
|
{selectorOpen && (
|
|
<div className="absolute top-full left-0 mt-1 z-50 bg-dark-800 border border-slate-700/40 rounded-xl shadow-2xl p-3 w-[480px] max-h-[60vh] overflow-y-auto">
|
|
{grouped.map(g => (
|
|
<div key={g.cat} className="mb-3">
|
|
<div className="text-xs text-slate-500 uppercase tracking-wide mb-1.5 px-1">{g.label}</div>
|
|
<div className="grid grid-cols-3 gap-1">
|
|
{g.items.map(inst => (
|
|
<button key={inst.id}
|
|
onClick={() => { navigate(`/instruments/${inst.id}`); setSelectorOpen(false) }}
|
|
className={clsx('text-left px-2.5 py-1.5 rounded-lg text-xs transition-colors',
|
|
inst.id === instrumentId
|
|
? 'bg-blue-800/40 text-blue-300 border border-blue-700/40'
|
|
: 'text-slate-400 hover:bg-dark-700 hover:text-white'
|
|
)}
|
|
>
|
|
<div className="font-semibold">{inst.id}</div>
|
|
<div className="text-slate-500 truncate">{inst.name}</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{selected && (
|
|
<span className="text-xs px-2 py-1 bg-dark-700 border border-slate-700/30 rounded-lg text-slate-400">
|
|
{CATEGORY_LABELS[selected.category] ?? selected.category}
|
|
</span>
|
|
)}
|
|
|
|
{displayPrice !== undefined && (
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-xl font-bold text-white">
|
|
{displayPrice.toLocaleString('fr-FR', { maximumFractionDigits: 4 })}
|
|
</span>
|
|
{isLastDate && snapshot && (
|
|
<span className={clsx('text-sm font-semibold', (snapshot.change_pct ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
|
{(snapshot.change_pct ?? 0) >= 0 ? '+' : ''}{snapshot.change_abs?.toFixed(2)} ({(snapshot.change_pct ?? 0) >= 0 ? '+' : ''}{snapshot.change_pct?.toFixed(2)}%)
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="ml-auto flex items-center gap-2">
|
|
<div className="flex items-center gap-1 bg-dark-800 border border-slate-700/40 rounded-xl p-1">
|
|
{PERIODS.map(p => (
|
|
<button key={p.key} onClick={() => setPeriod(p.key)}
|
|
className={clsx('px-3 py-1 text-xs rounded-lg transition-colors',
|
|
period === p.key ? 'bg-blue-700/50 text-blue-300' : 'text-slate-400 hover:text-white hover:bg-dark-700'
|
|
)}
|
|
>
|
|
{p.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{selected && <p className="w-full text-xs text-slate-500">{selected.description}</p>}
|
|
</div>
|
|
|
|
{/* ── Loading ── */}
|
|
{loading && (
|
|
<div className="space-y-4">
|
|
<div className="bg-dark-800/60 rounded-xl border border-slate-700/40 animate-pulse" style={{ height: 420 }} />
|
|
<div className="bg-dark-800/60 rounded-xl border border-slate-700/40 animate-pulse h-20" />
|
|
<div className="grid grid-cols-3 gap-4">
|
|
{[1, 2, 3].map(i => <div key={i} className="bg-dark-800/60 rounded-xl border border-slate-700/40 animate-pulse h-72" />)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Content ── */}
|
|
{!loading && snapshot && (
|
|
<>
|
|
<InstrumentChart
|
|
priceData={snapshot.price_data}
|
|
indicators={snapshot.indicators}
|
|
events={[]}
|
|
height={420}
|
|
onDateHover={handleDateHover}
|
|
/>
|
|
|
|
{/* Date badge */}
|
|
<div className="flex items-center gap-2">
|
|
<div className={clsx(
|
|
'flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border',
|
|
isLastDate
|
|
? 'bg-slate-800 border-slate-700/40 text-slate-400'
|
|
: 'bg-blue-900/40 border-blue-700/50 text-blue-300'
|
|
)}>
|
|
<Calendar className="w-3 h-3" />
|
|
Snapshot au {dateLabel}
|
|
{!isLastDate && <span className="text-blue-500 ml-1">← survol du graphe</span>}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Tabs sous la courbe ── */}
|
|
<div className="border-b border-slate-700/40 flex gap-1">
|
|
{([
|
|
{ key: 'counters', label: 'Compteurs' },
|
|
{ key: 'analyse', label: 'Analyse de la courbe' },
|
|
] as const).map(t => (
|
|
<button
|
|
key={t.key}
|
|
onClick={() => setTabUnder(t.key)}
|
|
className={clsx(
|
|
'px-4 py-2 text-xs font-medium border-b-2 -mb-px transition-colors',
|
|
tabUnder === t.key
|
|
? 'text-blue-300 border-blue-500'
|
|
: 'text-slate-500 border-transparent hover:text-slate-300 hover:border-slate-600'
|
|
)}
|
|
>
|
|
{t.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{tabUnder === 'counters' && (
|
|
<>
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<RegimeCard
|
|
regime={snapshot.regime}
|
|
macroRegime={macroAtDate ? snapToMacroRegime(macroAtDate) : (snapshot.macro_regime ?? null)}
|
|
signalsAt={dateSignals}
|
|
dateLabel={dateLabel}
|
|
/>
|
|
<TrendCard
|
|
trend={dateTrend ?? snapshot.trend}
|
|
dateLabel={dateLabel}
|
|
/>
|
|
<EventsCard
|
|
events={snapshot.events}
|
|
selectedDate={effectiveDate}
|
|
/>
|
|
</div>
|
|
{macroAtDate && <MacroGaugePanel snap={macroAtDate} dateLabel={dateLabel} />}
|
|
</>
|
|
)}
|
|
|
|
{tabUnder === 'analyse' && (
|
|
<div className="space-y-3">
|
|
{/* Zone haute — frise des événements */}
|
|
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<Calendar className="w-4 h-4 text-amber-400" />
|
|
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Frise des événements</span>
|
|
<span className="text-xs text-slate-600 ml-auto">survol = détail · clic = timeline</span>
|
|
</div>
|
|
<EventTimeline
|
|
events={snapshot.events}
|
|
priceData={snapshot.price_data}
|
|
selectedDate={effectiveDate}
|
|
onNavigate={date => navigate(`/timeline?date=${date}`)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Zone basse — graphes causaux liés aux événements */}
|
|
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<BarChart2 className="w-4 h-4 text-violet-400" />
|
|
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Graphes causaux</span>
|
|
<span className="text-xs text-slate-600 ml-auto">
|
|
vert = actif + touche l'instrument · ambre = actif · gris = période seulement
|
|
</span>
|
|
</div>
|
|
<EventGraphCards
|
|
events={snapshot.events}
|
|
templates={templates}
|
|
selectedDate={effectiveDate}
|
|
instrumentCategory={selected?.category ?? ''}
|
|
/>
|
|
</div>
|
|
|
|
{/* Note globale */}
|
|
<ExplanationScore
|
|
events={snapshot.events}
|
|
templates={templates}
|
|
selectedDate={effectiveDate}
|
|
instrumentCategory={selected?.category ?? ''}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<NarrativeCard
|
|
narrative={narrative}
|
|
loading={loadingNarr}
|
|
onLoad={loadNarrative}
|
|
instrument={snapshot.instrument}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{!loading && !snapshot && (
|
|
<div className="text-center py-20 text-slate-500">
|
|
<BarChart2 className="w-8 h-8 mx-auto mb-3 opacity-30" />
|
|
<p>Aucune donnée disponible pour {instrumentId}</p>
|
|
</div>
|
|
)}
|
|
|
|
{selectorOpen && <div className="fixed inset-0 z-40" onClick={() => setSelectorOpen(false)} />}
|
|
</div>
|
|
)
|
|
}
|