1700 lines
78 KiB
TypeScript
1700 lines
78 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, { TheoPoint } from '../components/InstrumentChart'
|
||
|
||
const api = axios.create({ baseURL: '/api' })
|
||
|
||
// Stable empty array — prevents InstrumentChart useEffect from re-running on every render
|
||
const NO_CHART_EVENTS: never[] = []
|
||
|
||
// ── 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: { from: string; to: string; lag_days?: number; lag_min?: number; sign?: string }[]
|
||
}
|
||
}
|
||
|
||
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 {
|
||
id?: number; template_id?: number | null
|
||
analyzed_instruments?: string | null // comma-sep: "EURUSD,SP500" — set when causal analysis exists
|
||
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
|
||
prediction_json?: string | null // JSON: {node_id: pips} from causal_event_analyses
|
||
actual_json?: string | null // JSON: {instrument: pips} from causal_event_analyses
|
||
activation_score?: number | null // stored directional accuracy from causal_event_analyses
|
||
}
|
||
|
||
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
|
||
// market_events.category → causal_graph_templates.category
|
||
// event_calendar & fundamental cover both US and EU; geopolitical/report/sentiment map 1-to-1
|
||
const EVENT_TO_CAUSAL_CAT: Record<string, string[]> = {
|
||
event_calendar: ['macro_us', 'macro_eu'],
|
||
fundamental: ['macro_us', 'macro_eu'],
|
||
geopolitical: ['geopolitical'],
|
||
report: ['report'],
|
||
sentiment: ['sentiment'],
|
||
commodity: ['commodity'],
|
||
technical: [],
|
||
}
|
||
|
||
// 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: [],
|
||
}
|
||
|
||
// Keyed by market_event.category — same palette as the ★ stars on the chart
|
||
const EV_CAT_TW: Record<string, string> = {
|
||
event_calendar: 'text-amber-400 border-amber-700/40 bg-amber-900/20',
|
||
geopolitical: 'text-red-400 border-red-700/40 bg-red-900/20',
|
||
fundamental: 'text-emerald-400 border-emerald-700/40 bg-emerald-900/20',
|
||
report: 'text-blue-400 border-blue-700/40 bg-blue-900/20',
|
||
sentiment: 'text-violet-400 border-violet-700/40 bg-violet-900/20',
|
||
technical: 'text-cyan-400 border-cyan-700/40 bg-cyan-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>
|
||
)
|
||
}
|
||
|
||
// ── Shared: trading-day-index x-coordinate ────────────────────────────────────
|
||
// Uses the same time scale as LightweightCharts (trading days, weekends skipped).
|
||
// For a date D, snaps to the nearest available trading day index.
|
||
function makeTdToX(priceData: PriceCandle[], width: number): (d: string) => number {
|
||
const dates = priceData.map(c => c.time)
|
||
const N = dates.length
|
||
if (N < 2) return () => 0
|
||
function snap(d: string): number {
|
||
if (d <= dates[0]) return 0
|
||
if (d >= dates[N - 1]) return N - 1
|
||
let lo = 0, hi = N - 1
|
||
while (lo < hi) {
|
||
const mid = (lo + hi) >> 1
|
||
if (dates[mid] < d) lo = mid + 1
|
||
else hi = mid
|
||
}
|
||
return lo
|
||
}
|
||
return (d: string) => (snap(d) / (N - 1)) * width
|
||
}
|
||
|
||
// ── Comprehension scoring ─────────────────────────────────────────────────────
|
||
|
||
function templateAbsorptionDays(tmpl: CausalTemplate): number {
|
||
const maxLag = Math.max(0, ...tmpl.graph_json.edges.map(e => e.lag_days || 0))
|
||
return maxLag > 0 ? maxLag : 30
|
||
}
|
||
|
||
/** Score 0-100 measuring how well the graph predicted actual pips for an instrument. */
|
||
function comprehensionScore(ev: SnapshotEvent, tmpl: CausalTemplate, instrument: string): number | null {
|
||
// Prefer the stored activation_score from causal_event_analyses (consistent with MarketEvents panel)
|
||
if (ev.activation_score != null) return Math.round(ev.activation_score * 100)
|
||
|
||
if (!ev.prediction_json || !ev.actual_json) return null
|
||
try {
|
||
const preds: Record<string, number> = JSON.parse(ev.prediction_json)
|
||
const actuals: Record<string, number> = JSON.parse(ev.actual_json)
|
||
const actual = actuals[instrument] ?? actuals[instrument.toUpperCase()] ?? actuals[instrument.toLowerCase()]
|
||
if (actual == null) return null
|
||
|
||
const outputNode = tmpl.graph_json.nodes.find(n =>
|
||
(n.type === 'market_asset' || n.type === 'output') && n.instrument?.toUpperCase() === instrument.toUpperCase()
|
||
)
|
||
const predicted = outputNode ? (preds[outputNode.id] ?? null) : null
|
||
if (predicted == null) return null
|
||
if (predicted === 0 && actual === 0) return 100
|
||
if (predicted === 0) return 40
|
||
|
||
const sameDir = predicted * actual > 0
|
||
if (!sameDir) return 0
|
||
const ratio = Math.min(Math.abs(actual), Math.abs(predicted)) / Math.max(Math.abs(actual), Math.abs(predicted))
|
||
return Math.round(50 + ratio * 50)
|
||
} catch { return null }
|
||
}
|
||
|
||
// ── CausalFrise ───────────────────────────────────────────────────────────────
|
||
|
||
const FRISE_LANE_H = 24 // px par lane
|
||
const FRISE_CHIP_H = 18 // hauteur d'un chip
|
||
const FRISE_CHIP_PAD = (FRISE_LANE_H - FRISE_CHIP_H) / 2
|
||
const FRISE_AXIS_H = 20 // axe temporel en bas
|
||
const FRISE_MIN_W = 44 // largeur minimale d'un chip (en px)
|
||
const FRISE_POPUP_W = 220 // largeur du popup
|
||
|
||
function CausalFrise({
|
||
events, templates, priceData, selectedDate, causalInsts,
|
||
chartDateToX, chartCanvasLeft, chartReady, causalScores,
|
||
}: {
|
||
events: SnapshotEvent[]
|
||
templates: CausalTemplate[]
|
||
priceData: PriceCandle[]
|
||
selectedDate: string | null
|
||
causalInsts: string[]
|
||
chartDateToX?: (d: string) => number | null
|
||
chartCanvasLeft?: number
|
||
chartReady?: number
|
||
causalScores: Record<number, number | null>
|
||
}) {
|
||
const navigate = useNavigate()
|
||
const containerRef = useRef<HTMLDivElement>(null)
|
||
const [contWidth, setContWidth] = useState(400)
|
||
const [contLeft, setContLeft] = useState(0)
|
||
const [activeChip, setActiveChip] = useState<{
|
||
tmpl: CausalTemplate; ev: SnapshotEvent; chipX: number; chipY: number
|
||
} | null>(null)
|
||
|
||
useEffect(() => {
|
||
const el = containerRef.current; if (!el) return
|
||
const update = () => {
|
||
const rect = el.getBoundingClientRect()
|
||
setContWidth(rect.width)
|
||
setContLeft(rect.left)
|
||
}
|
||
update()
|
||
const obs = new ResizeObserver(update)
|
||
obs.observe(el)
|
||
return () => obs.disconnect()
|
||
}, [chartReady]) // re-measure when chart is ready (layout may have changed)
|
||
|
||
// Close popup on outside click (tiny delay avoids self-close)
|
||
useEffect(() => {
|
||
if (!activeChip) return
|
||
let tid: ReturnType<typeof setTimeout>
|
||
const close = () => setActiveChip(null)
|
||
tid = setTimeout(() => document.addEventListener('mousedown', close), 60)
|
||
return () => { clearTimeout(tid); document.removeEventListener('mousedown', close) }
|
||
}, [activeChip])
|
||
|
||
const linked = events.filter(ev => {
|
||
if (!ev.analyzed_instruments || ev.template_id == null) return false
|
||
const insts = ev.analyzed_instruments.split(',')
|
||
return causalInsts.some(ci => insts.includes(ci))
|
||
})
|
||
|
||
if (!priceData.length || !linked.length) {
|
||
return (
|
||
<div ref={containerRef} className="h-12 flex items-center justify-center text-xs text-slate-600 italic">
|
||
Aucun graphe causal lié pour cet instrument
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const minDate = priceData[0].time
|
||
const maxDate = priceData[priceData.length - 1].time
|
||
const usable = Math.max(contWidth, 1)
|
||
|
||
// If chart coordinate bridge is available, use it for pixel-perfect alignment.
|
||
// chart.timeToCoordinate() gives x relative to chart canvas left edge.
|
||
// We subtract (chartCanvasLeft - contLeft) to convert to frise container coords.
|
||
// canvasOffset > 0 means chart canvas starts to the right of the frise container.
|
||
const canvasOffset = (chartCanvasLeft ?? 0) - contLeft
|
||
const tdToX: (d: string) => number = chartDateToX
|
||
? (d: string) => {
|
||
const x = chartDateToX(d)
|
||
if (x !== null) return x - canvasOffset
|
||
// Fallback for out-of-range dates: clamp to edges
|
||
return d <= minDate ? -canvasOffset : usable - canvasOffset
|
||
}
|
||
: makeTdToX(priceData, usable)
|
||
|
||
// Build chips (one per event × template)
|
||
type Chip = {
|
||
ev: SnapshotEvent; tmpl: CausalTemplate
|
||
x1: number; x2: number; w: number; active: boolean
|
||
}
|
||
const chips: Chip[] = linked
|
||
.filter(ev => ev.date <= maxDate)
|
||
.map(ev => {
|
||
const tmpl = templates.find(t => t.id === ev.template_id)
|
||
if (!tmpl) return null
|
||
const absorptionDays = templateAbsorptionDays(tmpl)
|
||
const endDate = ev.end_date ?? (() => {
|
||
const d = new Date(ev.date); d.setDate(d.getDate() + absorptionDays)
|
||
return d.toISOString().slice(0, 10)
|
||
})()
|
||
const x1 = tdToX(ev.date)
|
||
const raw = tdToX(endDate) - x1
|
||
const w = Math.max(raw, FRISE_MIN_W)
|
||
return { ev, tmpl, x1, x2: x1 + w, w, active: isActiveAt(ev, selectedDate) }
|
||
})
|
||
.filter((c): c is Chip => c !== null)
|
||
.sort((a, b) => a.x1 - b.x1)
|
||
|
||
// Greedy lane assignment — no overlap
|
||
const laneEnds: number[] = []
|
||
const placed = chips.map(chip => {
|
||
const GAP = 3
|
||
let lane = laneEnds.findIndex(end => end + GAP <= chip.x1)
|
||
if (lane === -1) { lane = laneEnds.length; laneEnds.push(0) }
|
||
laneEnds[lane] = chip.x2
|
||
return { ...chip, lane }
|
||
})
|
||
|
||
const numLanes = Math.max(laneEnds.length, 1)
|
||
const containerH = numLanes * FRISE_LANE_H + FRISE_AXIS_H + 4
|
||
|
||
// Month tick marks — skip some if too crowded
|
||
const ticks: { label: string; x: number }[] = []
|
||
{
|
||
const start = new Date(minDate)
|
||
let cur = new Date(start.getFullYear(), start.getMonth() + 1, 1)
|
||
while (cur.toISOString().slice(0, 10) <= maxDate) {
|
||
ticks.push({
|
||
label: cur.toLocaleDateString('fr-FR', {
|
||
month: 'short',
|
||
...(cur.getFullYear() !== start.getFullYear() ? { year: '2-digit' } : {}),
|
||
}),
|
||
x: tdToX(cur.toISOString().slice(0, 10)),
|
||
})
|
||
cur = new Date(cur.getFullYear(), cur.getMonth() + 1, 1)
|
||
}
|
||
}
|
||
// Keep 1 label every ~70px to avoid crowding
|
||
const tickStep = Math.max(1, Math.ceil(ticks.length / (usable / 70)))
|
||
const visTicks = ticks.filter((_, i) => i % tickStep === 0)
|
||
|
||
const crossX = selectedDate && selectedDate >= minDate && selectedDate <= maxDate
|
||
? tdToX(selectedDate) : null
|
||
|
||
return (
|
||
<div
|
||
ref={containerRef}
|
||
className="relative w-full select-none"
|
||
style={{ height: containerH }}
|
||
onClick={() => setActiveChip(null)}
|
||
>
|
||
{/* Month grid lines */}
|
||
{visTicks.map((t, i) => (
|
||
<div key={i} className="absolute top-0 pointer-events-none"
|
||
style={{ left: t.x, bottom: FRISE_AXIS_H, width: 1, background: 'rgba(100,116,139,0.12)' }} />
|
||
))}
|
||
|
||
{/* Crosshair */}
|
||
{crossX !== null && (
|
||
<div className="absolute top-0 bottom-0 pointer-events-none"
|
||
style={{ left: crossX, width: 1, background: 'rgba(96,165,250,0.3)' }} />
|
||
)}
|
||
|
||
{/* Chips */}
|
||
{placed.map(({ ev, tmpl, x1, w, lane, active }) => {
|
||
const catTw = EV_CAT_TW[ev.category] ?? 'text-slate-400 border-slate-700/30 bg-slate-800/40'
|
||
const chipY = lane * FRISE_LANE_H + FRISE_CHIP_PAD
|
||
const isOpen = activeChip?.ev.id === ev.id && activeChip?.tmpl.id === tmpl.id
|
||
const charsFit = Math.floor((w - 18) / 5.5)
|
||
const label = charsFit < 3 ? '' : tmpl.name.length > charsFit
|
||
? tmpl.name.slice(0, charsFit - 1) + '…' : tmpl.name
|
||
|
||
return (
|
||
<div
|
||
key={`${tmpl.id}-${ev.id ?? ev.date}`}
|
||
className={clsx(
|
||
'absolute rounded border cursor-pointer transition-all flex items-center gap-1 overflow-hidden px-1.5',
|
||
'text-[9px] font-medium leading-none',
|
||
active
|
||
? catTw + (isOpen ? ' ring-1 ring-current/40 brightness-125' : ' hover:brightness-110')
|
||
: clsx('text-slate-500 border-slate-700/40 bg-slate-800/25',
|
||
'hover:text-slate-300 hover:border-slate-600/60 hover:bg-slate-800/50'),
|
||
)}
|
||
style={{ left: x1, top: chipY, width: w, height: FRISE_CHIP_H }}
|
||
onMouseDown={e => e.stopPropagation()}
|
||
onClick={e => {
|
||
e.stopPropagation()
|
||
setActiveChip(isOpen ? null : { tmpl, ev, chipX: x1, chipY })
|
||
}}
|
||
title={`${tmpl.name} · ${ev.title}\n${fmtDateFR(ev.date)} → ${ev.end_date ? fmtDateFR(ev.end_date) : '+30j'}`}
|
||
>
|
||
<span className={clsx('w-1.5 h-1.5 rounded-full bg-current shrink-0', !active && 'opacity-40')} />
|
||
{label && <span className="truncate">{label}</span>}
|
||
</div>
|
||
)
|
||
})}
|
||
|
||
{/* Popup */}
|
||
{activeChip && (() => {
|
||
const { tmpl, ev, chipX, chipY } = activeChip
|
||
const popH = 108
|
||
const popTop = chipY - popH - 6 >= 0 ? chipY - popH - 6 : chipY + FRISE_CHIP_H + 4
|
||
const popLeft = Math.max(0, Math.min(chipX, contWidth - FRISE_POPUP_W - 4))
|
||
return (
|
||
<div
|
||
className="absolute z-30 bg-dark-800 border border-slate-600/40 rounded-lg shadow-2xl p-3"
|
||
style={{ left: popLeft, top: popTop, width: FRISE_POPUP_W }}
|
||
onMouseDown={e => e.stopPropagation()}
|
||
onClick={e => e.stopPropagation()}
|
||
>
|
||
<button
|
||
onClick={() => setActiveChip(null)}
|
||
className="absolute top-1.5 right-1.5 w-4 h-4 flex items-center justify-center text-slate-600 hover:text-slate-300 text-xs leading-none"
|
||
>×</button>
|
||
|
||
<div className="flex items-start gap-2 mb-1.5 pr-4">
|
||
<span className="flex-1 text-xs font-semibold text-slate-200 leading-tight line-clamp-2">{tmpl.name}</span>
|
||
<span className={clsx('text-[9px] px-1 py-0.5 rounded border shrink-0',
|
||
EV_CAT_TW[ev.category] ?? 'text-slate-500 border-slate-700/30'
|
||
)}>{ev.category}</span>
|
||
</div>
|
||
|
||
<div className="text-[10px] text-slate-400 truncate mb-1.5">{ev.title}</div>
|
||
|
||
<div className="flex items-center gap-3 mb-2 text-[9px] text-slate-600">
|
||
<span className="flex items-center gap-1">
|
||
<Clock className="w-2.5 h-2.5" />
|
||
{fmtDateFR(ev.date)} → {ev.end_date ? fmtDateFR(ev.end_date) : `+${templateAbsorptionDays(tmpl)}j`}
|
||
</span>
|
||
{ev.impact_score != null && (
|
||
<span className="text-amber-600">★ {ev.impact_score.toFixed(1)}</span>
|
||
)}
|
||
{ev.surprise_pct != null && (
|
||
<span className={ev.surprise_pct > 0 ? 'text-emerald-600' : 'text-red-600'}>
|
||
{ev.surprise_pct > 0 ? '+' : ''}{ev.surprise_pct.toFixed(1)}%
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Précision — causalScores (API fraîche) avec fallback sur activation_score du snapshot */}
|
||
{(() => {
|
||
const snapScore = ev.activation_score != null ? Math.round(ev.activation_score * 100) : null
|
||
const s = ev.id != null ? (causalScores[ev.id] ?? snapScore) : snapScore
|
||
if (s == null) return null
|
||
const barCls = s >= 70 ? 'bg-emerald-500' : s >= 40 ? 'bg-amber-500' : 'bg-red-500'
|
||
const txtCls = s >= 70 ? 'text-emerald-400' : s >= 40 ? 'text-amber-400' : 'text-red-400'
|
||
return (
|
||
<div className="mb-2">
|
||
<div className="flex items-center justify-between text-[9px] text-slate-600 mb-0.5">
|
||
<span>Précision prédiction</span>
|
||
<span className={txtCls}>{s}%</span>
|
||
</div>
|
||
<div className="h-1 bg-slate-800 rounded-full overflow-hidden">
|
||
<div className={clsx('h-full rounded-full', barCls)} style={{ width: `${s}%` }} />
|
||
</div>
|
||
</div>
|
||
)
|
||
})()}
|
||
|
||
<div className="flex gap-1.5">
|
||
<button
|
||
onClick={() => { if (ev.id) navigate(`/market-events?event=${ev.id}`); setActiveChip(null) }}
|
||
className="flex-1 text-[10px] text-slate-400 hover:text-slate-200 bg-slate-700/40 hover:bg-slate-700/60 rounded px-2 py-1 transition-colors"
|
||
>Événement →</button>
|
||
<button
|
||
onClick={() => { navigate(`/causal-lab?template=${tmpl.id}`); setActiveChip(null) }}
|
||
className="flex-1 text-[10px] text-violet-400 hover:text-violet-200 bg-violet-900/20 hover:bg-violet-900/40 rounded px-2 py-1 transition-colors"
|
||
>CausalLab →</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
})()}
|
||
|
||
{/* X-axis */}
|
||
<div className="absolute left-0 right-0 border-t border-slate-700/30" style={{ bottom: FRISE_AXIS_H }}>
|
||
{visTicks.map((t, i) => (
|
||
<span key={i} className="absolute text-[9px] text-slate-600 -translate-x-1/2 pt-0.5" style={{ left: t.x }}>
|
||
{t.label}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── ExplanationScore ──────────────────────────────────────────────────────────
|
||
|
||
function ExplanationScore({
|
||
events, templates, causalInsts, onRefreshDone, onDebugResult, debugInfo, causalScores,
|
||
}: {
|
||
events: SnapshotEvent[]
|
||
templates: CausalTemplate[]
|
||
causalInsts: string[]
|
||
causalScores: Record<number, number | null>
|
||
onRefreshDone?: () => void
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
onDebugResult?: (data: any) => void
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
debugInfo?: any
|
||
}) {
|
||
const [refreshing, setRefreshing] = useState(false)
|
||
const [refreshDone, setRefreshDone] = useState(false)
|
||
|
||
// For each linked event, compute the comprehension score and collect scored ones
|
||
const scored: number[] = []
|
||
const totalLinked: number[] = []
|
||
const debugRows: {id?: number; name: string; pred: string; actual: string; score: number | null; reason: string; surprisePct?: number | null; rawPred?: string}[] = []
|
||
|
||
for (const ev of events) {
|
||
if (!ev.template_id) continue
|
||
const tmpl = templates.find(t => t.id === ev.template_id)
|
||
if (!tmpl) continue
|
||
const inst = causalInsts.find(ci => tmpl.instruments.includes(ci))
|
||
if (!inst) continue
|
||
totalLinked.push(ev.id ?? 0)
|
||
|
||
// Detailed debug per event
|
||
const hasPred = !!(ev.prediction_json && ev.prediction_json !== '{}')
|
||
const hasActual = !!(ev.actual_json && ev.actual_json !== '{}')
|
||
let reason = ''
|
||
if (!hasPred) reason = 'prediction_json vide'
|
||
else if (!hasActual) reason = 'actual_json vide'
|
||
else {
|
||
try {
|
||
const preds = JSON.parse(ev.prediction_json!)
|
||
const actuals = JSON.parse(ev.actual_json!)
|
||
const outputNode = tmpl.graph_json.nodes.find(n =>
|
||
(n.type === 'market_asset' || n.type === 'output') && n.instrument?.toUpperCase() === inst.toUpperCase()
|
||
)
|
||
if (!outputNode) reason = `nœud output "${inst}" introuvable (types: ${[...new Set(tmpl.graph_json.nodes.map(n => n.type))].join(',')})`
|
||
else if (preds[outputNode.id] == null) reason = `preds["${outputNode.id}"] absent`
|
||
else if (actuals[inst] == null && actuals[inst.toUpperCase()] == null) reason = `actuals["${inst}"] absent — clés: ${Object.keys(actuals).join(',')}`
|
||
else reason = 'ok'
|
||
} catch (e) { reason = `parse error: ${e}` }
|
||
}
|
||
debugRows.push({
|
||
id: ev.id, name: ev.title?.slice(0, 30) ?? '?',
|
||
pred: hasPred ? '✓' : '✗',
|
||
actual: hasActual ? '✓' : '✗',
|
||
score: null, reason,
|
||
surprisePct: ev.surprise_pct ?? null,
|
||
rawPred: (ev.prediction_json ?? 'null').slice(0, 30),
|
||
})
|
||
|
||
const snapScore = ev.activation_score != null ? Math.round(ev.activation_score * 100) : null
|
||
const s = ev.id != null ? (causalScores[ev.id] ?? snapScore) : snapScore
|
||
if (debugRows.length > 0) debugRows[debugRows.length - 1].score = s
|
||
if (s != null) scored.push(s)
|
||
}
|
||
|
||
const globalScore = scored.length > 0
|
||
? Math.round(scored.reduce((a, b) => a + b, 0) / scored.length)
|
||
: null
|
||
|
||
const verdict = globalScore == null ? 'En attente'
|
||
: globalScore >= 70 ? 'Bien expliqué' : globalScore >= 40 ? 'Partiellement' : 'Peu lisible'
|
||
const badgeCls = globalScore == null
|
||
? 'text-slate-500 bg-slate-800/60 border-slate-700/30'
|
||
: globalScore >= 70
|
||
? 'text-emerald-400 bg-emerald-900/30 border-emerald-700/40'
|
||
: globalScore >= 40
|
||
? 'text-amber-400 bg-amber-900/30 border-amber-700/40'
|
||
: 'text-red-400 bg-red-900/20 border-red-700/30'
|
||
const barCls = globalScore == null ? 'bg-slate-600'
|
||
: globalScore >= 70 ? 'bg-emerald-500' : globalScore >= 40 ? 'bg-amber-500' : 'bg-red-500'
|
||
|
||
const handleRefresh = async () => {
|
||
onDebugResult?.(null)
|
||
setRefreshing(true)
|
||
try {
|
||
const res = await api.post('/causal-lab/auto-analyze/refresh')
|
||
const data = res.data
|
||
onDebugResult?.(data)
|
||
setRefreshDone(true)
|
||
onRefreshDone?.()
|
||
setTimeout(() => setRefreshDone(false), 8000)
|
||
} catch (err: any) {
|
||
onDebugResult?.({ error: String(err) })
|
||
} finally {
|
||
setRefreshing(false)
|
||
}
|
||
}
|
||
|
||
const needsRefresh = totalLinked.length > 0 && scored.length === 0 && !refreshDone
|
||
|
||
return (
|
||
<div className="space-y-2">
|
||
<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: `${globalScore ?? 0}%` }} />
|
||
</div>
|
||
<span className={clsx('text-xs font-semibold px-2 py-1 rounded-lg border whitespace-nowrap', badgeCls)}>
|
||
{globalScore != null ? `${globalScore}% — ` : ''}{verdict}
|
||
</span>
|
||
<span className="text-[10px] text-slate-600 whitespace-nowrap">
|
||
{scored.length}/{totalLinked.length} graphes
|
||
</span>
|
||
{(needsRefresh || scored.length === 0) && !refreshDone && totalLinked.length > 0 && (
|
||
<button
|
||
onClick={handleRefresh}
|
||
disabled={refreshing}
|
||
className="text-[10px] text-violet-400 hover:text-violet-200 bg-violet-900/20 hover:bg-violet-900/40 border border-violet-800/40 rounded px-2 py-1 whitespace-nowrap disabled:opacity-50 transition-colors"
|
||
>
|
||
{refreshing ? '…' : 'Recalculer'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Debug panel — score diagnostics per event */}
|
||
{debugRows.length > 0 && scored.length === 0 && (
|
||
<div className="rounded-lg border border-slate-700/30 bg-dark-900/60 p-3 text-[10px] font-mono">
|
||
<div className="text-slate-500 mb-1.5 uppercase tracking-wide">Diagnostic scores ({debugRows.length} graphes)</div>
|
||
{debugRows.map((r, i) => (
|
||
<div key={i} className="flex gap-2 py-0.5 border-b border-slate-800/40 last:border-0">
|
||
<span className={clsx('shrink-0 w-4', r.pred === '✓' ? 'text-emerald-600' : 'text-red-600')}>{r.pred}</span>
|
||
<span className={clsx('shrink-0 w-4', r.actual === '✓' ? 'text-emerald-600' : 'text-red-600')}>{r.actual}</span>
|
||
<span className="text-slate-400 w-32 shrink-0 truncate" title={r.name}>{r.name}</span>
|
||
<span className={clsx('flex-1', r.reason === 'ok' ? 'text-emerald-600' : 'text-amber-600')}>{r.reason}</span>
|
||
{r.surprisePct != null
|
||
? <span className="text-cyan-600 shrink-0">{r.surprisePct > 0 ? '+' : ''}{r.surprisePct.toFixed(1)}%</span>
|
||
: <span className="text-red-800 shrink-0">surp?</span>}
|
||
{r.score != null && <span className="text-violet-400 shrink-0">{r.score}%</span>}
|
||
<span className="text-slate-700 shrink-0 ml-1" title="raw prediction_json">{r.rawPred}</span>
|
||
</div>
|
||
))}
|
||
{debugInfo && (
|
||
<div className="mt-2 pt-2 border-t border-slate-800/40 text-slate-600">
|
||
{debugInfo.error
|
||
? <span className="text-red-500">Erreur: {debugInfo.error}</span>
|
||
: <span>Refresh: {debugInfo.refreshed} ok / {debugInfo.failed} err / {debugInfo.total} total</span>
|
||
}
|
||
{debugInfo.details?.map((d: any, i: number) => (
|
||
<div key={i} className="mt-1 border-t border-slate-800/40 pt-1">
|
||
<span className={d.result === 'ok' ? 'text-emerald-500' : 'text-red-500'}>{d.result}</span>
|
||
<span className="text-slate-500 ml-1">#{d.event_id} {(d.event_name ?? '').slice(0, 20)}</span>
|
||
{d.run_error && <span className="text-red-400 ml-1">err:{d.run_error.slice(0, 40)}</span>}
|
||
<div className="ml-2 text-slate-600">
|
||
surp:{d.surprise_pct != null ? Number(d.surprise_pct).toFixed(1)+'%' : `null (act=${d.actual_value} exp=${d.expected_value})`}
|
||
{' | '}inputs:{JSON.stringify(d.inputs ?? {})}
|
||
{' | '}pred_keys:[{(d.node_keys ?? []).join(',')}]
|
||
{' | '}actual:{JSON.stringify(d.actual_moves ?? {})}
|
||
</div>
|
||
</div>
|
||
))}
|
||
{/* DB state: show rows not in refresh (non-empty pred/actual but still 'vide' in snapshot) */}
|
||
{debugInfo.db_state?.length > 0 && (
|
||
<div className="mt-2 pt-1 border-t border-slate-800/40">
|
||
<div className="text-slate-600 mb-0.5">DB state ({debugInfo.db_state.length} analyses)</div>
|
||
{debugInfo.db_state.map((r: any, i: number) => (
|
||
<div key={i} className="text-slate-700">
|
||
#{r.event_id} {(r.event_name ?? '').slice(0, 18)}
|
||
<span className={r.pred_empty ? 'text-red-800' : 'text-emerald-800'}> pred:{r.pred_empty ? '∅' : `[${(r.pred_keys ?? []).join(',')}]`}</span>
|
||
<span className={r.actual_empty ? 'text-red-800' : 'text-emerald-800'}> act:{r.actual_empty ? '∅' : JSON.stringify(r.actual_keys)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Main page ─────────────────────────────────────────────────────────────────
|
||
|
||
const PERIODS = [
|
||
{ key: '5d', label: '5D' },
|
||
{ key: '1mo', label: '1M' },
|
||
{ key: '3mo', label: '3M' },
|
||
{ key: '6mo', label: '6M' },
|
||
{ key: '1y', label: '1Y' },
|
||
{ key: '2y', label: '2Y' },
|
||
{ key: '5y', label: '5Y' },
|
||
]
|
||
|
||
export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { instrumentIdProp?: string; isVisible?: boolean } = {}) {
|
||
const { id: paramId = localStorage.getItem('last_instrument') || 'EURUSD=X' } = useParams<{ id: string }>()
|
||
const navigate = useNavigate()
|
||
const [period, setPeriod] = useState('1y')
|
||
const [chartStyle, setChartStyle] = useState<'candles' | 'line'>('candles')
|
||
const [instruments, setInstruments] = useState<InstrumentConfig[]>([])
|
||
const [snapshot, setSnapshot] = useState<Snapshot | null>(null)
|
||
const [narrative, setNarrative] = useState('')
|
||
const [loading, setLoading] = useState(false)
|
||
const [refreshDebug, setRefreshDebug] = useState<any>(null)
|
||
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 [causalScores, setCausalScores] = useState<Record<number, number | null>>({}) // eventId → activation_score*100
|
||
const [macroAtDate, setMacroAtDate] = useState<MacroGaugeSnap | null>(null)
|
||
const [theoryCurve, setTheoryCurve] = useState<TheoPoint[] | null>(null)
|
||
const [loadingTheory, setLoadingTheory] = useState(false)
|
||
const [showTheory, setShowTheory] = useState(false)
|
||
// Chart coordinate bridge — lets CausalFrise align with the chart's x-axis
|
||
const chartDateToXRef = useRef<((d: string) => number | null) | null>(null)
|
||
const chartCanvasLeftRef = useRef<number>(0)
|
||
const [chartReady, setChartReady] = useState(0) // bump to trigger frise re-render
|
||
|
||
// instrumentIdProp is set when mounted as a keep-alive tab (so URL params don't bleed across instances)
|
||
const instrumentId = (instrumentIdProp ?? paramId).toUpperCase()
|
||
|
||
useEffect(() => {
|
||
api.get('/instruments').then(r => setInstruments(r.data)).catch(() => {})
|
||
api.get('/causal-lab/templates').then(r => setTemplates(r.data)).catch(() => {})
|
||
}, [])
|
||
|
||
// Silent refresh when the tab becomes visible again (e.g. after analysis updated from MarketEvents)
|
||
const mountedRef = useRef(false)
|
||
useEffect(() => {
|
||
if (!mountedRef.current) { mountedRef.current = true; return }
|
||
if (isVisible) fetchSnapshotSilent()
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [isVisible])
|
||
|
||
const fetchSnapshot = useCallback(() => {
|
||
setLoading(true)
|
||
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])
|
||
|
||
// Silent re-fetch (no loading spinner) — used after Recalculer so ExplanationScore keeps its state
|
||
const fetchSnapshotSilent = useCallback(() => {
|
||
api.get(`/instruments/${instrumentId}/snapshot?period=${period}`)
|
||
.then(r => { setSnapshot(r.data) })
|
||
.catch(() => {})
|
||
}, [instrumentId, period])
|
||
|
||
// Fetch causal scores directly from the analyses API (not via snapshot) so the popup
|
||
// always shows the DB-stored activation_score, independent of snapshot staleness
|
||
const refreshCausalScores = useCallback((events: SnapshotEvent[]) => {
|
||
const ids = events.map(e => e.id).filter(Boolean) as number[]
|
||
if (!ids.length) return
|
||
Promise.all(ids.map(id =>
|
||
api.get(`/causal-lab/analyses?market_event_id=${id}&limit=1`)
|
||
.then(r => ({ id, score: r.data[0]?.activation_score ?? null }))
|
||
.catch(() => ({ id, score: null }))
|
||
)).then(results => {
|
||
setCausalScores(Object.fromEntries(results.map(r => [r.id, r.score != null ? Math.round(r.score * 100) : null])))
|
||
})
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
setSnapshot(null)
|
||
setNarrative('')
|
||
setSelectedDate(null)
|
||
setMacroAtDate(null)
|
||
setTheoryCurve(null)
|
||
setShowTheory(false)
|
||
chartDateToXRef.current = null
|
||
setChartReady(0)
|
||
fetchSnapshot()
|
||
}, [instrumentId, period])
|
||
|
||
// Refresh causal scores whenever the snapshot events change
|
||
useEffect(() => {
|
||
if (snapshot?.events?.length) refreshCausalScores(snapshot.events)
|
||
}, [snapshot?.events, refreshCausalScores])
|
||
|
||
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 handleChartReady = useCallback((fn: ((d: string) => number | null) | null, left: number) => {
|
||
chartDateToXRef.current = fn
|
||
chartCanvasLeftRef.current = left
|
||
setChartReady(k => k + 1)
|
||
}, [])
|
||
|
||
const toggleTheory = useCallback(() => {
|
||
if (showTheory) {
|
||
setShowTheory(false)
|
||
setTheoryCurve(null)
|
||
return
|
||
}
|
||
if (theoryCurve) { setShowTheory(true); return }
|
||
setLoadingTheory(true)
|
||
api.get(`/instruments/${instrumentId}/theoretical-curve?period=${period}`)
|
||
.then(r => { setTheoryCurve(r.data); setShowTheory(true) })
|
||
.catch(() => {})
|
||
.finally(() => setLoadingTheory(false))
|
||
}, [showTheory, theoryCurve, instrumentId, period])
|
||
|
||
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">
|
||
{/* Chart style toggle */}
|
||
<div className="flex items-center bg-dark-800 border border-slate-700/40 rounded-xl p-1">
|
||
<button
|
||
onClick={() => setChartStyle('candles')}
|
||
title="Chandeliers"
|
||
className={clsx('p-1.5 rounded-lg transition-colors', chartStyle === 'candles' ? 'bg-blue-700/50 text-blue-300' : 'text-slate-500 hover:text-white hover:bg-dark-700')}
|
||
><BarChart2 className="w-3.5 h-3.5" /></button>
|
||
<button
|
||
onClick={() => setChartStyle('line')}
|
||
title="Courbe lisse"
|
||
className={clsx('p-1.5 rounded-lg transition-colors', chartStyle === 'line' ? 'bg-blue-700/50 text-blue-300' : 'text-slate-500 hover:text-white hover:bg-dark-700')}
|
||
><TrendingUp className="w-3.5 h-3.5" /></button>
|
||
</div>
|
||
{/* Period selector */}
|
||
<div className="flex items-center gap-1 bg-dark-800 border border-slate-700/40 rounded-xl p-1">
|
||
{PERIODS.map(p => (
|
||
<button key={p.key} onClick={() => setPeriod(p.key)}
|
||
className={clsx('px-3 py-1 text-xs rounded-lg transition-colors',
|
||
period === p.key ? 'bg-blue-700/50 text-blue-300' : 'text-slate-400 hover:text-white hover:bg-dark-700'
|
||
)}
|
||
>
|
||
{p.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{selected && <p className="w-full text-xs text-slate-500">{selected.description}</p>}
|
||
</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={NO_CHART_EVENTS}
|
||
height={420}
|
||
chartType={chartStyle}
|
||
onDateHover={handleDateHover}
|
||
theoryCurve={showTheory && theoryCurve ? theoryCurve : undefined}
|
||
onChartReady={handleChartReady}
|
||
/>
|
||
|
||
{/* 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' && (() => {
|
||
const causalInsts = CAT_TO_CAUSAL_INST[selected?.category ?? ''] ?? []
|
||
const theoPt = effectiveDate && theoryCurve
|
||
? theoryCurve.find(p => p.date === effectiveDate) ?? null
|
||
: null
|
||
return (
|
||
<div className="space-y-3">
|
||
{/* Frise des graphes causaux (inclut l'event) */}
|
||
<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">Frise des graphes</span>
|
||
<button
|
||
onClick={toggleTheory}
|
||
disabled={loadingTheory}
|
||
className={`ml-auto px-2.5 py-1 rounded text-xs font-medium border transition-colors ${
|
||
showTheory
|
||
? 'bg-violet-700/60 border-violet-600/60 text-violet-200'
|
||
: 'bg-dark-900/40 border-slate-600/40 text-slate-400 hover:border-violet-600/40 hover:text-violet-300'
|
||
}`}
|
||
>
|
||
{loadingTheory ? '↻ Chargement…' : showTheory ? '⟁ Théorie ON' : '⟁ Courbe théorique'}
|
||
</button>
|
||
</div>
|
||
<CausalFrise
|
||
events={snapshot.events}
|
||
templates={templates}
|
||
priceData={snapshot.price_data}
|
||
selectedDate={effectiveDate}
|
||
causalInsts={causalInsts}
|
||
chartDateToX={chartDateToXRef.current ?? undefined}
|
||
chartCanvasLeft={chartCanvasLeftRef.current}
|
||
chartReady={chartReady}
|
||
causalScores={causalScores}
|
||
/>
|
||
</div>
|
||
|
||
{/* Décomposition théorique au curseur */}
|
||
{showTheory && theoPt && (
|
||
<div className="rounded-xl border border-violet-800/40 bg-violet-950/20 p-4">
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<span className="text-violet-400 text-xs">⟁</span>
|
||
<span className="text-xs font-semibold text-violet-300 uppercase tracking-wide">
|
||
Contributions théoriques — {dateLabel}
|
||
</span>
|
||
<span className={`ml-auto text-sm font-mono font-bold ${theoPt.cumulative_pips >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
||
{theoPt.cumulative_pips >= 0 ? '+' : ''}{theoPt.cumulative_pips} pips
|
||
</span>
|
||
</div>
|
||
{theoPt.contributions.length === 0 ? (
|
||
<p className="text-xs text-slate-500 italic">Aucun événement actif à cette date.</p>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{theoPt.contributions.map((c, i) => (
|
||
<div key={i} className="flex items-center gap-2 text-xs">
|
||
<div className="flex-1 min-w-0">
|
||
<span className="text-slate-300 font-medium truncate block">{c.event_name}</span>
|
||
<span className="text-slate-500">{c.template_name} · depuis {c.event_date} · {Math.round(c.decay_factor * 100)}% actif</span>
|
||
</div>
|
||
<span className={`font-mono font-semibold shrink-0 ${c.pips >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
||
{c.pips >= 0 ? '+' : ''}{c.pips} pip
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{showTheory && !theoPt && theoryCurve && (
|
||
<div className="rounded-xl border border-violet-800/30 bg-violet-950/10 p-3 text-xs text-slate-500 text-center">
|
||
⟁ Aucune contribution théorique pour cette date
|
||
</div>
|
||
)}
|
||
|
||
{/* Note globale */}
|
||
<ExplanationScore
|
||
events={snapshot.events}
|
||
templates={templates}
|
||
causalInsts={causalInsts}
|
||
causalScores={causalScores}
|
||
onRefreshDone={fetchSnapshotSilent}
|
||
onDebugResult={setRefreshDebug}
|
||
debugInfo={refreshDebug}
|
||
/>
|
||
</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>
|
||
)
|
||
}
|