diff --git a/backend/routers/causal_lab.py b/backend/routers/causal_lab.py index 1e3030f..b0f3dcb 100644 --- a/backend/routers/causal_lab.py +++ b/backend/routers/causal_lab.py @@ -923,25 +923,37 @@ def analyze_event(body: AnalyzeRequest): "graph_json": graph, # structure complète pour visualisation frontend } - # Persistance - conn.execute(""" - INSERT INTO causal_event_analyses - (market_event_id, template_id, instrument, inputs_json, - override_params, prediction_json, actual_json, - activation_score, drift_json, analyzed_at) - VALUES (?,?,?,?,?,?,?,?,?,?) - """, ( - body.market_event_id, - body.template_id, - primary_inst, - json.dumps(inputs), - json.dumps(body.coef_overrides), - json.dumps(node_values), - json.dumps(actual_moves), - activation.get("score"), - json.dumps(drift_by_inst.get(primary_inst, {})), - analyzed_at, - )) + # Persistance — UPSERT : one analysis per (market_event_id, template_id) + existing = conn.execute( + "SELECT id FROM causal_event_analyses WHERE market_event_id=? AND template_id=?", + (body.market_event_id, body.template_id), + ).fetchone() + if existing: + conn.execute(""" + UPDATE causal_event_analyses + SET instrument=?, inputs_json=?, override_params=?, prediction_json=?, + actual_json=?, activation_score=?, drift_json=?, analyzed_at=? + WHERE market_event_id=? AND template_id=? + """, ( + primary_inst, json.dumps(inputs), json.dumps(body.coef_overrides), + json.dumps(node_values), json.dumps(actual_moves), activation.get("score"), + json.dumps(drift_by_inst.get(primary_inst, {})), analyzed_at, + body.market_event_id, body.template_id, + )) + else: + conn.execute(""" + INSERT INTO causal_event_analyses + (market_event_id, template_id, instrument, inputs_json, + override_params, prediction_json, actual_json, + activation_score, drift_json, analyzed_at) + VALUES (?,?,?,?,?,?,?,?,?,?) + """, ( + body.market_event_id, body.template_id, primary_inst, + json.dumps(inputs), json.dumps(body.coef_overrides), + json.dumps(node_values), json.dumps(actual_moves), + activation.get("score"), + json.dumps(drift_by_inst.get(primary_inst, {})), analyzed_at, + )) conn.commit() # Calibration @@ -1206,6 +1218,25 @@ def list_analyses( raise HTTPException(500, str(e)) +@router.delete("/api/causal-lab/analyses/{analysis_id}") +def delete_analysis(analysis_id: int): + try: + from services.database import get_conn + conn = get_conn() + _init(conn) + row = conn.execute("SELECT id FROM causal_event_analyses WHERE id=?", (analysis_id,)).fetchone() + if not row: + conn.close(); raise HTTPException(404, "Analyse introuvable") + conn.execute("DELETE FROM causal_event_analyses WHERE id=?", (analysis_id,)) + conn.commit(); conn.close() + return {"ok": True} + except HTTPException: + raise + except Exception as e: + logger.error(f"[causal_lab] delete_analysis: {e}") + raise HTTPException(500, str(e)) + + @router.get("/api/causal-lab/calibration") def get_calibration(): """Statistiques de calibration par template (avg_activation, avg_pred vs avg_actual).""" diff --git a/frontend/src/pages/MarketEvents.tsx b/frontend/src/pages/MarketEvents.tsx index ed38556..ac3bb3c 100644 --- a/frontend/src/pages/MarketEvents.tsx +++ b/frontend/src/pages/MarketEvents.tsx @@ -59,6 +59,7 @@ interface CausalAnalysis { instrument: string activation_score: number | null analyzed_at: string + inputs_json: Record prediction_json: Record actual_json: Record graph_json?: { nodes: GraphNode[]; edges: GraphEdge[] } @@ -312,7 +313,7 @@ function CausalGraphMini({ nodes, edges, nodeValues }: { const BOX_W = 90, BOX_H = 36, SHORTEN = 21 return ( - + {(['positive', 'negative', 'neutral'] as const).map(s => ( @@ -415,7 +416,7 @@ function EventDetail({ if (data.length > 0) { const latest = data[0] if (latest.template_id) setSelTmpl(latest.template_id) - // Reconstruct graph state from stored analysis so it's always visible on reopen + if (Object.keys(latest.inputs_json || {}).length > 0) setInstInputs(latest.inputs_json) setAnResult({ score: latest.activation_score, preds: latest.prediction_json || {}, @@ -517,6 +518,13 @@ function EventDetail({ 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) @@ -699,31 +707,39 @@ function EventDetail({ .sort(([, v1], [, v2]) => Math.abs(v2) - Math.abs(v1)) .slice(0, 3) return ( -
- {a.template_name} - {score != null && ( - = 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}% - )} -
-
- {a.instrument} - {topPreds.map(([k, v]) => ( - 0 ? 'text-emerald-500' : 'text-red-500')}> - {k}: {v > 0 ? '+' : ''}{Math.round(v)} - - ))} -
- +
+ + +
) })} @@ -822,54 +838,103 @@ function EventDetail({ - {/* Inline result */} - {anResult && ( -
- {/* Activation score */} + {/* 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 ( +
+ {/* Activation row */} +
+ Activation + {anResult.score != null ? ( + = 0.7 ? 'text-emerald-400' : + anResult.score >= 0.4 ? 'text-amber-400' : 'text-red-400' + )}>{Math.round(anResult.score * 100)}% + ) : données manquantes} +
+ {/* Side-by-side: recap left + graph right */} +
+ {/* ── Recap panel ── */} +
+ {inputNodes.length > 0 && ( +
+
Entrées
+ {inputNodes.map(n => { + const val = anResult.preds[n.id] ?? instInputs[n.id] + return ( +
+ {n.label} + 0 ? 'text-emerald-400' : val < 0 ? 'text-red-400' : 'text-slate-500')}> + {val != null ? `${val > 0 ? '+' : ''}${val.toFixed(1)}` : '—'} + +
+ ) + })} +
+ )} + {outputNodes.length > 0 && ( +
+
Prédictions
+ {outputNodes.map(n => { + const pred = anResult.preds[n.id] + const actual = anResult.actuals[n.instrument || n.id] ?? anResult.actuals[n.id] + return ( +
+
+ {n.label} + 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 === '%' ? '%' : ''}` : '—'} + +
+ {actual != null && ( +
+ réel + 0 ? 'text-emerald-300' : 'text-red-300')}> + {actual > 0 ? '+' : ''}{Math.round(actual)}p + +
+ )} +
+ ) + })} +
+ )} +
+ {/* ── Graph ── */} +
+ +
+
+
+ ) + })()} + {anResult && !graphData?.nodes?.length && ( +
Activation {anResult.score != null ? ( - = 0.7 ? 'text-emerald-400' : - anResult.score >= 0.4 ? 'text-amber-400' : 'text-red-400' + = 0.7 ? 'text-emerald-400' : anResult.score >= 0.4 ? 'text-amber-400' : 'text-red-400' )}>{Math.round(anResult.score * 100)}% ) : données manquantes}
- - {/* Per-instrument actual moves */} - {Object.keys(anResult.actuals).length > 0 && ( -
- {Object.entries(anResult.actuals).map(([inst, move]) => { - const pred = Object.entries(anResult.preds) - .find(([k]) => k.toLowerCase().includes(inst.toLowerCase()))?.[1] - return ( -
- {inst} - {pred !== undefined && ( - 0 ? 'text-blue-400' : 'text-orange-400')}> - pred {pred > 0 ? '+' : ''}{Math.round(pred)}p - - )} - 0 ? 'text-emerald-400' : 'text-red-400')}> - {move > 0 ? '+' : ''}{Math.round(move)}p - -
- ) - })} -
- )} - - {/* Causal graph visualization */} - {graphData?.nodes?.length && ( -
- -
- )} + {Object.entries(anResult.actuals).map(([inst, move]) => { + const pred = Object.entries(anResult.preds).find(([k]) => k.toLowerCase().includes(inst.toLowerCase()))?.[1] + return ( +
+ {inst} + {pred !== undefined && 0 ? 'text-blue-400' : 'text-orange-400')}>pred {pred > 0 ? '+' : ''}{Math.round(pred)}p} + 0 ? 'text-emerald-400' : 'text-red-400')}>{move > 0 ? '+' : ''}{Math.round(move)}p +
+ ) + })}
)}