Files
OpenFin/frontend/src/pages/CausalLab.tsx

740 lines
34 KiB
TypeScript

import { useState, useEffect } from 'react'
import {
FlaskConical, Library, Zap, BarChart3, RefreshCw, ChevronRight,
Sliders, Brain, Search, Filter, CheckCircle, XCircle, Minus, AlertCircle,
} from 'lucide-react'
import clsx from 'clsx'
// ── Types ─────────────────────────────────────────────────────────────────────
interface CausalNode {
id: string; label: string; type: 'input' | 'intermediate' | 'output'
x: number; y: number; formula?: string; unit?: string; instrument?: string
}
interface CausalEdge {
from: string; to: string; style: 'solid' | 'dashed'; type?: string; label?: string
}
interface Coefficient {
value: number; calibrated: number | null; description: string
}
interface GraphJson {
nodes: CausalNode[]; edges: CausalEdge[]
coefficients: Record<string, Coefficient>
instruments: string[]
input_mapping: Record<string, { source: string; unit?: string; range?: number[] }>
}
interface Template {
id: number; name: string; category: string; sub_type: string
instruments: string[]; description: string; graph_json: GraphJson
ai_rationale: string; calibration_json: Record<string, unknown>
heuristic_ver: number
}
interface MarketEvent {
id: number; name: string; category: string; sub_type: string
start_date: string; level: string; impact_score: number
market_impact: string; affected_assets: string
actual_value: number | null; expected_value: number | null; surprise_pct: number | null
analysis_id: number | null; template_id: number | null; activation_score: number | null
}
interface NodeResult {
pred: number | null; act: number | null; status: string; label: string
}
interface AnalysisResult {
event: MarketEvent; template_id: number; template_name: string; instrument: string
inputs: Record<string, number>; node_values: Record<string, number>
actual_moves: Record<string, number>; yields: Record<string, number | null>
activation: {
score: number | null; nodes: Record<string, NodeResult>; correct: number; total: number
}
drift: { pre_pips: number | null; post_pips: number | null; drift_ratio: number | null; leak: string }
prices_mode: string; analyzed_at: string
}
interface Recommendation {
template_id: number | null; template_name: string; confidence: number
rationale: string; coefficient_suggestions: Record<string, number>
instrument_focus: string[]; alternative_template_id: number | null; notes: string
error?: string
}
interface CalibRow {
id: number; name: string; category: string; sub_type: string
n_analyses: number; avg_activation: number | null
calibration: Record<string, { n?: number; avg_pred?: number; avg_actual?: number; coef_ratio?: number }>
}
// ── API ───────────────────────────────────────────────────────────────────────
const api = async (path: string, opts?: RequestInit) => {
const r = await fetch(`http://localhost:8000${path}`, opts)
if (!r.ok) { const t = await r.text(); throw new Error(t) }
return r.json()
}
// ── Couleurs / labels ─────────────────────────────────────────────────────────
const NODE_COLORS: Record<string, string> = {
input: '#3b82f6',
intermediate: '#8b5cf6',
output: '#10b981',
}
const STATUS_COLOR: Record<string, string> = {
correct: '#10b981', wrong: '#ef4444', neutral: '#64748b', unknown: '#64748b',
}
const CAT_LABELS: Record<string, string> = {
macro_us: 'Macro US', macro_eu: 'Macro EU', geopolitical: 'Géopolitique',
commodity: 'Matières premières', report: 'Report', sentiment: 'Sentiment',
}
const fmtPips = (v: number | null | undefined) =>
v == null ? '—' : `${v > 0 ? '+' : ''}${v.toFixed(0)} pip`
// ── SVG Graph renderer ────────────────────────────────────────────────────────
function GraphSVG({
graph, nodeValues, activationNodes, compact = false,
}: {
graph: GraphJson
nodeValues?: Record<string, number>
activationNodes?: Record<string, NodeResult>
compact?: boolean
}) {
const nodes = graph.nodes || []
const edges = graph.edges || []
const xs = nodes.map(n => n.x)
const ys = nodes.map(n => n.y)
const minX = Math.min(...xs, 0) - 40
const minY = Math.min(...ys, 0) - 30
const maxX = Math.max(...xs, 400) + 120
const maxY = Math.max(...ys, 400) + 60
const W = maxX - minX; const H = maxY - minY
const nodeMap = Object.fromEntries(nodes.map(n => [n.id, n]))
const NW = compact ? 90 : 115; const NH = compact ? 28 : 38
function nodeColor(n: CausalNode) {
if (activationNodes?.[n.id]) return STATUS_COLOR[activationNodes[n.id].status] ?? NODE_COLORS[n.type]
return NODE_COLORS[n.type] ?? '#64748b'
}
function edgePath(e: CausalEdge) {
const s = nodeMap[e.from]; const t = nodeMap[e.to]
if (!s || !t) return ''
const x1 = s.x; const y1 = s.y + NH / 2
const x2 = t.x; const y2 = t.y - NH / 2
const mx = (x1 + x2) / 2
return `M${x1},${y1} C${x1},${mx} ${x2},${mx} ${x2},${y2}`
}
return (
<svg viewBox={`${minX} ${minY} ${W} ${H}`}
style={{ width: '100%', height: compact ? 280 : 460 }}
className="overflow-visible">
<defs>
<marker id="arrow" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#475569" />
</marker>
</defs>
{edges.map((e, i) => {
const s = nodeMap[e.from]; const t = nodeMap[e.to]
if (!s || !t) return null
return (
<path key={i} d={edgePath(e)} fill="none" stroke="#475569" strokeWidth={1.5}
strokeDasharray={e.style === 'dashed' ? '5,3' : undefined}
markerEnd="url(#arrow)" opacity={0.8} />
)
})}
{nodes.map(n => {
const col = nodeColor(n)
const val = nodeValues?.[n.id]
const unit = n.unit || ''
return (
<g key={n.id} transform={`translate(${n.x - NW / 2},${n.y - NH / 2})`}>
<rect width={NW} height={NH} rx={6} fill={col} fillOpacity={0.18}
stroke={col} strokeWidth={1.5} />
<text x={NW / 2} y={compact ? 11 : 14} textAnchor="middle"
fill="#e2e8f0" fontSize={compact ? 9 : 10} fontWeight="600">
{n.label.length > 18 ? n.label.slice(0, 17) + '…' : n.label}
</text>
{val != null && (
<text x={NW / 2} y={compact ? 22 : 27} textAnchor="middle"
fill={col} fontSize={compact ? 9 : 10} fontWeight="700">
{val > 0 ? '+' : ''}{val.toFixed(1)}{unit ? ` ${unit}` : ''}
</text>
)}
</g>
)
})}
</svg>
)
}
// ── Tab Bibliothèque ──────────────────────────────────────────────────────────
function TabLibrary() {
const [templates, setTemplates] = useState<Template[]>([])
const [selected, setSelected] = useState<Template | null>(null)
const [catFilter, setCatFilter] = useState('')
const [saving, setSaving] = useState(false)
const [editCoefs, setEditCoefs] = useState<Record<string, number>>({})
const [loading, setLoading] = useState(true)
const [apiError, setApiError] = useState<string | null>(null)
const [debugInfo, setDebugInfo] = useState<Record<string, unknown> | null>(null)
useEffect(() => {
setLoading(true); setApiError(null)
api(`/api/causal-lab/templates${catFilter ? `?category=${catFilter}` : ''}`)
.then(data => {
setTemplates(data)
if (data.length === 0) {
// Auto-run debug to show diagnostic
api('/api/causal-lab/debug').then(setDebugInfo).catch(() => {})
}
})
.catch(e => setApiError(String(e)))
.finally(() => setLoading(false))
}, [catFilter])
function selectTemplate(t: Template) {
setSelected(t)
setEditCoefs(Object.fromEntries(
Object.entries(t.graph_json?.coefficients || {}).map(([k, v]) => [k, v.value])
))
}
async function saveCoefs() {
if (!selected) return
setSaving(true)
try {
await api(`/api/causal-lab/template/${selected.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ coefficients: editCoefs }),
})
const fresh = await api(`/api/causal-lab/template/${selected.id}`)
setSelected(fresh)
} finally { setSaving(false) }
}
const cats = [...new Set(templates.map(t => t.category))]
return (
<div className="flex gap-4">
{/* Liste templates */}
<div className="w-72 flex-shrink-0">
<select value={catFilter} onChange={e => setCatFilter(e.target.value)}
className="w-full mb-3 bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-xs text-slate-300">
<option value="">Toutes les catégories</option>
{cats.map(c => <option key={c} value={c}>{CAT_LABELS[c] ?? c}</option>)}
</select>
{apiError && (
<div className="mb-2 p-3 bg-red-900/20 border border-red-700/40 rounded text-xs text-red-300 break-all">
<div className="font-bold mb-1">Erreur API</div>
{apiError}
</div>
)}
{debugInfo && (
<div className="mb-2 p-3 bg-yellow-900/20 border border-yellow-700/40 rounded text-xs text-yellow-200">
<div className="font-bold mb-1">Diagnostic</div>
<pre className="whitespace-pre-wrap">{JSON.stringify(debugInfo, null, 2)}</pre>
</div>
)}
{loading
? <div className="text-slate-500 text-xs p-4">Chargement</div>
: <div className="space-y-1 overflow-y-auto max-h-[calc(100vh-230px)]">
{templates.map(t => (
<button key={t.id} onClick={() => selectTemplate(t)}
className={clsx('w-full text-left px-3 py-2.5 rounded border text-xs transition-all',
selected?.id === t.id
? 'bg-blue-900/30 border-blue-500/50 text-blue-200'
: 'bg-dark-700 border-slate-700/50 text-slate-300 hover:border-slate-500')}>
<div className="font-medium truncate">{t.name}</div>
<div className="text-slate-500 mt-0.5 flex items-center gap-2">
<span className="px-1 rounded bg-slate-700/50">{CAT_LABELS[t.category] ?? t.category}</span>
<span>{t.instruments.join(', ')}</span>
</div>
</button>
))}
</div>
}
</div>
{/* Détail */}
{selected ? (
<div className="flex-1 overflow-y-auto space-y-4">
<div className="bg-dark-700 rounded-lg p-4 border border-slate-700/40">
<div className="flex items-start justify-between mb-2">
<div>
<h3 className="text-white font-semibold">{selected.name}</h3>
<p className="text-slate-400 text-xs mt-1">{selected.description}</p>
</div>
<span className="text-xs px-2 py-1 rounded bg-purple-900/30 text-purple-300 border border-purple-700/40">
v{selected.heuristic_ver}
</span>
</div>
{selected.ai_rationale && (
<div className="mt-3 p-3 bg-blue-900/10 border border-blue-800/30 rounded text-xs text-blue-200">
<Brain className="w-3 h-3 inline mr-1.5 text-blue-400" />
{selected.ai_rationale}
</div>
)}
<div className="mt-3 flex gap-2 flex-wrap">
{selected.instruments.map(i => (
<span key={i} className="text-xs px-2 py-0.5 rounded bg-emerald-900/20 text-emerald-400 border border-emerald-700/30">
{i}
</span>
))}
</div>
</div>
<div className="bg-dark-700 rounded-lg p-4 border border-slate-700/40">
<h4 className="text-slate-300 text-xs font-semibold mb-3 uppercase tracking-wider">Graphe causal</h4>
<GraphSVG graph={selected.graph_json} />
</div>
<div className="bg-dark-700 rounded-lg p-4 border border-slate-700/40">
<h4 className="text-slate-300 text-xs font-semibold mb-3 uppercase tracking-wider flex items-center gap-2">
<Sliders className="w-3.5 h-3.5" /> Coefficients heuristiques
</h4>
<div className="space-y-3">
{Object.entries(selected.graph_json?.coefficients || {}).map(([k, c]) => (
<div key={k} className="grid grid-cols-[1fr_auto] gap-3 items-center">
<div>
<div className="text-xs text-slate-300 font-medium">{k}</div>
<div className="text-xs text-slate-500">{c.description}</div>
</div>
<input type="number" step="0.001"
value={editCoefs[k] ?? c.value}
onChange={e => setEditCoefs(p => ({ ...p, [k]: parseFloat(e.target.value) }))}
className="w-28 bg-dark-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 text-right" />
</div>
))}
</div>
<button onClick={saveCoefs} disabled={saving}
className="mt-4 px-4 py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 rounded text-xs font-medium text-white">
{saving ? 'Sauvegarde…' : 'Sauvegarder les coefficients'}
</button>
</div>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-slate-600 text-sm">
Sélectionnez un template dans la liste
</div>
)}
</div>
)
}
// ── Tab Analyser ──────────────────────────────────────────────────────────────
function TabAnalyze() {
const [events, setEvents] = useState<MarketEvent[]>([])
const [templates, setTemplates] = useState<Template[]>([])
const [selEvent, setSelEvent] = useState<MarketEvent | null>(null)
const [selTmpl, setSelTmpl] = useState<number>(0)
const [instrument, setInstr] = useState('EURUSD')
const [manualInputs, setManual] = useState<Record<string, number>>({})
const [result, setResult] = useState<AnalysisResult | null>(null)
const [rec, setRec] = useState<Recommendation | null>(null)
const [loadingRec, setLoadRec] = useState(false)
const [loadingAn, setLoadAn] = useState(false)
const [q, setQ] = useState('')
const [catF, setCatF] = useState('')
const [error, setError] = useState('')
useEffect(() => {
Promise.all([
api(`/api/causal-lab/market-events?limit=200${catF ? `&category=${catF}` : ''}${q ? `&q=${encodeURIComponent(q)}` : ''}`),
api('/api/causal-lab/templates'),
]).then(([evs, tmps]) => { setEvents(evs); setTemplates(tmps) })
}, [catF, q])
const currentTemplate = templates.find(t => t.id === selTmpl)
const manualNodes = currentTemplate?.graph_json?.input_mapping
? Object.entries(currentTemplate.graph_json.input_mapping).filter(([, c]) => c.source === 'user_input')
: []
async function recommend() {
if (!selEvent) return
setLoadRec(true); setRec(null); setError('')
try {
const r = await api('/api/causal-lab/recommend', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ market_event_id: selEvent.id }),
})
setRec(r.recommendation)
if (r.recommendation?.template_id) setSelTmpl(r.recommendation.template_id)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e))
} finally { setLoadRec(false) }
}
async function analyze() {
if (!selEvent || !selTmpl) return
setLoadAn(true); setResult(null); setError('')
try {
const r = await api('/api/causal-lab/analyze', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
market_event_id: selEvent.id,
template_id: selTmpl, instrument,
inputs: manualInputs, coef_overrides: {},
}),
})
setResult(r)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e))
} finally { setLoadAn(false) }
}
return (
<div className="flex gap-4">
{/* Colonne gauche */}
<div className="w-72 flex-shrink-0 space-y-3">
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="w-3.5 h-3.5 absolute left-2 top-2 text-slate-500" />
<input value={q} onChange={e => setQ(e.target.value)} placeholder="Rechercher un événement…"
className="w-full bg-dark-700 border border-slate-700 rounded pl-7 pr-2 py-1.5 text-xs text-slate-300" />
</div>
<select value={catF} onChange={e => setCatF(e.target.value)}
className="bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-xs text-slate-300">
<option value="">Tous</option>
{Object.entries(CAT_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
<div className="space-y-1 overflow-y-auto max-h-64 border border-slate-700/30 rounded-lg p-1">
{events.length === 0
? <div className="text-slate-600 text-xs p-3 text-center">Aucun événement</div>
: events.map(ev => (
<button key={ev.id} onClick={() => { setSelEvent(ev); setResult(null); setRec(null) }}
className={clsx('w-full text-left px-3 py-2 rounded border text-xs transition-all',
selEvent?.id === ev.id
? 'bg-blue-900/30 border-blue-500/50 text-blue-200'
: 'bg-dark-700 border-slate-700/50 text-slate-300 hover:border-slate-500')}>
<div className="font-medium truncate">{ev.name}</div>
<div className="text-slate-500 flex justify-between">
<span>{ev.start_date?.slice(0, 10)}</span>
{ev.activation_score != null && (
<span className={clsx('font-semibold',
ev.activation_score >= 0.7 ? 'text-emerald-400' :
ev.activation_score >= 0.4 ? 'text-yellow-400' : 'text-red-400')}>
{(ev.activation_score * 100).toFixed(0)}%
</span>
)}
</div>
</button>
))}
</div>
<select value={selTmpl} onChange={e => setSelTmpl(Number(e.target.value))}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-2 text-xs text-slate-300">
<option value={0}> Choisir un template </option>
{templates.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
<select value={instrument} onChange={e => setInstr(e.target.value)}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-2 text-xs text-slate-300">
{['EURUSD', 'XAUUSD', 'SP500', 'BRENT'].map(i => <option key={i} value={i}>{i}</option>)}
</select>
{manualNodes.length > 0 && (
<div className="bg-dark-800 rounded p-3 space-y-2 border border-slate-700/30">
<div className="text-xs text-slate-400 font-semibold">Inputs manuels</div>
{manualNodes.map(([id, cfg]) => {
const range = cfg.range as [number, number] | undefined
return (
<div key={id} className="flex items-center gap-2">
<span className="text-xs text-slate-400 flex-1 capitalize">{id.replace(/_/g, ' ')}</span>
<input type="range" min={range?.[0] ?? -3} max={range?.[1] ?? 3} step={0.5}
value={manualInputs[id] ?? 0}
onChange={e => setManual(p => ({ ...p, [id]: parseFloat(e.target.value) }))}
className="w-24" />
<span className="text-xs text-slate-300 w-8 text-right">
{(manualInputs[id] ?? 0) > 0 ? '+' : ''}{manualInputs[id] ?? 0}
</span>
</div>
)
})}
</div>
)}
<button onClick={recommend} disabled={!selEvent || loadingRec}
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-purple-700/50 hover:bg-purple-700 disabled:opacity-40 rounded text-xs font-medium text-purple-200 border border-purple-700/40">
<Brain className="w-3.5 h-3.5" />
{loadingRec ? 'GPT-4o analyse…' : 'Recommandation GPT-4o'}
</button>
<button onClick={analyze} disabled={!selEvent || !selTmpl || loadingAn}
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 rounded text-xs font-medium text-white">
<Zap className="w-3.5 h-3.5" />
{loadingAn ? 'Analyse…' : 'Lancer l\'analyse'}
</button>
{error && (
<div className="p-3 bg-red-900/20 border border-red-700/40 rounded text-xs text-red-300">{error}</div>
)}
</div>
{/* Colonne droite: résultats */}
<div className="flex-1 space-y-4 min-w-0">
{/* Recommandation IA */}
{rec && (
<div className="bg-purple-900/10 border border-purple-700/30 rounded-lg p-4">
<div className="flex items-center gap-2 mb-2">
<Brain className="w-4 h-4 text-purple-400" />
<span className="text-sm font-semibold text-purple-200">Recommandation GPT-4o</span>
{rec.confidence != null && (
<span className="ml-auto text-xs text-purple-400 font-bold">
{(rec.confidence * 100).toFixed(0)}% confiance
</span>
)}
</div>
{rec.error
? <div className="text-red-400 text-xs">{rec.error}</div>
: <>
<div className="text-sm text-white font-medium mb-1">Template : {rec.template_name}</div>
<p className="text-xs text-purple-200/80">{rec.rationale}</p>
{rec.notes && <p className="text-xs text-slate-400 mt-1">{rec.notes}</p>}
{Object.keys(rec.coefficient_suggestions || {}).length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{Object.entries(rec.coefficient_suggestions).map(([k, v]) => (
<span key={k} className="text-xs px-2 py-0.5 bg-purple-900/30 text-purple-300 rounded border border-purple-700/30">
{k} = {v}
</span>
))}
</div>
)}
</>
}
</div>
)}
{/* Résultat */}
{result && (
<>
<div className="bg-dark-700 rounded-lg p-4 border border-slate-700/40">
<div className="flex items-center justify-between mb-3">
<h3 className="text-white font-semibold text-sm">{result.event.name}</h3>
<div className="flex items-center gap-3">
<span className="text-xs text-slate-400">{result.event.start_date?.slice(0, 10)}</span>
<span className={clsx('text-sm font-bold px-3 py-1 rounded',
result.activation.score == null ? 'text-slate-400 bg-slate-800' :
result.activation.score >= 0.7 ? 'text-emerald-400 bg-emerald-900/30' :
result.activation.score >= 0.4 ? 'text-yellow-400 bg-yellow-900/30' :
'text-red-400 bg-red-900/30')}>
{result.activation.score != null
? `${(result.activation.score * 100).toFixed(0)}% activation`
: 'N/A'}
</span>
</div>
</div>
<div className="grid grid-cols-4 gap-3 mb-3">
{[
{ label: 'Pré-drift', val: fmtPips(result.drift?.pre_pips) },
{ label: 'Réel post', val: fmtPips(result.drift?.post_pips) },
{ label: 'Mode prix', val: result.prices_mode },
{ label: 'Fuite info', val: result.drift?.leak ?? '—' },
].map(m => (
<div key={m.label} className="bg-dark-800 rounded p-3 text-center">
<div className="text-xs text-slate-500">{m.label}</div>
<div className="text-sm font-bold text-slate-200 mt-0.5">{m.val}</div>
</div>
))}
</div>
<div className="flex flex-wrap gap-2">
{Object.entries(result.activation.nodes).map(([id, node]) => {
const Icon = node.status === 'correct' ? CheckCircle : node.status === 'wrong' ? XCircle : Minus
const col =
node.status === 'correct' ? 'text-emerald-400 border-emerald-700/40 bg-emerald-900/10' :
node.status === 'wrong' ? 'text-red-400 border-red-700/40 bg-red-900/10' :
'text-slate-500 border-slate-700/40 bg-slate-800/30'
return (
<div key={id} className={clsx('flex items-center gap-1.5 px-3 py-1.5 rounded border text-xs', col)}>
<Icon className="w-3.5 h-3.5" />
<span>{node.label}</span>
{node.pred != null && (
<span className="opacity-70">
P:{node.pred > 0 ? '+' : ''}{node.pred.toFixed(1)} /
A:{node.act != null ? (node.act > 0 ? '+' : '') + node.act.toFixed(1) : '—'}
</span>
)}
</div>
)
})}
</div>
</div>
<div className="bg-dark-700 rounded-lg p-4 border border-slate-700/40">
<h4 className="text-slate-300 text-xs font-semibold mb-3 uppercase tracking-wider">
Graphe causal valeurs calculées
</h4>
{currentTemplate && (
<GraphSVG graph={currentTemplate.graph_json}
nodeValues={result.node_values}
activationNodes={result.activation.nodes} />
)}
</div>
{result.yields && (
<div className="bg-dark-700 rounded-lg p-4 border border-slate-700/40">
<h4 className="text-slate-300 text-xs font-semibold mb-3 uppercase tracking-wider">
Taux réels
</h4>
<div className="grid grid-cols-3 gap-3">
{[
{ label: 'US 10Y', v: result.yields.us10y },
{ label: 'EU 10Y', v: result.yields.eu10y },
{ label: 'US 2Y', v: result.yields.us2y },
].map(y => (
<div key={y.label} className="bg-dark-800 rounded p-3 text-center">
<div className="text-xs text-slate-500">{y.label}</div>
<div className={clsx('text-sm font-bold mt-0.5',
y.v == null ? 'text-slate-600' : y.v > 0 ? 'text-red-400' : 'text-emerald-400')}>
{y.v == null ? '—' : `${y.v > 0 ? '+' : ''}${y.v.toFixed(3)}%`}
</div>
</div>
))}
</div>
</div>
)}
</>
)}
{(loadingAn || loadingRec) && (
<div className="flex items-center justify-center h-48 text-slate-500 gap-3">
<RefreshCw className="w-5 h-5 animate-spin" />
<span className="text-sm">
{loadingAn ? 'Récupération des prix (yfinance)…' : 'GPT-4o en cours…'}
</span>
</div>
)}
{!result && !loadingAn && !loadingRec && !rec && (
<div className="flex flex-col items-center justify-center h-64 text-slate-600 space-y-3">
<FlaskConical className="w-12 h-12 opacity-30" />
<div className="text-sm text-center">
Sélectionnez un événement, choisissez un template et lancez l'analyse
</div>
</div>
)}
</div>
</div>
)
}
// ── Tab Calibration ───────────────────────────────────────────────────────────
function TabCalibration() {
const [data, setData] = useState<CalibRow[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
api('/api/causal-lab/calibration').then(setData).finally(() => setLoading(false))
}, [])
if (loading) return <div className="text-slate-500 text-sm p-6">Chargement…</div>
return (
<div className="space-y-3">
<div className="text-xs text-slate-500 flex items-center gap-2">
<AlertCircle className="w-3.5 h-3.5" />
Statistiques agrégées de toutes les analyses par template.
Le ratio pred/réel converge vers 1.0 quand les coefficients sont bien calibrés.
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-slate-700/50">
{['Template', 'Catégorie', 'Nb analyses', 'Activation moy.', 'Pred. moy. (pip)', 'Réel moy. (pip)', 'Ratio'].map(h => (
<th key={h} className="px-3 py-2 text-left text-slate-500 uppercase tracking-wider font-medium">{h}</th>
))}
</tr>
</thead>
<tbody>
{data.map(row => {
const calib = row.calibration || {}
const instKey = Object.keys(calib).find(k =>
!['n_events', 'avg_activation', 'last_analyzed'].includes(k))
const instData = instKey ? calib[instKey] : null
return (
<tr key={row.id} className="border-b border-slate-800/50 hover:bg-dark-700/30 transition-colors">
<td className="px-3 py-2.5 text-slate-200 font-medium">{row.name}</td>
<td className="px-3 py-2.5 text-slate-400">{CAT_LABELS[row.category] ?? row.category}</td>
<td className="px-3 py-2.5 text-slate-300">{row.n_analyses}</td>
<td className="px-3 py-2.5">
{row.avg_activation != null
? <span className={clsx('font-bold',
row.avg_activation >= 0.7 ? 'text-emerald-400' :
row.avg_activation >= 0.4 ? 'text-yellow-400' : 'text-red-400')}>
{(row.avg_activation * 100).toFixed(0)}%
</span>
: <span className="text-slate-600">—</span>}
</td>
<td className="px-3 py-2.5 text-slate-300">{instData?.avg_pred?.toFixed(1) ?? ''}</td>
<td className="px-3 py-2.5 text-slate-300">{instData?.avg_actual?.toFixed(1) ?? ''}</td>
<td className="px-3 py-2.5">
{instData?.coef_ratio != null
? <span className={clsx('font-bold',
Math.abs(instData.coef_ratio - 1) < 0.2 ? 'text-emerald-400' :
Math.abs(instData.coef_ratio - 1) < 0.5 ? 'text-yellow-400' : 'text-red-400')}>
{instData.coef_ratio.toFixed(2)}x
</span>
: <span className="text-slate-600">—</span>}
</td>
</tr>
)
})}
</tbody>
</table>
{data.length === 0 && (
<div className="text-center py-12 text-slate-600 text-sm">
Aucune analyse effectuée pour l'instant. Lancez des analyses dans l'onglet "Analyser".
</div>
)}
</div>
</div>
)
}
// ── Page principale ───────────────────────────────────────────────────────────
const TABS = [
{ id: 'library', label: 'Bibliothèque', icon: Library },
{ id: 'analyze', label: 'Analyser', icon: FlaskConical },
{ id: 'calib', label: 'Calibration', icon: BarChart3 },
]
export default function CausalLab() {
const [tab, setTab] = useState('library')
return (
<div className="p-6 min-h-screen">
<div className="flex items-center gap-3 mb-6">
<FlaskConical className="w-6 h-6 text-blue-400" />
<div>
<h1 className="text-xl font-bold text-white">Lab Causal</h1>
<p className="text-slate-400 text-xs mt-0.5">
Bibliothèque de graphes causaux · Analyse empirique · Calibration IA
</p>
</div>
</div>
<div className="flex gap-1 mb-6 bg-dark-800 p-1 rounded-lg w-fit border border-slate-700/30">
{TABS.map(({ id, label, icon: Icon }) => (
<button key={id} onClick={() => setTab(id)}
className={clsx('flex items-center gap-2 px-4 py-2 rounded text-sm font-medium transition-all',
tab === id ? 'bg-blue-600 text-white' : 'text-slate-400 hover:text-slate-200')}>
<Icon className="w-4 h-4" />
{label}
</button>
))}
</div>
<div className="bg-dark-800 rounded-xl p-5 border border-slate-700/30">
{tab === 'library' && <TabLibrary />}
{tab === 'analyze' && <TabAnalyze />}
{tab === 'calib' && <TabCalibration />}
</div>
</div>
)
}