feat: causal lab
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ? 'Ré-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>
|
||||
|
||||
Reference in New Issue
Block a user