feat: causal lab

This commit is contained in:
OpenSquared
2026-06-28 14:18:43 +02:00
parent 20f1897211
commit 863ba67610
4 changed files with 247 additions and 97 deletions

View File

@@ -853,9 +853,10 @@ def analyze_event(body: AnalyzeRequest):
@router.get("/api/causal-lab/analyses")
def list_analyses(
template_id: int = Query(0),
instrument: str = Query(""),
limit: int = Query(100, le=500),
template_id: int = Query(0),
market_event_id: int = Query(0),
instrument: str = Query(""),
limit: int = Query(100, le=500),
):
try:
from services.database import get_conn
@@ -865,6 +866,8 @@ def list_analyses(
conditions = ["1=1"]
if template_id:
conditions.append(f"a.template_id = {template_id}")
if market_event_id:
conditions.append(f"a.market_event_id = {market_event_id}")
if instrument:
conditions.append(f"a.instrument = '{instrument}'")
where = " AND ".join(conditions)

View File

@@ -53,8 +53,8 @@ interface Template {
}
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
start_date: string; end_date: string | null; level: string; created_at: string | null
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
}
@@ -1318,7 +1318,7 @@ function TabEditor() {
// ── Tab: Analyser ─────────────────────────────────────────────────────────────
function TabAnalyze() {
function TabAnalyze({ initialEventId }: { initialEventId?: number | null }) {
const [events, setEvents] = useState<MarketEvent[]>([])
const [templates, setTemplates] = useState<Template[]>([])
const [selEvent, setSelEvent] = useState<MarketEvent | null>(null)
@@ -1340,6 +1340,12 @@ function TabAnalyze() {
]).then(([evs, tmps]) => { setEvents(evs); setTemplates(tmps) })
}, [catF, q])
useEffect(() => {
if (!initialEventId || !events.length || selEvent) return
const found = events.find(e => e.id === initialEventId)
if (found) setSelEvent(found)
}, [events, initialEventId])
const currentTemplate = templates.find(t => t.id === selTmpl)
const manualNodes = currentTemplate?.graph_json?.input_mapping
? Object.entries(currentTemplate.graph_json.input_mapping).filter(([, c]) =>
@@ -1389,7 +1395,12 @@ function TabAnalyze() {
<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>)}
<option value="event_calendar">Calendrier éco</option>
<option value="fundamental">Fondamental</option>
<option value="geopolitical">Géopolitique</option>
<option value="report">Report</option>
<option value="sentiment">Sentiment</option>
<option value="technical">Technique</option>
</select>
</div>
@@ -1686,7 +1697,8 @@ const TABS = [
export default function CausalLab() {
const [searchParams] = useSearchParams()
const initialTemplateId = searchParams.get('template') ? parseInt(searchParams.get('template')!) : null
const [tab, setTab] = useState('library')
const initialEventId = searchParams.get('event') ? parseInt(searchParams.get('event')!) : null
const [tab, setTab] = useState(initialEventId ? 'analyze' : 'library')
return (
<div className="p-6 min-h-screen">
@@ -1714,7 +1726,7 @@ export default function CausalLab() {
<div className="bg-dark-800 rounded-xl p-5 border border-slate-700/30">
{tab === 'library' && <TabLibrary initialTemplateId={initialTemplateId} />}
{tab === 'editor' && <TabEditor />}
{tab === 'analyze' && <TabAnalyze />}
{tab === 'analyze' && <TabAnalyze initialEventId={initialEventId} />}
{tab === 'calib' && <TabCalibration />}
</div>
</div>

View File

@@ -140,15 +140,16 @@ function isActiveAt(ev: SnapshotEvent, selectedDate: string | null): boolean {
// ── Causal graph config ───────────────────────────────────────────────────────
// Maps snapshot event category → causal template category
const EVENT_TO_CAUSAL_CAT: Record<string, string> = {
macro_us: 'macro_us',
macro_eu: 'macro_eu',
geopolitical: 'geopolitical',
report: 'report',
sentiment: 'sentiment',
commodity: 'commodity',
fundamental: 'macro_us',
event_calendar: 'macro_us',
// 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

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useSearchParams, useNavigate } from 'react-router-dom'
import {
Search, RefreshCw, Plus, Trash2, Edit3, Sparkles, ExternalLink,
ChevronDown, ChevronUp, Loader2, CheckCircle, AlertCircle, X,
@@ -51,6 +51,24 @@ interface MarketEvent {
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
prediction_json: Record<string, number>
}
interface TemplateRef {
id: number
name: string
category: string
instruments: string[]
}
// ── Constants ─────────────────────────────────────────────────────────────────
const CAT_COLORS: Record<string, string> = {
@@ -304,14 +322,22 @@ function EventDetail({
onUpdated: (ev: MarketEvent) => void
onDeleted: (id: number) => void
}) {
const navigate = useNavigate()
const [detail, setDetail] = useState<MarketEvent>(event)
const [evaluating, setEval] = useState(false)
const [evalMsg, setEvalMsg] = useState<string | null>(null)
const [editing, setEditing] = useState(false)
const [editData, setEditData] = useState<Partial<MarketEvent>>({})
const [saving, setSaving] = useState(false)
const [allCats, setAllCats] = useState<EventCategory[]>([])
const [catSaving, setCatSaving] = useState(false)
const [analyses, setAnalyses] = useState<CausalAnalysis[]>([])
// Inline analysis
const [templates, setTemplates] = useState<TemplateRef[]>([])
const [selTmpl, setSelTmpl] = useState(0)
const [instrument, setInstrument] = useState('EURUSD')
const [loadingRec, setLoadingRec] = useState(false)
const [loadingAn, setLoadingAn] = useState(false)
const [recMsg, setRecMsg] = useState<string | null>(null)
const [anResult, setAnResult] = useState<{ score: number | null; preds: Record<string, number> } | null>(null)
const loadDetail = useCallback(async () => {
const r = await fetch(`/api/market-events/${event.id}`)
@@ -321,7 +347,16 @@ function EventDetail({
}
}, [event.id])
useEffect(() => { loadDetail() }, [loadDetail])
const loadAnalyses = useCallback(async () => {
const r = await fetch(`/api/causal-lab/analyses?market_event_id=${event.id}&limit=20`)
if (r.ok) setAnalyses(await r.json())
}, [event.id])
useEffect(() => {
loadDetail()
loadAnalyses()
fetch('/api/causal-lab/templates').then(r => r.json()).then(d => setTemplates(Array.isArray(d) ? d : [])).catch(() => {})
}, [loadDetail, loadAnalyses])
useEffect(() => {
fetch('/api/impact/categories')
.then(r => r.json())
@@ -351,23 +386,6 @@ function EventDetail({
}
}
const evaluate = async (force = false) => {
setEval(true)
setEvalMsg(null)
try {
const r = await fetch(`/api/market-events/${event.id}/evaluate?force=${force}`, { method: 'POST' })
const d = await r.json()
if (!r.ok) throw new Error(d.detail || r.statusText)
const catInfo = d.matched_category ? ` · catégorie: ${d.matched_category}` : ''
setEvalMsg(`${d.n_instruments} instruments évalués${catInfo}`)
await loadDetail()
} catch (e: any) {
setEvalMsg(`${e.message}`)
} finally {
setEval(false)
}
}
const saveEdit = async () => {
setSaving(true)
const payload = { ...detail, ...editData, source_refs: detail.source_refs ?? [] }
@@ -391,7 +409,42 @@ function EventDetail({
onClose()
}
const impacts = detail.impacts || []
const recommendTemplate = async () => {
setLoadingRec(true); setRecMsg(null); setAnResult(null)
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)}%)` : ''}`)
} else {
setRecMsg('Aucun template correspondant — créez-en un dans l\'Editeur')
}
} catch (e: any) { setRecMsg(`${e.message}`) }
finally { setLoadingRec(false) }
}
const runAnalysis = async () => {
if (!selTmpl) return
setLoadingAn(true); setAnResult(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, instrument, inputs: {}, coef_overrides: {} }),
})
const d = await r.json()
if (!r.ok) throw new Error(d.detail || r.statusText)
setAnResult({ score: d.activation?.score ?? null, preds: d.node_values ?? {} })
await loadAnalyses()
} catch (e: any) { setAnResult({ score: null, preds: { error: -1 } }) }
finally { setLoadingAn(false) }
}
const evalScore = detail.impact_score
return (
@@ -606,69 +659,150 @@ function EventDetail({
)}
</div>
{/* Instrument impacts */}
<div>
<div className="flex items-center justify-between mb-2">
<div className="text-xs text-slate-500 uppercase tracking-wide">
Impacts instruments
<span className="text-slate-600 ml-1.5 normal-case">({impacts.length})</span>
</div>
<div className="flex items-center gap-2">
{evalMsg && (
<span className={clsx('text-xs', evalMsg.startsWith('') ? 'text-emerald-400' : 'text-red-400')}>
{evalMsg}
</span>
)}
<button
onClick={() => evaluate(impacts.length > 0)}
disabled={evaluating}
className="flex items-center gap-1.5 text-xs bg-blue-700/40 hover:bg-blue-700/60 border border-blue-700/50 text-blue-300 rounded px-3 py-1"
>
{evaluating ? <Loader2 size={11} className="animate-spin" /> : <Sparkles size={11} />}
{impacts.length > 0 ? '-analyser' : 'Analyser IA'}
</button>
</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>
{impacts.length === 0 ? (
<div className="text-xs text-slate-600 italic py-3 px-3 bg-slate-800/30 rounded-lg">
Aucun impact évalué — cliquez sur "Analyser IA" pour lancer l'évaluation.
{/* 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 (
<button
key={a.id}
onClick={() => navigate(`/causal-lab?template=${a.template_id}`)}
className="w-full 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>
)
})}
</div>
) : (
<div className="bg-slate-800/30 rounded-lg overflow-hidden">
{/* Header */}
<div className="grid text-xs text-slate-600 px-3 py-1.5 border-b border-slate-800 bg-slate-800/50"
style={{ gridTemplateColumns: '6rem 1.5rem 8rem 2.5rem 1fr 3rem 3rem 1.5rem 1.5rem' }}>
<span>Instrument</span>
<span></span>
<span>Score</span>
<span>Conf.</span>
<span>Rationale</span>
<span></span>
<span></span>
<span></span>
<span></span>
)}
{/* 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>
{impacts
.slice()
.sort((a, b) => (b.adjusted_score ?? b.impact_score) - (a.adjusted_score ?? a.impact_score))
.map(imp => (
<ImpactRow
key={imp.id}
imp={imp}
eventId={detail.id}
onUpdated={loadDetail}
onDeleted={loadDetail}
/>
)}
{/* Instrument + analyze */}
<div className="flex gap-1.5">
<select
value={instrument}
onChange={e => setInstrument(e.target.value)}
className="bg-dark-900 border border-slate-700 rounded px-2 py-1.5 text-xs text-slate-300"
>
{['EURUSD', 'XAUUSD', 'SP500', 'BRENT', 'US10Y', 'EU10Y'].map(i => (
<option key={i} value={i}>{i}</option>
))}
<AddInstrumentPanel eventId={detail.id} onAdded={loadDetail} />
</select>
<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'}
</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>
)}
{impacts.length === 0 && (
<div className="mt-2">
<AddInstrumentPanel eventId={detail.id} onAdded={loadDetail} />
</div>
)}
{/* Inline result */}
{anResult && (
<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-red-400">Erreur</span>}
</div>
{Object.entries(anResult.preds)
.filter(([k]) => !k.includes('error'))
.sort(([, a], [, b]) => Math.abs(b) - Math.abs(a))
.slice(0, 4)
.map(([k, v]) => (
<div key={k} className="flex justify-between items-center">
<span className="text-xs text-slate-500 truncate">{k}</span>
<span className={clsx('text-xs font-mono', v > 0 ? 'text-emerald-400' : 'text-red-400')}>
{v > 0 ? '+' : ''}{Math.round(v)} pip
</span>
</div>
))}
</div>
)}
{templates.length === 0 && (
<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>