feat: market event
This commit is contained in:
@@ -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)."""
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user