feat: market event

This commit is contained in:
OpenSquared
2026-06-29 08:14:25 +02:00
parent 5e65424500
commit 97077af369
2 changed files with 184 additions and 88 deletions

View File

@@ -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)."""

View File

@@ -59,6 +59,7 @@ interface CausalAnalysis {
instrument: string
activation_score: number | null
analyzed_at: string
inputs_json: Record<string, number>
prediction_json: Record<string, number>
actual_json: Record<string, number>
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 (
<svg viewBox={`${minX} ${minY} ${vw} ${vh}`} className="w-full" style={{ maxHeight: 250 }}>
<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">
@@ -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 (
<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 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>
@@ -822,54 +838,103 @@ function EventDetail({
</button>
</div>
{/* Inline result */}
{anResult && (
<div className="bg-slate-900/60 rounded p-2 space-y-2">
{/* 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 (
<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>
{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'
<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>
{/* Per-instrument actual moves */}
{Object.keys(anResult.actuals).length > 0 && (
<div className="space-y-1">
{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">
<span className="text-xs font-mono text-slate-400 w-16 shrink-0">{inst}</span>
{pred !== undefined && (
<span className={clsx('text-xs font-mono', pred > 0 ? 'text-blue-400' : 'text-orange-400')}>
pred {pred > 0 ? '+' : ''}{Math.round(pred)}p
</span>
)}
<span className={clsx('text-xs font-mono font-bold ml-auto', move > 0 ? 'text-emerald-400' : 'text-red-400')}>
{move > 0 ? '+' : ''}{Math.round(move)}p
</span>
</div>
)
})}
</div>
)}
{/* Causal graph visualization */}
{graphData?.nodes?.length && (
<div className="border-t border-slate-700/50 pt-2">
<CausalGraphMini
nodes={graphData.nodes}
edges={graphData.edges}
nodeValues={anResult.preds}
/>
</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>
)}