From 863ba67610c4a22c5717cd42526b2252deeb9082 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Sun, 28 Jun 2026 14:18:43 +0200 Subject: [PATCH] feat: causal lab --- backend/routers/causal_lab.py | 9 +- frontend/src/pages/CausalLab.tsx | 24 +- frontend/src/pages/InstrumentDashboard.tsx | 19 +- frontend/src/pages/MarketEvents.tsx | 292 +++++++++++++++------ 4 files changed, 247 insertions(+), 97 deletions(-) diff --git a/backend/routers/causal_lab.py b/backend/routers/causal_lab.py index 3c30d8a..09c41c0 100644 --- a/backend/routers/causal_lab.py +++ b/backend/routers/causal_lab.py @@ -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) diff --git a/frontend/src/pages/CausalLab.tsx b/frontend/src/pages/CausalLab.tsx index 7ddc47f..9ed71c6 100644 --- a/frontend/src/pages/CausalLab.tsx +++ b/frontend/src/pages/CausalLab.tsx @@ -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([]) const [templates, setTemplates] = useState([]) const [selEvent, setSelEvent] = useState(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() { @@ -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 (
@@ -1714,7 +1726,7 @@ export default function CausalLab() {
{tab === 'library' && } {tab === 'editor' && } - {tab === 'analyze' && } + {tab === 'analyze' && } {tab === 'calib' && }
diff --git a/frontend/src/pages/InstrumentDashboard.tsx b/frontend/src/pages/InstrumentDashboard.tsx index b545640..c90f639 100644 --- a/frontend/src/pages/InstrumentDashboard.tsx +++ b/frontend/src/pages/InstrumentDashboard.tsx @@ -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 = { - 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 = { + 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 diff --git a/frontend/src/pages/MarketEvents.tsx b/frontend/src/pages/MarketEvents.tsx index b3b5e0c..b00726c 100644 --- a/frontend/src/pages/MarketEvents.tsx +++ b/frontend/src/pages/MarketEvents.tsx @@ -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 +} + +interface TemplateRef { + id: number + name: string + category: string + instruments: string[] +} + // ── Constants ───────────────────────────────────────────────────────────────── const CAT_COLORS: Record = { @@ -304,14 +322,22 @@ function EventDetail({ onUpdated: (ev: MarketEvent) => void onDeleted: (id: number) => void }) { + const navigate = useNavigate() const [detail, setDetail] = useState(event) - const [evaluating, setEval] = useState(false) - const [evalMsg, setEvalMsg] = useState(null) const [editing, setEditing] = useState(false) const [editData, setEditData] = useState>({}) const [saving, setSaving] = useState(false) const [allCats, setAllCats] = useState([]) const [catSaving, setCatSaving] = useState(false) + const [analyses, setAnalyses] = useState([]) + // Inline analysis + const [templates, setTemplates] = useState([]) + const [selTmpl, setSelTmpl] = useState(0) + const [instrument, setInstrument] = useState('EURUSD') + const [loadingRec, setLoadingRec] = useState(false) + const [loadingAn, setLoadingAn] = useState(false) + const [recMsg, setRecMsg] = useState(null) + const [anResult, setAnResult] = useState<{ score: number | null; preds: Record } | 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({ )} - {/* Instrument impacts */} -
-
-
- Impacts instruments - ({impacts.length}) -
-
- {evalMsg && ( - - {evalMsg} - - )} - -
+ {/* Causal graphs */} +
+
+ Graphes causaux + {analyses.length > 0 && ({analyses.length})}
- {impacts.length === 0 ? ( -
- Aucun impact évalué — cliquez sur "Analyser IA" pour lancer l'évaluation. + {/* Existing analyses */} + {analyses.length > 0 && ( +
+ {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 ( + + ) + })}
- ) : ( -
- {/* Header */} -
- Instrument - - Score - Conf. - Rationale - - - - + )} + + {/* Inline analysis panel */} +
+
+ {analyses.length === 0 ? 'Aucun graphe — lancez une analyse' : 'Nouvelle analyse'} +
+ + {/* AI recommend + template picker */} +
+ + +
+ + {recMsg && ( +
+ {recMsg}
- {impacts - .slice() - .sort((a, b) => (b.adjusted_score ?? b.impact_score) - (a.adjusted_score ?? a.impact_score)) - .map(imp => ( - + )} + + {/* Instrument + analyze */} +
+ + +
- )} - {impacts.length === 0 && ( -
- -
- )} + + {/* Inline result */} + {anResult && ( +
+
+ Activation + {anResult.score != null ? ( + = 0.7 ? 'text-emerald-400' : + anResult.score >= 0.4 ? 'text-amber-400' : 'text-red-400' + )}>{Math.round(anResult.score * 100)}% + ) : Erreur} +
+ {Object.entries(anResult.preds) + .filter(([k]) => !k.includes('error')) + .sort(([, a], [, b]) => Math.abs(b) - Math.abs(a)) + .slice(0, 4) + .map(([k, v]) => ( +
+ {k} + 0 ? 'text-emerald-400' : 'text-red-400')}> + {v > 0 ? '+' : ''}{Math.round(v)} pip + +
+ ))} +
+ )} + + {templates.length === 0 && ( +

+ Aucun template disponible —{' '} + +

+ )} +