feat:: causal lab

This commit is contained in:
OpenSquared
2026-06-28 15:38:19 +02:00
parent 863ba67610
commit 1e44557551
7 changed files with 321 additions and 84 deletions

View File

@@ -122,27 +122,44 @@ function BootstrapAction({ action }: { action: ActionDef }) {
interface CheckEventsConfig {
sources: string[]
news_impact_min: number
news_lookback_hours: number
eco_z_threshold: number
eco_days: number
technical_lookback_days: number
report_days: number
report_min_importance: number
date_from: string
date_to: string
}
const DEFAULT_CONFIG: CheckEventsConfig = {
sources: ['news', 'eco', 'technical', 'reports'],
news_impact_min: 0.55,
news_lookback_hours: 48,
eco_z_threshold: 1.5,
eco_days: 7,
technical_lookback_days: 7,
report_days: 7,
report_min_importance: 3,
const SCAN_PRESETS = [
{ label: '6h', hours: 6 },
{ label: '12h', hours: 12 },
{ label: '24h', hours: 24 },
{ label: '48h', hours: 48 },
{ label: '72h', hours: 72 },
{ label: '7j', hours: 168 },
]
function presetDates(hours: number): { date_from: string; date_to: string } {
const now = new Date()
const from = new Date(now.getTime() - hours * 3_600_000)
const fmt = (d: Date) => d.toISOString().slice(0, 10)
return { date_from: fmt(from), date_to: fmt(now) }
}
function makeDefaultConfig(): CheckEventsConfig {
return {
sources: ['news', 'eco', 'technical', 'reports'],
news_impact_min: 0.55,
eco_z_threshold: 1.5,
technical_lookback_days: 7,
report_days: 7,
report_min_importance: 3,
...presetDates(48),
}
}
function CheckEventsPanel() {
const [config, setConfig] = useState<CheckEventsConfig>(DEFAULT_CONFIG)
const [config, setConfig] = useState<CheckEventsConfig>(makeDefaultConfig)
const [running, setRunning] = useState(false)
const [result, setResult] = useState<ActionResult | null>(null)
const [error, setError] = useState<string | null>(null)
@@ -215,14 +232,46 @@ function CheckEventsPanel() {
</div>
{showConfig && (
<div className="px-5 pb-4 border-t border-slate-700/30 pt-4 grid grid-cols-2 gap-4 text-sm">
<div className="px-5 pb-5 border-t border-slate-700/30 pt-4 space-y-4 text-sm">
{/* Fenêtre de scan */}
<div>
<div className="text-slate-400 mb-2 text-xs uppercase tracking-wide">Fenêtre de scan (news + éco)</div>
<div className="flex flex-wrap gap-1.5 mb-2">
{SCAN_PRESETS.map(p => {
const { date_from, date_to } = presetDates(p.hours)
const active = config.date_from === date_from && config.date_to === date_to
return (
<button key={p.label}
onClick={() => setConfig(c => ({ ...c, ...presetDates(p.hours) }))}
className={`px-3 py-1 rounded text-xs border transition-colors ${
active
? 'bg-blue-600/30 border-blue-500/50 text-blue-300'
: 'border-slate-700 text-slate-500 hover:border-slate-500 hover:text-slate-300'
}`}
>
{p.label}
</button>
)
})}
</div>
<div className="flex gap-2 items-center">
<input type="date" value={config.date_from}
onChange={e => setConfig(c => ({ ...c, date_from: e.target.value }))}
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-xs flex-1" />
<span className="text-slate-600 text-xs"></span>
<input type="date" value={config.date_to}
onChange={e => setConfig(c => ({ ...c, date_to: e.target.value }))}
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-xs flex-1" />
</div>
</div>
{/* Sources */}
<div>
<div className="text-slate-400 mb-2 text-xs uppercase tracking-wide">Sources actives</div>
<div className="flex flex-wrap gap-2">
{['news', 'eco', 'technical', 'reports'].map(s => (
<button
key={s}
onClick={() => toggleSource(s)}
<button key={s} onClick={() => toggleSource(s)}
className={`px-3 py-1 rounded-full text-xs border transition-colors ${
config.sources.includes(s)
? 'bg-emerald-600/30 border-emerald-500/50 text-emerald-300'
@@ -234,38 +283,29 @@ function CheckEventsPanel() {
))}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
{/* Advanced */}
<div className="grid grid-cols-3 gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs text-slate-400">News impact min</span>
<input type="number" step="0.05" min="0" max="1"
value={config.news_impact_min}
onChange={e => setConfig(c => ({ ...c, news_impact_min: +e.target.value }))}
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm w-full"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-slate-400">News lookback (h)</span>
<input type="number" step="12" min="6" max="168"
value={config.news_lookback_hours}
onChange={e => setConfig(c => ({ ...c, news_lookback_hours: +e.target.value }))}
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm w-full"
/>
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm w-full" />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-slate-400">Éco z-score min</span>
<input type="number" step="0.25" min="0.5" max="5"
value={config.eco_z_threshold}
onChange={e => setConfig(c => ({ ...c, eco_z_threshold: +e.target.value }))}
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm w-full"
/>
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm w-full" />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-slate-400">Report importance min</span>
<input type="number" step="1" min="1" max="5"
value={config.report_min_importance}
onChange={e => setConfig(c => ({ ...c, report_min_importance: +e.target.value }))}
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm w-full"
/>
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm w-full" />
</label>
</div>
</div>

View File

@@ -31,6 +31,7 @@ interface LinePoint { time: string; value: number }
interface SnapshotEvent {
id?: number; template_id?: number | null
analyzed_instruments?: string | null // comma-sep: "EURUSD,SP500" — set when causal analysis exists
date: string; end_date: string | null; title: string; level: string
category: string; sub_type?: string; description: string; impact_score: number
expected_value?: string | null; actual_value?: string | null
@@ -686,11 +687,12 @@ function EventTimeline({
return () => obs.disconnect()
}, [])
// Only events that have a causal graph linked to this instrument
// Only events that have an instantiated analysis for this instrument
// Uses analyzed_instruments (actual DB analyses) — not template.instruments (theoretical)
const linked = events.filter(ev => {
if (!ev.template_id) return false
const tmpl = templates.find(t => t.id === ev.template_id)
return tmpl != null && causalInsts.some(ci => tmpl.instruments.includes(ci))
if (!ev.analyzed_instruments) return false
const insts = ev.analyzed_instruments.split(',')
return causalInsts.some(ci => insts.includes(ci))
})
if (!priceData.length || !linked.length) {
@@ -781,11 +783,11 @@ function EventGraphCards({
}) {
const navigate = useNavigate()
// Events that have a causal graph template touching this instrument
// Events that have an instantiated causal analysis for this instrument
const linkedEvents = events.filter(ev => {
if (!ev.template_id) return false
const tmpl = templates.find(t => t.id === ev.template_id)
return tmpl != null && causalInsts.some(ci => tmpl.instruments.includes(ci))
if (!ev.analyzed_instruments) return false
const insts = ev.analyzed_instruments.split(',')
return causalInsts.some(ci => insts.includes(ci))
})
// Unique templates from those linked events

View File

@@ -335,8 +335,11 @@ function EventDetail({
const [selTmpl, setSelTmpl] = useState(0)
const [instrument, setInstrument] = useState('EURUSD')
const [loadingRec, setLoadingRec] = useState(false)
const [loadingInst, setLoadingInst] = useState(false)
const [loadingAn, setLoadingAn] = useState(false)
const [recMsg, setRecMsg] = useState<string | null>(null)
const [instInputs, setInstInputs] = useState<Record<string, number>>({})
const [instMsg, setInstMsg] = useState<string | null>(null)
const [anResult, setAnResult] = useState<{ score: number | null; preds: Record<string, number> } | null>(null)
const loadDetail = useCallback(async () => {
@@ -409,8 +412,24 @@ function EventDetail({
onClose()
}
const instantiate = async (tmplId: number) => {
if (!tmplId) return
setLoadingInst(true); setInstMsg(null); setInstInputs({})
try {
const r = await fetch('/api/causal-lab/instantiate', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ market_event_id: detail.id, template_id: tmplId }),
})
const d = await r.json()
if (!r.ok) throw new Error(d.detail || r.statusText)
setInstInputs(d.inputs || {})
setInstMsg(d.rationale || null)
} catch (e: any) { setInstMsg(`${(e as any).message}`) }
finally { setLoadingInst(false) }
}
const recommendTemplate = async () => {
setLoadingRec(true); setRecMsg(null); setAnResult(null)
setLoadingRec(true); setRecMsg(null); setAnResult(null); setInstInputs({}); setInstMsg(null)
try {
const r = await fetch('/api/causal-lab/recommend', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
@@ -422,10 +441,11 @@ function EventDetail({
if (rec?.template_id) {
setSelTmpl(rec.template_id)
setRecMsg(`${rec.template_name}${rec.confidence ? ` (${Math.round(rec.confidence * 100)}%)` : ''}`)
await instantiate(rec.template_id)
} else {
setRecMsg('Aucun template correspondant — créez-en un dans l\'Editeur')
setRecMsg('Aucun template — créez-en un dans l\'Editeur')
}
} catch (e: any) { setRecMsg(`${e.message}`) }
} catch (e: any) { setRecMsg(`${(e as any).message}`) }
finally { setLoadingRec(false) }
}
@@ -435,13 +455,13 @@ function EventDetail({
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: {} }),
body: JSON.stringify({ market_event_id: detail.id, template_id: selTmpl, instrument, inputs: instInputs, 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 } }) }
} catch (e: any) { setAnResult({ score: null, preds: {} }) }
finally { setLoadingAn(false) }
}
@@ -738,6 +758,37 @@ function EventDetail({
</div>
)}
{/* Instantiation status + inputs preview */}
{selTmpl > 0 && (
<div className="space-y-1">
<div className="flex items-center gap-2">
<button
onClick={() => instantiate(selTmpl)}
disabled={loadingInst}
className="flex items-center gap-1 text-xs text-slate-500 hover:text-purple-400 disabled:opacity-50 transition-colors"
>
{loadingInst ? <Loader2 size={9} className="animate-spin" /> : <Sparkles size={9} />}
{loadingInst ? 'Calibration IA' : 'Calibrer (IA)'}
</button>
{instMsg && !instMsg.startsWith('') && (
<span className="text-xs text-slate-600 truncate">{instMsg}</span>
)}
</div>
{Object.keys(instInputs).length > 0 && (
<div className="flex flex-wrap gap-1.5">
{Object.entries(instInputs).map(([k, v]) => (
<span key={k} className={clsx(
'text-xs font-mono px-1.5 py-0.5 rounded border',
v > 0 ? 'text-emerald-400 border-emerald-800/50 bg-emerald-900/20' : v < 0 ? 'text-red-400 border-red-800/50 bg-red-900/20' : 'text-slate-500 border-slate-700/50'
)}>
{k}: {v > 0 ? '+' : ''}{v.toFixed(2)}
</span>
))}
</div>
)}
</div>
)}
{/* Instrument + analyze */}
<div className="flex gap-1.5">
<select