Files
OpenFin/frontend/src/pages/MarketEvents.tsx
2026-06-29 21:40:44 +02:00

1646 lines
80 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback, useRef } from 'react'
import { useSearchParams, useNavigate } from 'react-router-dom'
import {
Search, RefreshCw, Plus, Trash2, Edit3, Sparkles, ExternalLink,
ChevronDown, ChevronUp, Loader2, CheckCircle, AlertCircle, X,
SlidersHorizontal, BarChart2, Star, Users
} from 'lucide-react'
import clsx from 'clsx'
// ── Types ─────────────────────────────────────────────────────────────────────
interface SourceRef {
title: string
source: string
url: string
date: string
original_score: number
}
interface InstrumentImpact {
id: number
instrument_id: string
impact_score: number
direction: 'bullish' | 'bearish' | 'neutral' | 'depends_on_outcome'
rationale: string
confidence: number
ai_generated: number
manually_adjusted: number
adjusted_score: number | null
adjusted_direction: string | null
override_rationale: string
}
interface MarketEvent {
id: number
name: string
start_date: string
end_date: string | null
level: 'long' | 'medium' | 'short'
category: string
sub_type: string
description: string
market_impact: string
affected_assets: string[]
impact_score: number
absorption_pct: number | null
source_refs: SourceRef[]
origin?: string
created_at?: string
impacts?: InstrumentImpact[]
evaluated?: boolean
}
interface CausalAnalysis {
id: number
market_event_id: number
template_id: number
template_name: string
instrument: string
activation_score: number | null
analyzed_at: string
inputs_json: Record<string, number>
prediction_json: Record<string, number>
actual_json: Record<string, number>
drift_json?: Record<string, any>
graph_json?: { nodes: GraphNode[]; edges: GraphEdge[] }
}
interface TemplateRef {
id: number
name: string
category: string
instruments: string[]
}
interface GraphNode { id: string; label: string; type: string; x: number; y: number; unit?: string; instrument?: string }
interface GraphEdge { from: string; to: string; style?: string; sign?: string }
interface GraphData { nodes: GraphNode[]; edges: GraphEdge[] }
// ── Constants ─────────────────────────────────────────────────────────────────
const LEVEL_COLORS: Record<string, string> = {
long: 'text-purple-400',
medium: 'text-amber-400',
short: 'text-emerald-400',
}
const ORIGIN_META: Record<string, { label: string; icon: string; color: string; description: string }> = {
bootstrap_macro: { label: 'Bootstrap macro', icon: '📚', color: 'text-slate-400', description: 'Données historiques macro/géopolitiques chargées au démarrage' },
bootstrap_eco: { label: 'Bootstrap éco', icon: '📅', color: 'text-amber-400', description: 'Calendrier économique FRED historique (FOMC, CPI, NFP…)' },
bootstrap_ma: { label: 'Bootstrap MA', icon: '📈', color: 'text-cyan-400', description: 'Signal technique MA détecté sur données historiques (yfinance)' },
bootstrap_legacy: { label: 'Bootstrap legacy', icon: '🗄️', color: 'text-slate-500', description: 'Données initiales (bootstrap, origine exacte inconnue)' },
detector_news: { label: 'News RSS', icon: '📰', color: 'text-red-400', description: 'Généré depuis un flux RSS — validé par IA (GPT-4o-mini)' },
detector_eco: { label: 'Surprise FRED', icon: '📊', color: 'text-amber-400', description: 'Surprise économique FRED détectée par le cycle (z-score > seuil)' },
detector_technical: { label: 'Signal technique', icon: '📐', color: 'text-cyan-400', description: 'Croisement MA50/MA100/MA200 détecté automatiquement (yfinance)' },
detector_report: { label: 'Rapport instit.', icon: '📋', color: 'text-blue-400', description: 'Rapport institutionnel haute importance (COT, EIA, Fed…)' },
manual: { label: 'Manuel', icon: '✏️', color: 'text-emerald-400', description: 'Créé manuellement dans l\'interface' },
unknown: { label: 'Inconnu', icon: '❓', color: 'text-slate-600', description: 'Origine non tracée (données antérieures au versioning)' },
}
const DIR_COLORS: Record<string, string> = {
bullish: 'text-emerald-400',
bearish: 'text-red-400',
neutral: 'text-slate-400',
depends_on_outcome: 'text-amber-400',
}
const DIR_ICONS: Record<string, string> = {
bullish: '▲', bearish: '▼', neutral: '●', depends_on_outcome: '◆',
}
const LEVELS = ['long', 'medium', 'short']
const ALL_INSTRUMENTS = [
'SPY','QQQ','IWM','EEM','EFA','GLD','SLV','USO','UNG','TLT',
'HYG','EURUSD=X','USDJPY=X','GBPUSD=X','VXX','AAPL','NVDA','GS','XOM','BTC-USD',
]
// ── Helpers ───────────────────────────────────────────────────────────────────
function ScoreBar({ score, max = 1 }: { score: number; max?: number }) {
const pct = Math.round((score / max) * 100)
const color = score >= 0.7 ? 'bg-red-500' : score >= 0.5 ? 'bg-amber-500' : 'bg-emerald-500'
return (
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden">
<div className={`h-full rounded-full ${color}`} style={{ width: `${pct}%` }} />
</div>
<span className="text-xs text-slate-400 w-8 text-right">{score.toFixed(2)}</span>
</div>
)
}
// ── Instrument impact row (inline editable) ───────────────────────────────────
function ImpactRow({
imp, eventId, onUpdated, onDeleted,
}: {
imp: InstrumentImpact
eventId: number
onUpdated: () => void
onDeleted: () => void
}) {
const [editing, setEditing] = useState(false)
const [score, setScore] = useState(String(imp.adjusted_score ?? imp.impact_score))
const [dir, setDir] = useState(imp.adjusted_direction || imp.direction)
const [note, setNote] = useState(imp.override_rationale || '')
const [saving, setSaving] = useState(false)
const effectiveScore = imp.adjusted_score ?? imp.impact_score
const effectiveDir = imp.adjusted_direction || imp.direction
const save = async () => {
setSaving(true)
await fetch(`/api/market-events/${eventId}/impacts/${imp.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ adjusted_score: +score, adjusted_direction: dir, override_rationale: note }),
})
setSaving(false)
setEditing(false)
onUpdated()
}
const del = async () => {
if (!confirm(`Supprimer l'impact sur ${imp.instrument_id} ?`)) return
await fetch(`/api/market-events/${eventId}/impacts/${imp.id}`, { method: 'DELETE' })
onDeleted()
}
return (
<div className="border-b border-slate-800/60 last:border-0">
<div className="flex items-center gap-3 py-2 px-3 hover:bg-slate-800/20">
{/* Ticker */}
<span className="font-mono text-sm text-slate-200 w-24 shrink-0">{imp.instrument_id}</span>
{/* Direction */}
<span className={clsx('text-sm font-bold w-6 shrink-0', DIR_COLORS[effectiveDir])}>
{DIR_ICONS[effectiveDir] ?? '●'}
</span>
{/* Score bar */}
<div className="w-28 shrink-0">
<ScoreBar score={effectiveScore} />
</div>
{/* Confidence */}
<span className="text-xs text-slate-500 w-10 shrink-0">{Math.round(imp.confidence * 100)}%</span>
{/* Rationale */}
<span className="text-xs text-slate-400 flex-1 min-w-0 truncate">{imp.rationale}</span>
{/* AI badge */}
{imp.ai_generated ? (
<span className="text-xs text-blue-400 shrink-0">AI</span>
) : (
<span className="text-xs text-slate-600 shrink-0">manual</span>
)}
{imp.manually_adjusted ? <span className="text-xs text-amber-400 shrink-0">adj.</span> : null}
{/* Actions */}
<button onClick={() => setEditing(x => !x)} className="text-slate-500 hover:text-slate-300 shrink-0"><Edit3 size={12} /></button>
<button onClick={del} className="text-slate-600 hover:text-red-400 shrink-0"><Trash2 size={12} /></button>
</div>
{editing && (
<div className="px-3 pb-3 grid grid-cols-3 gap-2 bg-slate-800/30">
<label className="flex flex-col gap-1">
<span className="text-xs text-slate-500">Score ajusté</span>
<input type="range" min="0" max="1" step="0.05"
value={score} onChange={e => setScore(e.target.value)}
className="w-full accent-amber-400" />
<span className="text-xs text-center text-amber-300">{(+score).toFixed(2)}</span>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-slate-500">Direction</span>
<select value={dir} onChange={e => setDir(e.target.value)}
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm">
{['bullish','bearish','neutral','depends_on_outcome'].map(d => (
<option key={d} value={d}>{d}</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-slate-500">Note</span>
<input value={note} onChange={e => setNote(e.target.value)}
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm"
placeholder="Raison de l'ajustement..." />
</label>
<div className="col-span-3 flex gap-2 mt-1">
<button onClick={save} disabled={saving}
className="flex items-center gap-1 bg-emerald-700 hover:bg-emerald-600 text-white text-xs rounded px-3 py-1">
{saving ? <Loader2 size={11} className="animate-spin" /> : <CheckCircle size={11} />}
Sauvegarder
</button>
<button onClick={() => setEditing(false)} className="text-xs text-slate-500 hover:text-slate-300 px-2">Annuler</button>
</div>
</div>
)}
</div>
)
}
// ── Add instrument panel ──────────────────────────────────────────────────────
function AddInstrumentPanel({ eventId, onAdded }: { eventId: number; onAdded: () => void }) {
const [open, setOpen] = useState(false)
const [ticker, setTicker] = useState('')
const [score, setScore] = useState(0.5)
const [dir, setDir] = useState('neutral')
const [rationale, setRat] = useState('')
const [saving, setSaving] = useState(false)
const submit = async () => {
if (!ticker) return
setSaving(true)
await fetch(`/api/market-events/${eventId}/impacts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ instrument_id: ticker, impact_score: score, direction: dir, rationale, confidence: 0.8 }),
})
setSaving(false)
setOpen(false)
setTicker('')
setRat('')
onAdded()
}
if (!open) {
return (
<button onClick={() => setOpen(true)}
className="flex items-center gap-1.5 text-xs text-slate-500 hover:text-emerald-400 py-2 px-3 border-t border-slate-800/60">
<Plus size={12} /> Ajouter instrument
</button>
)
}
return (
<div className="border-t border-slate-800/60 px-3 py-3 bg-slate-800/30 grid grid-cols-4 gap-2">
<select value={ticker} onChange={e => setTicker(e.target.value)}
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm col-span-1">
<option value="">Ticker...</option>
{ALL_INSTRUMENTS.map(t => <option key={t} value={t}>{t}</option>)}
</select>
<select value={dir} onChange={e => setDir(e.target.value)}
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm">
{['bullish','bearish','neutral','depends_on_outcome'].map(d => <option key={d} value={d}>{d}</option>)}
</select>
<div className="flex flex-col gap-0.5">
<input type="range" min="0" max="1" step="0.05" value={score}
onChange={e => setScore(+e.target.value)} className="accent-emerald-400" />
<span className="text-xs text-center text-emerald-300">{score.toFixed(2)}</span>
</div>
<input value={rationale} onChange={e => setRat(e.target.value)} placeholder="Rationale..."
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm" />
<div className="col-span-4 flex gap-2">
<button onClick={submit} disabled={saving || !ticker}
className="flex items-center gap-1 bg-emerald-700 hover:bg-emerald-600 disabled:bg-slate-700 disabled:text-slate-500 text-white text-xs rounded px-3 py-1">
{saving ? <Loader2 size={11} className="animate-spin" /> : <Plus size={11} />} Ajouter
</button>
<button onClick={() => setOpen(false)} className="text-xs text-slate-500 hover:text-slate-300 px-2">Annuler</button>
</div>
</div>
)
}
// ── CausalGraphMini — SVG inline du graphe instancié ─────────────────────────
function CausalGraphMini({ nodes, edges, nodeValues }: {
nodes: GraphNode[]
edges: GraphEdge[]
nodeValues: Record<string, number>
}) {
if (!nodes.length) return null
const xs = nodes.map(n => n.x)
const ys = nodes.map(n => n.y)
const minX = Math.min(...xs) - 70
const maxX = Math.max(...xs) + 70
const minY = Math.min(...ys) - 30
const maxY = Math.max(...ys) + 30
const vw = Math.max(maxX - minX, 200)
const vh = Math.max(maxY - minY, 100)
const nodeMap = Object.fromEntries(nodes.map(n => [n.id, n]))
const BOX_W = 90, BOX_H = 36, SHORTEN = 21
return (
<svg viewBox={`${minX} ${minY} ${vw} ${vh}`} className="w-full" style={{ maxHeight: 400, minHeight: 200 }}>
<defs>
{(['positive', 'negative', 'neutral'] as const).map(s => (
<marker key={s} id={`cgm-arr-${s}`} markerWidth={7} markerHeight={7} refX={6} refY={3} orient="auto">
<path d="M0,0 L0,6 L7,3 z" fill={s === 'positive' ? '#14b8a6' : s === 'negative' ? '#f97316' : '#64748b'} />
</marker>
))}
</defs>
{edges.map((e, i) => {
const from = nodeMap[e.from], to = nodeMap[e.to]
if (!from || !to) return null
const dx = to.x - from.x, dy = to.y - from.y
const len = Math.sqrt(dx * dx + dy * dy) || 1
const sign = e.sign || 'neutral'
const color = sign === 'positive' ? '#14b8a6' : sign === 'negative' ? '#f97316' : '#64748b'
return (
<line key={i}
x1={from.x + (dx / len) * SHORTEN} y1={from.y + (dy / len) * SHORTEN}
x2={to.x - (dx / len) * (SHORTEN + 5)} y2={to.y - (dy / len) * (SHORTEN + 5)}
stroke={color} strokeWidth={1.5}
strokeDasharray={e.style === 'dashed' ? '5,3' : undefined}
markerEnd={`url(#cgm-arr-${sign})`} />
)
})}
{nodes.map(n => {
const val = nodeValues[n.id]
const isOut = n.type === 'market_asset' || n.type === 'output'
const isIn = n.type === 'input'
const fill = isOut ? (val > 0 ? '#052e16' : val < 0 ? '#450a0a' : '#1e293b') : '#0f172a'
const stroke = isIn ? '#3b82f6'
: isOut ? (val > 0 ? '#16a34a' : val < 0 ? '#dc2626' : '#64748b') : '#475569'
const valColor = val > 0 ? '#4ade80' : val < 0 ? '#f87171' : '#94a3b8'
const lbl = n.label.length > 14 ? n.label.slice(0, 13) + '…' : n.label
const valStr = val !== undefined
? `${val > 0 ? '+' : ''}${val.toFixed(n.unit === 'pips' ? 0 : 1)}${n.unit === 'pips' ? 'p' : n.unit === '%' ? '%' : ''}`
: '—'
return (
<g key={n.id} transform={`translate(${n.x},${n.y})`}>
<rect x={-BOX_W/2} y={-BOX_H/2} width={BOX_W} height={BOX_H} rx={4}
fill={fill} stroke={stroke} strokeWidth={isOut ? 2 : 1} />
<text y={-7} textAnchor="middle" fill="#94a3b8" fontSize={8} fontFamily="ui-monospace,monospace">{lbl}</text>
<text y={9} textAnchor="middle" fill={val !== undefined ? valColor : '#475569'}
fontSize={10} fontWeight="bold" fontFamily="ui-monospace,monospace">{valStr}</text>
</g>
)
})}
</svg>
)
}
// ── Event detail panel (right side) ──────────────────────────────────────────
function EventDetail({
event, onClose, onUpdated, onDeleted,
}: {
event: MarketEvent
onClose: () => void
onUpdated: (ev: MarketEvent) => void
onDeleted: (id: number) => void
}) {
const navigate = useNavigate()
const [detail, setDetail] = useState<MarketEvent>(event)
const [editing, setEditing] = useState(false)
const [editData, setEditData] = useState<Partial<MarketEvent>>({})
const [saving, setSaving] = useState(false)
const [analyses, setAnalyses] = useState<CausalAnalysis[]>([])
// Inline analysis
const [templates, setTemplates] = useState<TemplateRef[]>([])
const [selTmpl, setSelTmpl] = useState(0)
const [loadingRec, setLoadingRec] = useState(false)
const [loadingInst, setLoadingInst] = useState(false)
const [loadingAn, setLoadingAn] = useState(false)
const [loadingCreate, setLoadingCreate] = useState(false)
const [recMsg, setRecMsg] = useState<string | null>(null)
const [noTemplateFound, setNoTemplateFound] = useState(false)
const [instInputs, setInstInputs] = useState<Record<string, number>>({})
const [instMsg, setInstMsg] = useState<string | null>(null)
const [anResult, setAnResult] = useState<{
score: number | null
preds: Record<string, number>
actuals: Record<string, number>
drift?: Record<string, { pre_pips: number | null; post_pips: number | null; lag_min: number; lag_days: number }>
pricesMode?: string
lagMin?: number
lagDays?: number
lagDebug?: { output_ids: string[]; edges_to_output: number; edges_with_lag_days: number; used_all_edges: boolean }
} | null>(null)
const [graphData, setGraphData] = useState<GraphData | null>(null)
const loadDetail = useCallback(async () => {
const r = await fetch(`/api/market-events/${event.id}`)
if (r.ok) {
const d = await r.json()
setDetail(d)
}
}, [event.id])
const loadAnalyses = useCallback(async () => {
const r = await fetch(`/api/causal-lab/analyses?market_event_id=${event.id}&limit=20`)
if (r.ok) {
const data: CausalAnalysis[] = await r.json()
setAnalyses(data)
if (data.length > 0) {
const latest = data[0]
if (latest.template_id) setSelTmpl(latest.template_id)
if (Object.keys(latest.inputs_json || {}).length > 0) setInstInputs(latest.inputs_json)
const driftRaw = latest.drift_json || {}
// drift_json peut être { EURUSD: {...}, XAUUSD: {...} } (nouveau) ou { post_pips: ..., ... } (ancien)
const isDriftByInst = driftRaw && !('post_pips' in driftRaw) && Object.keys(driftRaw).length > 0
const driftByInst = isDriftByInst
? driftRaw
: latest.instrument && Object.keys(driftRaw).length > 0
? { [latest.instrument]: driftRaw }
: undefined
setAnResult({
score: latest.activation_score,
preds: latest.prediction_json || {},
actuals: latest.actual_json || {},
drift: driftByInst,
pricesMode: undefined,
lagMin: undefined,
lagDays: undefined,
lagDebug: undefined,
})
if (latest.graph_json?.nodes?.length) {
setGraphData(latest.graph_json as GraphData)
}
}
}
}, [event.id])
useEffect(() => {
loadDetail()
loadAnalyses()
fetch('/api/causal-lab/templates').then(r => r.json()).then(d => setTemplates(Array.isArray(d) ? d : [])).catch(() => {})
}, [loadDetail, loadAnalyses])
const saveEdit = async () => {
setSaving(true)
const payload = { ...detail, ...editData, source_refs: detail.source_refs ?? [] }
const r = await fetch(`/api/market-events/${event.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
setSaving(false)
if (r.ok) {
setEditing(false)
await loadDetail()
onUpdated({ ...detail, ...editData } as MarketEvent)
}
}
const del = async () => {
if (!confirm(`Supprimer "${detail.name}" ?`)) return
await fetch(`/api/market-events/${event.id}`, { method: 'DELETE' })
onDeleted(event.id)
onClose()
}
const instantiate = async (tmplId: number) => {
if (!tmplId) return
setLoadingInst(true); setInstMsg(null); setInstInputs({})
try {
const r = await fetch('/api/causal-lab/instantiate', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ market_event_id: detail.id, template_id: tmplId }),
})
const d = await r.json()
if (!r.ok) throw new Error(d.detail || r.statusText)
setInstInputs(d.inputs || {})
setInstMsg(d.rationale || null)
} catch (e: any) { setInstMsg(`${(e as any).message}`) }
finally { setLoadingInst(false) }
}
const recommendTemplate = async () => {
setLoadingRec(true); setRecMsg(null); setAnResult(null); setGraphData(null)
setInstInputs({}); setInstMsg(null); setNoTemplateFound(false)
try {
const r = await fetch('/api/causal-lab/recommend', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ market_event_id: detail.id }),
})
const d = await r.json()
if (!r.ok) throw new Error(d.detail || r.statusText)
const rec = d.recommendation
if (rec?.template_id) {
setSelTmpl(rec.template_id)
setRecMsg(`${rec.template_name}${rec.confidence ? ` (${Math.round(rec.confidence * 100)}%)` : ''}`)
await instantiate(rec.template_id)
} else {
// Aucun template adapté — création automatique d'un nouveau
setRecMsg('Aucun template adapté — création en cours…')
setNoTemplateFound(false)
setLoadingCreate(true)
try {
const cr = await fetch('/api/causal-lab/create-from-event', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ market_event_id: detail.id }),
})
const cd = await cr.json()
if (!cr.ok) throw new Error(cd.detail || cr.statusText)
const newTmplId = cd.id ?? cd.template?.id ?? cd.template_id
if (newTmplId) {
setTemplates(prev => [...prev, { id: newTmplId, name: cd.name, category: cd.category, graph_json: cd.graph_json, instruments: cd.instruments, calibration_json: {} }])
setSelTmpl(newTmplId)
setRecMsg(`✦ Nouveau template créé : ${cd.name ?? 'template'}`)
await instantiate(newTmplId)
} else {
setRecMsg('Template créé mais ID manquant')
}
} catch (ce: any) {
setRecMsg(`✗ Création échouée : ${ce.message}`)
setNoTemplateFound(true)
} finally {
setLoadingCreate(false)
}
}
} catch (e: any) { setRecMsg(`${(e as any).message}`) }
finally { setLoadingRec(false) }
}
const createTemplateFromEvent = async () => {
setLoadingCreate(true); setRecMsg(null); setNoTemplateFound(false)
try {
const r = await fetch('/api/causal-lab/create-from-event', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ market_event_id: detail.id }),
})
const d = await r.json()
if (!r.ok) throw new Error(d.detail || r.statusText)
const tmplRes = await fetch('/api/causal-lab/templates')
const tmplData = await tmplRes.json()
setTemplates(Array.isArray(tmplData) ? tmplData : [])
if (d.id) {
setSelTmpl(d.id)
setRecMsg(`✓ Template créé : ${d.name}`)
await instantiate(d.id)
}
} catch (e: any) { setRecMsg(`${(e as any).message}`) }
finally { setLoadingCreate(false) }
}
const deleteAnalysis = async (analysisId: number) => {
if (!confirm('Supprimer cette instanciation ?')) return
await fetch(`/api/causal-lab/analyses/${analysisId}`, { method: 'DELETE' })
setAnResult(null); setGraphData(null); setInstInputs({})
await loadAnalyses()
}
const runAnalysis = async () => {
if (!selTmpl) return
setLoadingAn(true); setAnResult(null); setGraphData(null)
try {
const r = await fetch('/api/causal-lab/analyze', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ market_event_id: detail.id, template_id: selTmpl, inputs: instInputs, coef_overrides: {} }),
})
const d = await r.json()
if (!r.ok) throw new Error(d.detail || r.statusText)
const freshResult = {
score: d.activation?.score ?? null,
preds: d.node_values ?? {},
actuals: d.actual_moves ?? {},
drift: d.drift_by_inst ?? (d.drift ? { [d.instrument]: d.drift } : undefined),
pricesMode: d.prices_mode,
lagMin: d.effective_lag_min,
lagDays: d.effective_lag_days,
lagDebug: d.lag_debug,
}
setAnResult(freshResult)
if (d.graph_json?.nodes?.length) setGraphData(d.graph_json)
await loadAnalyses()
setAnResult(freshResult) // loadAnalyses() écrase anResult — on restaure le résultat frais
} catch (e: any) { setAnResult({ score: null, preds: {}, actuals: {} }) }
finally { setLoadingAn(false) }
}
const evalScore = detail.impact_score
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Header */}
<div className="flex items-start gap-3 px-5 py-4 border-b border-slate-700/40 shrink-0">
<div className="flex-1 min-w-0">
{editing ? (
<input
defaultValue={detail.name}
onChange={e => setEditData(d => ({ ...d, name: e.target.value }))}
className="bg-dark-900 border border-amber-500/50 rounded px-3 py-1.5 text-slate-100 font-bold text-sm w-full"
/>
) : (
<h2 className="text-slate-100 font-bold text-sm leading-snug">{detail.name}</h2>
)}
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
<span className={clsx('text-xs font-semibold', LEVEL_COLORS[detail.level])}>{detail.level}</span>
<span className="text-xs text-slate-500">{detail.start_date}</span>
{detail.end_date && <span className="text-xs text-slate-600"> {detail.end_date}</span>}
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<button onClick={() => { setEditing(x => !x); setEditData({}) }}
className={clsx('p-1.5 rounded hover:bg-slate-700/50', editing ? 'text-amber-400' : 'text-slate-500 hover:text-slate-300')}>
<Edit3 size={14} />
</button>
<button onClick={del} className="p-1.5 rounded text-slate-600 hover:text-red-400 hover:bg-red-900/20">
<Trash2 size={14} />
</button>
<button onClick={onClose} className="p-1.5 rounded text-slate-500 hover:text-slate-300 hover:bg-slate-700/50">
<X size={14} />
</button>
</div>
</div>
{/* Scrollable body */}
<div className="flex-1 overflow-y-auto space-y-4 px-5 py-4">
{/* Impact score + absorption */}
<div className="grid grid-cols-2 gap-3">
<div className="bg-slate-800/40 rounded-lg p-3">
<div className="text-xs text-slate-500 mb-1">Score impact</div>
<ScoreBar score={evalScore} />
</div>
{detail.absorption_pct != null && (
<div className="bg-slate-800/40 rounded-lg p-3">
<div className="text-xs text-slate-500 mb-1">Absorption marché</div>
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden">
<div className="h-full bg-blue-500 rounded-full" style={{ width: `${detail.absorption_pct}%` }} />
</div>
<span className="text-xs text-blue-300">{detail.absorption_pct}%</span>
</div>
</div>
)}
</div>
{/* Description */}
{editing ? (
<textarea
defaultValue={detail.description}
onChange={e => setEditData(d => ({ ...d, description: e.target.value }))}
rows={3}
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-2 text-slate-300 text-sm resize-none"
/>
) : detail.description ? (
<p className="text-sm text-slate-300 leading-relaxed">{detail.description}</p>
) : null}
{editing ? (
<div className="grid grid-cols-2 gap-2">
<div>
<div className="text-xs text-slate-500 mb-1">Niveau</div>
<select defaultValue={detail.level}
onChange={e => setEditData(d => ({ ...d, level: e.target.value as any }))}
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm">
{LEVELS.map(l => <option key={l} value={l}>{l}</option>)}
</select>
</div>
<div>
<div className="text-xs text-slate-500 mb-1">Score impact</div>
<input type="number" step="0.05" min="0" max="1" defaultValue={detail.impact_score}
onChange={e => setEditData(d => ({ ...d, impact_score: +e.target.value }))}
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm" />
</div>
</div>
) : null}
{editing && (
<div className="flex gap-2">
<button onClick={saveEdit} disabled={saving}
className="flex items-center gap-1.5 bg-emerald-700 hover:bg-emerald-600 text-white text-sm rounded-lg px-4 py-1.5">
{saving ? <Loader2 size={13} className="animate-spin" /> : <CheckCircle size={13} />} Sauvegarder
</button>
<button onClick={() => setEditing(false)} className="text-sm text-slate-500 hover:text-slate-300 px-3">Annuler</button>
</div>
)}
{/* Origin + Sources */}
<div>
<div className="text-xs text-slate-500 uppercase tracking-wide mb-2">Provenance</div>
{/* Origin badge */}
{(() => {
const meta = ORIGIN_META[detail.origin ?? 'unknown'] ?? ORIGIN_META['unknown']
return (
<div className="flex items-start gap-2 bg-slate-800/50 rounded-lg px-3 py-2 mb-2 border border-slate-700/30">
<span className="text-base shrink-0">{meta.icon}</span>
<div className="flex-1 min-w-0">
<div className={`text-xs font-semibold ${meta.color}`}>{meta.label}</div>
<div className="text-xs text-slate-500 mt-0.5">{meta.description}</div>
</div>
</div>
)
})()}
{/* Source refs */}
{(detail.source_refs?.length ?? 0) > 0 ? (
<div className="space-y-1.5">
{detail.source_refs!.map((ref, i) => (
<div key={i} className="flex items-start gap-2 bg-slate-800/30 rounded-lg px-3 py-2 border border-slate-800/60">
<div className="flex-1 min-w-0">
<div className="text-xs text-slate-200 font-medium">{ref.title}</div>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
<span className="text-xs text-slate-500">{ref.source}</span>
<span className="text-xs text-slate-600">{ref.date}</span>
{ref.original_score > 0 && (
<span className="text-xs text-slate-600">
score brut: <span className="text-slate-400">{ref.original_score.toFixed(2)}</span>
</span>
)}
</div>
</div>
{ref.url && (
<a href={ref.url} target="_blank" rel="noreferrer"
className="text-slate-500 hover:text-blue-400 shrink-0 mt-0.5">
<ExternalLink size={12} />
</a>
)}
</div>
))}
</div>
) : (
<div className="text-xs text-slate-600 italic px-3 py-2 bg-slate-800/20 rounded-lg border border-slate-800/40">
Aucune source liée cet événement ne référence pas de document ou d'article spécifique.
</div>
)}
</div>
{/* Causal graphs */}
<div className="space-y-3">
<div className="text-xs text-slate-500 uppercase tracking-wide">
Graphes causaux
{analyses.length > 0 && <span className="text-slate-600 ml-1.5 normal-case">({analyses.length})</span>}
</div>
{/* Existing analyses */}
{analyses.length > 0 && (
<div className="space-y-1.5">
{analyses.map(a => {
const score = a.activation_score != null ? Math.round(a.activation_score * 100) : null
const topPreds = Object.entries(a.prediction_json || {})
.filter(([k]) => !k.includes('error'))
.sort(([, v1], [, v2]) => Math.abs(v2) - Math.abs(v1))
.slice(0, 3)
return (
<div key={a.id} className="flex items-stretch gap-1">
<button
onClick={() => navigate(`/causal-lab?template=${a.template_id}`)}
className="flex-1 text-left bg-slate-800/40 hover:bg-slate-800/70 border border-slate-700/40 hover:border-blue-700/40 rounded-lg px-3 py-2 transition-colors"
>
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-slate-200 truncate">{a.template_name}</span>
{score != null && (
<span className={clsx(
'text-xs font-mono px-1.5 py-0.5 rounded shrink-0 ml-2',
score >= 70 ? 'bg-emerald-500/20 text-emerald-400' :
score >= 40 ? 'bg-amber-500/20 text-amber-400' :
'bg-slate-700/40 text-slate-400'
)}>{score}%</span>
)}
</div>
<div className="flex items-center gap-2 flex-wrap mt-0.5">
<span className="text-xs text-slate-500">{a.instrument}</span>
{topPreds.map(([k, v]) => (
<span key={k} className={clsx('text-xs font-mono', v > 0 ? 'text-emerald-500' : 'text-red-500')}>
{k}: {v > 0 ? '+' : ''}{Math.round(v)}
</span>
))}
</div>
</button>
<button
onClick={() => deleteAnalysis(a.id)}
className="px-2 rounded-lg text-slate-600 hover:text-red-400 hover:bg-red-900/20 border border-slate-700/40 transition-colors shrink-0"
title="Supprimer cette instanciation"
>
<Trash2 size={11} />
</button>
</div>
)
})}
</div>
)}
{/* Inline analysis panel */}
<div className="bg-slate-800/30 rounded-lg border border-slate-700/30 p-3 space-y-2">
<div className="text-xs text-slate-400 font-medium">
{analyses.length === 0 ? 'Aucun graphe lancez une analyse' : 'Nouvelle analyse'}
</div>
{/* AI recommend + template picker */}
<div className="flex gap-1.5">
<button
onClick={recommendTemplate}
disabled={loadingRec}
className="flex items-center gap-1 text-xs bg-purple-700/40 hover:bg-purple-700/60 border border-purple-700/40 text-purple-300 rounded px-2 py-1.5 shrink-0 disabled:opacity-50"
>
{loadingRec ? <Loader2 size={10} className="animate-spin" /> : <Sparkles size={10} />}
IA
</button>
<select
value={selTmpl}
onChange={e => { setSelTmpl(Number(e.target.value)); setRecMsg(null); setAnResult(null) }}
className="flex-1 bg-dark-900 border border-slate-700 rounded px-2 py-1.5 text-xs text-slate-300 min-w-0"
>
<option value={0}>— Choisir un template —</option>
{templates.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
</div>
{recMsg && (
<div className={clsx('text-xs px-2 py-1 rounded', recMsg.startsWith('') ? 'text-emerald-400 bg-emerald-900/20' : recMsg.startsWith('') ? 'text-red-400 bg-red-900/20' : 'text-amber-400 bg-amber-900/20')}>
{recMsg}
</div>
)}
{noTemplateFound && (
<button
onClick={createTemplateFromEvent}
disabled={loadingCreate}
className="flex items-center gap-1.5 text-xs bg-purple-800/30 hover:bg-purple-700/50 border border-purple-700/40 text-purple-300 rounded px-2 py-1.5 w-full justify-center disabled:opacity-50"
>
{loadingCreate ? <Loader2 size={10} className="animate-spin" /> : <Sparkles size={10} />}
{loadingCreate ? 'Création IA en cours' : "L'IA peut créer un template"}
</button>
)}
{/* Instantiation status + inputs preview */}
{selTmpl > 0 && (
<div className="space-y-1">
<div className="flex items-center gap-2">
<button
onClick={() => instantiate(selTmpl)}
disabled={loadingInst}
className="flex items-center gap-1 text-xs text-slate-500 hover:text-purple-400 disabled:opacity-50 transition-colors"
>
{loadingInst ? <Loader2 size={9} className="animate-spin" /> : <Sparkles size={9} />}
{loadingInst ? 'Calibration IA…' : 'Calibrer (IA)'}
</button>
{instMsg && !instMsg.startsWith('✗') && (
<span className="text-xs text-slate-600 truncate">{instMsg}</span>
)}
</div>
{Object.keys(instInputs).length > 0 && (
<div className="space-y-1">
{Object.entries(instInputs).map(([k, v]) => (
<div key={k} className="flex items-center gap-1.5">
<span className="text-xs font-mono text-slate-500 flex-1 truncate" title={k}>{k}</span>
<input
type="number"
step="any"
value={v}
onChange={e => {
const n = parseFloat(e.target.value)
if (!isNaN(n)) setInstInputs(prev => ({ ...prev, [k]: n }))
}}
className={clsx(
'w-20 text-xs font-mono text-right rounded px-1.5 py-0.5 border bg-dark-900 focus:outline-none focus:ring-1 focus:ring-blue-500/50',
v > 0 ? 'text-emerald-400 border-emerald-800/60'
: v < 0 ? 'text-red-400 border-red-800/60'
: 'text-slate-400 border-slate-700/60'
)}
/>
</div>
))}
</div>
)}
</div>
)}
{/* Analyze + open in CausalLab */}
<div className="flex gap-1.5">
<button
onClick={runAnalysis}
disabled={!selTmpl || loadingAn}
className="flex-1 flex items-center justify-center gap-1.5 text-xs bg-blue-700/50 hover:bg-blue-700/70 border border-blue-700/40 text-blue-200 rounded px-3 py-1.5 disabled:opacity-40"
>
{loadingAn ? <Loader2 size={10} className="animate-spin" /> : null}
{loadingAn ? 'Analyse…' : 'Lancer l\'analyse'}
</button>
<button
onClick={() => navigate(`/causal-lab?template=${selTmpl || ''}`)}
className="text-xs text-slate-500 hover:text-blue-400 px-2 py-1.5 rounded hover:bg-slate-700/30 transition-colors"
title="Ouvrir dans CausalLab"
>
<ExternalLink size={12} />
</button>
</div>
{/* Bandeau de détail du calcul réel */}
{anResult && (anResult.pricesMode || anResult.drift) && (() => {
const mode = anResult.pricesMode
const lagMin = anResult.lagMin ?? 0
const lagDays = anResult.lagDays ?? 0
const driftEntries = Object.entries(anResult.drift ?? {})
const dbg = anResult.lagDebug
return (
<div className="bg-slate-900/70 border border-slate-700/40 rounded px-3 py-2 space-y-1.5 text-[10px] font-mono">
<div className="flex items-center gap-2 flex-wrap">
<span className={clsx('px-1.5 py-0.5 rounded font-semibold',
mode === 'intraday_5m' ? 'bg-cyan-900/40 text-cyan-400 border border-cyan-700/40'
: 'bg-amber-900/40 text-amber-400 border border-amber-700/40')}>
{mode === 'intraday_5m' ? '5m intraday' : mode === 'daily' ? 'journalier' : mode ?? '—'}
</span>
{mode === 'intraday_5m' && lagMin > 0 && (
<span className="text-slate-400">lag <span className="text-cyan-300">{lagMin}min</span></span>
)}
{mode !== 'intraday_5m' && lagDays > 0 && (
<span className="text-slate-400">lag <span className="text-amber-300">{lagDays}j</span></span>
)}
{mode !== 'intraday_5m' && lagDays === 0 && (
<span className="text-slate-600 italic">lag J = 0 (jour J)</span>
)}
{dbg && dbg.used_all_edges && (
<span className="text-yellow-500/80 italic">⚠ fallback edges (output_ids vides)</span>
)}
{dbg && (
<span className="text-slate-600">
edges→out: <span className="text-slate-400">{dbg.edges_to_output}</span>
{' '}| edges lag_j: <span className="text-slate-400">{dbg.edges_with_lag_days}</span>
</span>
)}
</div>
{driftEntries.map(([inst, d]) => (
<div key={inst} className="flex items-center gap-2 flex-wrap border-t border-slate-800/60 pt-1.5">
<span className="text-slate-400 w-16 truncate">{inst}</span>
{d.pre_pips != null && (
<span className="text-slate-500">
pré: <span className={d.pre_pips > 0 ? 'text-emerald-500' : 'text-red-500'}>{d.pre_pips > 0 ? '+' : ''}{d.pre_pips}p</span>
</span>
)}
<span className="text-slate-500">
post: <span className={d.post_pips == null ? 'text-slate-600' : d.post_pips > 0 ? 'text-emerald-400' : 'text-red-400'}>
{d.post_pips == null ? '—' : `${d.post_pips > 0 ? '+' : ''}${d.post_pips}p`}
</span>
</span>
{d.post_pips == null && (
<span className="text-red-500 italic">données manquantes (yfinance)</span>
)}
</div>
))}
</div>
)
})()}
{/* Inline result — full-width graph + left recap */}
{anResult && graphData?.nodes?.length && (() => {
const inputNodes = graphData.nodes.filter(n => n.type === 'input')
const outputNodes = graphData.nodes.filter(n => n.type === 'market_asset' || n.type === 'output')
return (
<div className="bg-slate-900/60 rounded p-2">
{/* Activation row */}
<div className="flex items-center gap-3 mb-2">
<span className="text-xs text-slate-500">Activation</span>
{anResult.score != null ? (
<span className={clsx(
'text-xs font-mono font-bold',
anResult.score >= 0.7 ? 'text-emerald-400' :
anResult.score >= 0.4 ? 'text-amber-400' : 'text-red-400'
)}>{Math.round(anResult.score * 100)}%</span>
) : <span className="text-xs text-slate-500">données manquantes</span>}
</div>
{/* Side-by-side: recap left + graph right */}
<div className="flex gap-3">
{/* ── Recap panel ── */}
<div className="w-40 shrink-0 space-y-3 text-xs">
{inputNodes.length > 0 && (
<div>
<div className="text-[10px] text-slate-600 uppercase tracking-wide mb-1">Entrées</div>
{inputNodes.map(n => {
const val = anResult.preds[n.id] ?? instInputs[n.id]
return (
<div key={n.id} className="flex items-center justify-between gap-1 py-0.5 border-b border-slate-800/60 last:border-0">
<span className="text-slate-400 truncate max-w-[80px]" title={n.label}>{n.label}</span>
<span className={clsx('font-mono shrink-0', val == null ? 'text-slate-600' : val > 0 ? 'text-emerald-400' : val < 0 ? 'text-red-400' : 'text-slate-500')}>
{val != null ? `${val > 0 ? '+' : ''}${val.toFixed(1)}` : '—'}
</span>
</div>
)
})}
</div>
)}
{outputNodes.length > 0 && (
<div>
<div className="text-[10px] text-slate-600 uppercase tracking-wide mb-1">Prédictions</div>
{(() => {
const nodeScores = outputNodes
.map(n => {
const pred = anResult.preds[n.id]
const act = anResult.actuals[n.instrument || n.id] ?? anResult.actuals[n.id]
if (pred == null || act == null) return null
if (pred === 0 && act === 0) return 100
if (pred === 0) return 40
const sameDir = pred * act > 0
if (!sameDir) return 0
const ratio = Math.min(Math.abs(act), Math.abs(pred)) / Math.max(Math.abs(act), Math.abs(pred))
return Math.round(50 + ratio * 50)
})
.filter((s): s is number => s != null)
const graphScore = nodeScores.length > 0
? Math.round(nodeScores.reduce((a, b) => a + b, 0) / nodeScores.length)
: null
if (graphScore == null) return null
const barCls = graphScore >= 70 ? 'bg-emerald-500' : graphScore >= 40 ? 'bg-amber-500' : 'bg-red-500'
const txtCls = graphScore >= 70 ? 'text-emerald-400' : graphScore >= 40 ? 'text-amber-400' : 'text-red-400'
return (
<div className="mb-2 pb-2 border-b border-slate-800/60">
<div className="flex items-center justify-between text-[9px] text-slate-600 mb-0.5">
<span>Précision</span>
<span className={txtCls}>{graphScore}%</span>
</div>
<div className="h-1 bg-slate-800 rounded-full overflow-hidden">
<div className={clsx('h-full rounded-full', barCls)} style={{ width: `${graphScore}%` }} />
</div>
</div>
)
})()}
{outputNodes.map(n => {
const pred = anResult.preds[n.id]
const actual = anResult.actuals[n.instrument || n.id] ?? anResult.actuals[n.id]
return (
<div key={n.id} className="py-0.5 border-b border-slate-800/60 last:border-0">
<div className="flex items-center justify-between gap-1">
<span className="text-slate-400 truncate max-w-[70px]" title={n.label}>{n.label}</span>
<span className={clsx('font-mono shrink-0', pred == null ? 'text-slate-600' : pred > 0 ? 'text-emerald-400' : pred < 0 ? 'text-red-400' : 'text-slate-500')}>
{pred != null ? `${pred > 0 ? '+' : ''}${Math.round(pred)}${n.unit === 'pips' ? 'p' : n.unit === '%' ? '%' : ''}` : '—'}
</span>
</div>
{actual != null && (
<div className="flex items-center justify-between gap-1 mt-0.5 opacity-70">
<span className="text-slate-600 text-[10px]">réel</span>
<span className={clsx('font-mono text-[10px] shrink-0', actual > 0 ? 'text-emerald-300' : 'text-red-300')}>
{actual > 0 ? '+' : ''}{Math.round(actual)}p
</span>
</div>
)}
</div>
)
})}
</div>
)}
</div>
{/* ── Graph ── */}
<div className="flex-1 min-w-0">
<CausalGraphMini
nodes={graphData.nodes}
edges={graphData.edges}
nodeValues={anResult.preds}
/>
</div>
</div>
</div>
)
})()}
{anResult && !graphData?.nodes?.length && (
<div className="bg-slate-900/60 rounded p-2 space-y-1">
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">Activation</span>
{anResult.score != null ? (
<span className={clsx('text-xs font-mono font-bold',
anResult.score >= 0.7 ? 'text-emerald-400' : anResult.score >= 0.4 ? 'text-amber-400' : 'text-red-400'
)}>{Math.round(anResult.score * 100)}%</span>
) : <span className="text-xs text-slate-500">données manquantes</span>}
</div>
{Object.entries(anResult.actuals).map(([inst, move]) => {
const pred = Object.entries(anResult.preds).find(([k]) => k.toLowerCase().includes(inst.toLowerCase()))?.[1]
return (
<div key={inst} className="flex items-center justify-between gap-2 text-xs">
<span className="font-mono text-slate-400">{inst}</span>
{pred !== undefined && <span className={clsx('font-mono', pred > 0 ? 'text-blue-400' : 'text-orange-400')}>pred {pred > 0 ? '+' : ''}{Math.round(pred)}p</span>}
<span className={clsx('font-mono font-bold ml-auto', move > 0 ? 'text-emerald-400' : 'text-red-400')}>{move > 0 ? '+' : ''}{Math.round(move)}p</span>
</div>
)
})}
</div>
)}
{templates.length === 0 && !noTemplateFound && (
<p className="text-xs text-slate-600 italic">
Aucun template disponible —{' '}
<button onClick={() => navigate('/causal-lab')} className="text-blue-500 hover:text-blue-400 underline">
créer dans CausalLab
</button>
</p>
)}
</div>
</div>
</div>
</div>
)
}
// ── Create event modal ────────────────────────────────────────────────────────
function CreateModal({ onCreated, onClose }: { onCreated: () => void; onClose: () => void }) {
const [form, setForm] = useState({
name: '', start_date: new Date().toISOString().slice(0, 10),
level: 'short', description: '', impact_score: 0.5,
})
const [saving, setSaving] = useState(false)
const submit = async () => {
if (!form.name || !form.start_date) return
setSaving(true)
await fetch('/api/market-events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...form, source_refs: [] }),
})
setSaving(false)
onCreated()
onClose()
}
const f = (k: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) =>
setForm(d => ({ ...d, [k]: e.target.value }))
return (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center">
<div className="bg-dark-800 border border-slate-700/50 rounded-xl w-full max-w-lg shadow-2xl">
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-700/40">
<h3 className="text-slate-100 font-semibold">Nouvel événement marché</h3>
<button onClick={onClose} className="text-slate-500 hover:text-slate-300"><X size={16} /></button>
</div>
<div className="p-5 space-y-3">
<input value={form.name} onChange={f('name')} placeholder="Nom de l'événement *"
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-2 text-slate-200 text-sm" />
<div className="grid grid-cols-2 gap-3">
<div>
<div className="text-xs text-slate-500 mb-1">Date début</div>
<input type="date" value={form.start_date} onChange={f('start_date')}
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-2 text-slate-200 text-sm" />
</div>
<div>
<div className="text-xs text-slate-500 mb-1">Niveau</div>
<select value={form.level} onChange={f('level')}
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-2 text-slate-200 text-sm">
{LEVELS.map(l => <option key={l} value={l}>{l}</option>)}
</select>
</div>
</div>
<textarea value={form.description} onChange={f('description')} placeholder="Description..." rows={3}
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-2 text-slate-200 text-sm resize-none" />
<div>
<div className="text-xs text-slate-500 mb-1">Score impact: {form.impact_score.toFixed(2)}</div>
<input type="range" min="0" max="1" step="0.05" value={form.impact_score}
onChange={e => setForm(d => ({ ...d, impact_score: +e.target.value }))}
className="w-full accent-amber-400" />
</div>
</div>
<div className="flex gap-2 px-5 pb-5">
<button onClick={submit} disabled={saving || !form.name}
className="flex items-center gap-2 bg-emerald-700 hover:bg-emerald-600 disabled:bg-slate-700 disabled:text-slate-500 text-white rounded-lg px-5 py-2 text-sm font-medium">
{saving ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />} Créer
</button>
<button onClick={onClose} className="text-sm text-slate-500 hover:text-slate-300 px-4">Annuler</button>
</div>
</div>
</div>
)
}
// ── Event list row ────────────────────────────────────────────────────────────
function EventRow({
ev, selected, onClick,
}: { ev: MarketEvent; selected: boolean; onClick: () => void }) {
return (
<div
onClick={onClick}
className={clsx(
'flex items-center gap-3 px-4 py-3 cursor-pointer border-b border-slate-800/50 hover:bg-slate-800/40 transition-colors',
selected && 'bg-slate-800/60 border-l-2 border-l-blue-500',
)}
>
<span className="w-1.5 h-1.5 rounded-full shrink-0 bg-slate-500" />
<div className="flex-1 min-w-0">
<div className="text-sm text-slate-200 truncate font-medium">{ev.name}</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-slate-500">{ev.start_date}</span>
{ev.sub_type && <span className="text-xs text-slate-600">{ev.sub_type}</span>}
{ev.origin && (
<span className="text-xs shrink-0" title={(ORIGIN_META[ev.origin] ?? ORIGIN_META['unknown']).description}>
{(ORIGIN_META[ev.origin] ?? ORIGIN_META['unknown']).icon}
</span>
)}
{ev.created_at && (
<span className="text-[10px] text-slate-700 shrink-0" title={`Généré le ${ev.created_at}`}>
⚡{ev.created_at.slice(5, 10)}
</span>
)}
</div>
</div>
<div className="shrink-0 text-right">
<div className="text-xs font-bold" style={{ color: ev.impact_score >= 0.7 ? '#ef4444' : ev.impact_score >= 0.5 ? '#f59e0b' : '#10b981' }}>
{ev.impact_score.toFixed(2)}
</div>
<div className={clsx('text-xs', LEVEL_COLORS[ev.level])}>{ev.level}</div>
</div>
</div>
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
// ── Main page ─────────────────────────────────────────────────────────────────
export default function MarketEvents() {
const [searchParams] = useSearchParams()
const [events, setEvents] = useState<MarketEvent[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(false)
const [selected, setSelected] = useState<MarketEvent | null>(null)
const [showCreate, setShowCreate] = useState(false)
const [bulkIds, setBulkIds] = useState<Set<number>>(new Set())
const [bulking, setBulking] = useState(false)
const [bulkMsg, setBulkMsg] = useState<string | null>(null)
// ── Filter state ─────────────────────────────────────────────────────────
const [search, setSearch] = useState('')
const [category, setCategory] = useState('')
const [level, setLevel] = useState('')
const [minScore, setMinScore] = useState(0)
const [evaluated, setEvaluated] = useState<'' | 'true' | 'false'>('')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [datePreset, setDatePreset] = useState<string>('all') // 7d|30d|90d|6m|1y|all|custom
// Generation date filter (created_at)
const [genFrom, setGenFrom] = useState('')
const [genTo, setGenTo] = useState('')
// Instrument impact filter
const [instTicker, setInstTicker] = useState('')
const [instMinScore, setInstMin] = useState(0.3)
const [instDir, setInstDir] = useState('')
const [showInstFilter, setShowInst] = useState(false)
// Sort
const [sortBy, setSortBy] = useState<'date'|'score'|'name'|'instrument_score'>('date')
const [sortDir, setSortDir] = useState<'desc'|'asc'>('desc')
// UI
const [showFilters, setShowFilters] = useState(false)
// ── Date preset helper ────────────────────────────────────────────────────
const applyPreset = useCallback((preset: string) => {
setDatePreset(preset)
if (preset === 'custom' || preset === 'all') {
setDateFrom(''); setDateTo('')
return
}
const now = new Date()
const to = now.toISOString().slice(0, 10)
const from = new Date(now)
if (preset === '7d') from.setDate(now.getDate() - 7)
if (preset === '30d') from.setDate(now.getDate() - 30)
if (preset === '90d') from.setDate(now.getDate() - 90)
if (preset === '6m') from.setMonth(now.getMonth() - 6)
if (preset === '1y') from.setFullYear(now.getFullYear() - 1)
setDateFrom(from.toISOString().slice(0, 10))
setDateTo(to)
}, [])
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const abortRef = useRef<AbortController | null>(null)
const load = useCallback(async () => {
// Cancel any in-flight fetch
abortRef.current?.abort()
abortRef.current = new AbortController()
const signal = abortRef.current.signal
setLoading(true)
const params = new URLSearchParams()
if (search) params.set('search', search)
if (category) params.set('category', category)
if (level) params.set('level', level)
if (minScore) params.set('min_score', String(minScore))
if (dateFrom) params.set('date_from', dateFrom)
if (dateTo) params.set('date_to', dateTo)
if (genFrom) params.set('gen_date_from', genFrom)
if (genTo) params.set('gen_date_to', genTo)
if (evaluated) params.set('evaluated', evaluated)
if (instTicker) {
params.set('instrument', instTicker)
if (instMinScore > 0) params.set('min_instrument_score', String(instMinScore))
if (instDir) params.set('instrument_direction', instDir)
}
params.set('sort_by', instTicker ? 'instrument_score' : sortBy)
params.set('sort_dir', sortDir)
params.set('limit', '500')
try {
const r = await fetch(`/api/market-events?${params}`, { signal })
if (r.ok) {
const d = await r.json()
setEvents(d.events)
setTotal(d.total)
}
} catch (e: any) {
if (e.name === 'AbortError') return // superseded by a newer load
}
setLoading(false)
}, [search, category, level, minScore, dateFrom, dateTo, genFrom, genTo, evaluated,
instTicker, instMinScore, instDir, sortBy, sortDir])
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(load, 250)
}, [load])
// Deep-link: ?event=ID — select that event once list is loaded
useEffect(() => {
const eventId = searchParams.get('event')
if (!eventId || !events.length) return
const found = events.find(e => e.id === parseInt(eventId))
if (found && (!selected || selected.id !== found.id)) setSelected(found)
}, [events, searchParams])
const bulkEvaluate = async () => {
if (bulkIds.size === 0) return
setBulking(true)
setBulkMsg(null)
const r = await fetch('/api/market-events/bulk-evaluate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify([...bulkIds]),
})
const d = await r.json()
setBulkMsg(`✓ ${d.evaluated} évalués${d.errors > 0 ? `, ${d.errors} erreurs` : ''}`)
setBulkIds(new Set())
setBulking(false)
load()
}
const unevaluatedIds = events.filter(e => !e.evaluated).map(e => e.id)
return (
<div className="flex flex-col h-full overflow-hidden">
<div className="flex flex-1 overflow-hidden">
{/* ── Left panel: filters + list ── */}
<div className="w-96 shrink-0 flex flex-col border-r border-slate-700/40 overflow-hidden">
{/* ── Search + actions ── */}
<div className="px-3 pt-3 pb-2 border-b border-slate-700/30 space-y-2">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-500" />
<input value={search} onChange={e => setSearch(e.target.value)}
placeholder="Recherche nom, description..."
className="w-full bg-slate-800/60 border border-slate-700/50 rounded-lg pl-8 pr-3 py-1.5 text-sm text-slate-200 placeholder:text-slate-600" />
</div>
<button onClick={() => setShowCreate(true)}
className="flex items-center gap-1 bg-emerald-700 hover:bg-emerald-600 text-white text-xs rounded-lg px-2 py-1.5 shrink-0">
<Plus size={12} />
</button>
<button onClick={load} disabled={loading}
className="p-1.5 text-slate-500 hover:text-slate-300 rounded shrink-0">
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
</button>
</div>
{/* ── Date presets ── */}
<div className="flex items-center gap-1 flex-wrap">
<span className="text-xs text-slate-600 mr-1">Période:</span>
{[['7d','7j'],['30d','30j'],['90d','3m'],['6m','6m'],['1y','1an'],['all','Tout'],['custom','Perso']] .map(([v, lbl]) => (
<button key={v} onClick={() => applyPreset(v)}
className={clsx('px-2 py-0.5 rounded text-xs border transition-colors',
datePreset === v
? 'bg-blue-600/30 border-blue-500/50 text-blue-300'
: 'border-slate-800 text-slate-600 hover:border-slate-600 hover:text-slate-400')}>
{lbl}
</button>
))}
</div>
{datePreset === 'custom' && (
<div className="grid grid-cols-2 gap-2">
<div>
<div className="text-xs text-slate-600 mb-0.5">Depuis</div>
<input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)}
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs" />
</div>
<div>
<div className="text-xs text-slate-600 mb-0.5">Jusqu'à</div>
<input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)}
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs" />
</div>
</div>
)}
</div>
{/* ── Quick category filter ── */}
<div className="px-3 py-1.5 border-b border-slate-800/40 flex items-center gap-1 flex-wrap">
{([
{ key: '', label: 'Tout', dot: 'bg-slate-500' },
{ key: 'event_calendar', label: '★ Cal.', dot: 'bg-amber-500' },
{ key: 'geopolitical', label: '★ Géo.', dot: 'bg-red-500' },
{ key: 'fundamental', label: '★ Fund.', dot: 'bg-emerald-500' },
{ key: 'report', label: '★ Rep.', dot: 'bg-blue-500' },
{ key: 'sentiment', label: '★ Sent.', dot: 'bg-violet-500' },
{ key: 'technical', label: '★ Tech.', dot: 'bg-cyan-500' },
] as const).map(({ key, label, dot }) => (
<button
key={key}
onClick={() => setCategory(key)}
className={clsx(
'flex items-center gap-1 px-2 py-0.5 rounded text-[10px] border transition-colors',
category === key
? 'bg-slate-700/80 border-slate-500/60 text-slate-200'
: 'border-slate-800/60 text-slate-600 hover:border-slate-600 hover:text-slate-400'
)}
>
<span className={clsx('w-1.5 h-1.5 rounded-full shrink-0', dot)} />
{label}
</button>
))}
</div>
{/* ── Sort + more filters toggle ── */}
<div className="px-3 py-1.5 border-b border-slate-800/40 flex items-center gap-2">
<span className="text-xs text-slate-600">Tri:</span>
<select value={sortBy} onChange={e => setSortBy(e.target.value as any)}
className="bg-transparent border-0 text-xs text-slate-400 cursor-pointer focus:outline-none">
<option value="date">Date</option>
<option value="score">Score global</option>
<option value="name">Nom</option>
</select>
<button onClick={() => setSortDir(d => d === 'desc' ? 'asc' : 'desc')}
className="text-xs text-slate-500 hover:text-slate-300 border border-slate-700 rounded px-1.5 py-0.5">
{sortDir === 'desc' ? '↓' : '↑'}
</button>
<div className="flex-1" />
<button onClick={() => setShowFilters(x => !x)}
className={clsx('flex items-center gap-1 text-xs border rounded px-2 py-0.5 transition-colors',
(showFilters || instTicker)
? 'bg-slate-700 border-slate-600 text-slate-300'
: 'border-slate-800 text-slate-600 hover:border-slate-600')}>
<SlidersHorizontal size={10} />
Filtres {(category || level || minScore > 0 || evaluated || instTicker) ? '●' : ''}
</button>
</div>
{/* ── Extended filters ── */}
{showFilters && (
<div className="px-3 py-2 border-b border-slate-800/40 space-y-2 bg-dark-900/50">
<div className="grid grid-cols-2 gap-2">
<div>
<div className="text-xs text-slate-600 mb-0.5">Niveau</div>
<select value={level} onChange={e => setLevel(e.target.value)}
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs">
<option value="">Tous</option>
{LEVELS.map(l => <option key={l} value={l}>{l}</option>)}
</select>
</div>
<div>
<div className="text-xs text-slate-600 mb-0.5">Évaluation</div>
<select value={evaluated} onChange={e => setEvaluated(e.target.value as any)}
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs">
<option value="">Tous</option>
<option value="true">Évalués IA</option>
<option value="false">Non évalués</option>
</select>
</div>
</div>
<div>
<div className="text-xs text-slate-600 mb-0.5">Score global min: <span className="text-slate-400">{minScore.toFixed(2)}</span></div>
<input type="range" min="0" max="1" step="0.05" value={minScore}
onChange={e => setMinScore(+e.target.value)} className="w-full accent-slate-400" />
</div>
{/* ── Instrument impact filter ── */}
<div className="border-t border-slate-800/60 pt-2">
<button onClick={() => setShowInst(x => !x)}
className={clsx('flex items-center gap-1.5 text-xs font-semibold mb-2 transition-colors',
instTicker ? 'text-amber-400' : 'text-slate-500 hover:text-slate-300')}>
<BarChart2 size={11} />
Impact sur instrument {instTicker ? `(${instTicker})` : ''}
{showInstFilter ? ' ▲' : ' ▼'}
</button>
{showInstFilter && (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<div>
<div className="text-xs text-slate-600 mb-0.5">Instrument</div>
<select value={instTicker} onChange={e => { setInstTicker(e.target.value); if (e.target.value) setSortBy('instrument_score') }}
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs">
<option value="">Tous</option>
{ALL_INSTRUMENTS.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<div>
<div className="text-xs text-slate-600 mb-0.5">Direction</div>
<select value={instDir} onChange={e => setInstDir(e.target.value)}
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs">
<option value="">Toutes</option>
<option value="bullish">🔼 Bullish</option>
<option value="bearish">🔽 Bearish</option>
<option value="neutral"> Neutre</option>
<option value="depends_on_outcome"> Conditionnel</option>
</select>
</div>
</div>
<div>
<div className="text-xs text-slate-600 mb-0.5">
Score impact min: <span className="text-amber-300">{instMinScore.toFixed(2)}</span>
</div>
<input type="range" min="0" max="1" step="0.05" value={instMinScore}
onChange={e => setInstMin(+e.target.value)} className="w-full accent-amber-400" />
</div>
{instTicker && (
<div className="text-xs text-amber-300 bg-amber-900/20 rounded px-2 py-1 border border-amber-800/30">
Tri automatique par impact sur {instTicker}
</div>
)}
{instTicker && (
<button onClick={() => { setInstTicker(''); setInstDir(''); setInstMin(0.3); setSortBy('date') }}
className="text-xs text-slate-500 hover:text-red-400">
× Effacer filtre instrument
</button>
)}
</div>
)}
</div>
{/* ── Generation date filter ── */}
<div className="border-t border-slate-800/60 pt-2">
<div className="flex items-center gap-1.5 text-xs font-semibold mb-2 text-slate-500">
Date de génération
{(genFrom || genTo) && (
<button onClick={() => { setGenFrom(''); setGenTo('') }}
className="ml-auto text-slate-600 hover:text-red-400 font-normal">× effacer</button>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<div className="text-xs text-slate-600 mb-0.5">Généré depuis</div>
<input type="date" value={genFrom} onChange={e => setGenFrom(e.target.value)}
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs" />
</div>
<div>
<div className="text-xs text-slate-600 mb-0.5">Généré jusqu'à</div>
<input type="date" value={genTo} onChange={e => setGenTo(e.target.value)}
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs" />
</div>
</div>
{(genFrom || genTo) && (
<div className="text-xs text-blue-300 bg-blue-900/20 rounded px-2 py-1 border border-blue-800/30 mt-1.5">
⚡ Filtre actif sur date de génération (created_at)
</div>
)}
</div>
</div>
)}
{/* ── Bulk evaluate ── */}
{unevaluatedIds.length > 0 && (
<div className="px-3 py-1.5 border-b border-slate-800/50 flex items-center gap-2 bg-blue-900/10">
<span className="text-xs text-blue-300 flex-1">{unevaluatedIds.length} non évalués</span>
{bulkMsg && <span className="text-xs text-emerald-400">{bulkMsg}</span>}
<button onClick={() => { setBulkIds(new Set(unevaluatedIds)); setTimeout(bulkEvaluate, 50) }}
disabled={bulking}
className="flex items-center gap-1 text-xs bg-blue-700/50 hover:bg-blue-700/70 text-blue-200 rounded px-2 py-1">
{bulking ? <Loader2 size={10} className="animate-spin" /> : <Sparkles size={10} />}
Évaluer tous
</button>
</div>
)}
{/* ── Count bar ── */}
<div className="px-3 py-1 text-xs text-slate-600 border-b border-slate-800/30 flex items-center gap-2">
<span>{loading ? '...' : total} événement{total !== 1 ? 's' : ''}</span>
{instTicker && <span className="text-amber-400">· impact {instTicker}</span>}
{(level || search || minScore > 0 || evaluated || dateFrom || dateTo || genFrom || genTo || instTicker) && (
<button onClick={() => {
setSearch(''); setLevel(''); setMinScore(0)
setEvaluated(''); setDateFrom(''); setDateTo(''); setDatePreset('all')
setGenFrom(''); setGenTo('')
setInstTicker(''); setInstDir(''); setInstMin(0.3); setSortBy('date')
}} className="ml-auto text-xs text-slate-600 hover:text-red-400">× tout effacer</button>
)}
</div>
{/* ── List ── */}
<div className="flex-1 overflow-y-auto">
{loading && events.length === 0 ? (
<div className="flex items-center justify-center h-32 text-slate-500 text-sm">
<Loader2 size={16} className="animate-spin mr-2" /> Chargement...
</div>
) : events.length === 0 ? (
<div className="text-center py-12 text-slate-600 text-sm">Aucun événement trouvé</div>
) : (
events.map(ev => (
<EventRow
key={ev.id}
ev={ev}
selected={selected?.id === ev.id}
onClick={() => setSelected(ev)}
/>
))
)}
</div>
</div>
{/* ── Right panel: detail ── */}
<div className="flex-1 overflow-hidden bg-dark-900/40">
{selected ? (
<EventDetail
key={selected.id}
event={selected}
onClose={() => setSelected(null)}
onUpdated={updated => {
setEvents(evs => evs.map(e => e.id === updated.id ? { ...e, ...updated } : e))
}}
onDeleted={id => {
setEvents(evs => evs.filter(e => e.id !== id))
setSelected(null)
}}
/>
) : (
<div className="flex flex-col items-center justify-center h-full text-slate-600 gap-3">
<BarChart2 size={40} className="opacity-30" />
<div className="text-sm">Sélectionne un événement pour voir le détail</div>
<div className="text-xs opacity-60">Sources · Impacts par instrument · Analyse IA</div>
</div>
)}
</div>
{showCreate && <CreateModal onCreated={load} onClose={() => setShowCreate(false)} />}
</div>
</div>
)
}