feat: instrument analysis

This commit is contained in:
OpenSquared
2026-06-29 20:44:03 +02:00
parent f4a55a8009
commit 5e819b5b67
2 changed files with 103 additions and 34 deletions

View File

@@ -543,9 +543,11 @@ def _get_relevant_events(
SELECT e.*,
(SELECT GROUP_CONCAT(DISTINCT a.instrument)
FROM causal_event_analyses a
WHERE a.market_event_id = e.id) AS analyzed_instruments,
(SELECT a.template_id FROM causal_event_analyses a WHERE a.market_event_id = e.id ORDER BY a.id DESC LIMIT 1) AS cea_template_id,
(SELECT a.id FROM causal_event_analyses a WHERE a.market_event_id = e.id ORDER BY a.id DESC LIMIT 1) AS analysis_id
WHERE a.market_event_id = e.id) AS analyzed_instruments,
(SELECT a.template_id FROM causal_event_analyses a WHERE a.market_event_id = e.id ORDER BY a.id DESC LIMIT 1) AS cea_template_id,
(SELECT a.id FROM causal_event_analyses a WHERE a.market_event_id = e.id ORDER BY a.id DESC LIMIT 1) AS analysis_id,
(SELECT a.prediction_json FROM causal_event_analyses a WHERE a.market_event_id = e.id ORDER BY a.id DESC LIMIT 1) AS cea_prediction_json,
(SELECT a.actual_json FROM causal_event_analyses a WHERE a.market_event_id = e.id ORDER BY a.id DESC LIMIT 1) AS cea_actual_json
FROM market_events e
ORDER BY e.start_date DESC
""").fetchall()
@@ -553,7 +555,9 @@ def _get_relevant_events(
# Use cea_template_id (from causal_event_analyses) explicitly to avoid
# any collision with an e.* column named template_id
all_events = [
{**dict(r), "template_id": r["cea_template_id"]}
{**dict(r), "template_id": r["cea_template_id"],
"prediction_json": r["cea_prediction_json"],
"actual_json": r["cea_actual_json"]}
for r in rows
]
except Exception as e:

View File

@@ -18,7 +18,10 @@ const NO_CHART_EVENTS: never[] = []
interface CausalTemplate {
id: number; name: string; category: string; sub_type: string
instruments: string[]; description: string
graph_json: { nodes: { id: string; type: string; label: string; instrument?: string }[]; edges: any[] }
graph_json: {
nodes: { id: string; type: string; label: string; instrument?: string }[]
edges: { from: string; to: string; lag_days?: number; lag_min?: number; sign?: string }[]
}
}
interface InstrumentConfig {
@@ -39,6 +42,8 @@ interface SnapshotEvent {
category: string; sub_type?: string; description: string; impact_score: number
expected_value?: string | null; actual_value?: string | null
surprise_pct?: number | null; unit?: string | null; absorption_pct?: number | null
prediction_json?: string | null // JSON: {node_id: pips} from causal_event_analyses
actual_json?: string | null // JSON: {instrument: pips} from causal_event_analyses
}
interface RegimeSignals {
@@ -690,6 +695,38 @@ function makeTdToX(priceData: PriceCandle[], width: number): (d: string) => numb
return (d: string) => (snap(d) / (N - 1)) * width
}
// ── Comprehension scoring ─────────────────────────────────────────────────────
function templateAbsorptionDays(tmpl: CausalTemplate): number {
const maxLag = Math.max(0, ...tmpl.graph_json.edges.map(e => e.lag_days || 0))
return maxLag > 0 ? maxLag : 30
}
/** Score 0-100 measuring how well the graph predicted actual pips for an instrument. */
function comprehensionScore(ev: SnapshotEvent, tmpl: CausalTemplate, instrument: string): number | null {
if (!ev.prediction_json || !ev.actual_json) return null
try {
const preds: Record<string, number> = JSON.parse(ev.prediction_json)
const actuals: Record<string, number> = JSON.parse(ev.actual_json)
const actual = actuals[instrument] ?? actuals[instrument.toUpperCase()] ?? actuals[instrument.toLowerCase()]
if (actual == null) return null
// Find the market_asset node for this instrument
const outputNode = tmpl.graph_json.nodes.find(n =>
n.type === 'market_asset' && n.instrument?.toUpperCase() === instrument.toUpperCase()
)
const predicted = outputNode ? (preds[outputNode.id] ?? null) : null
if (predicted == null) return null
if (predicted === 0 && actual === 0) return 100
if (predicted === 0) return 40
const sameDir = predicted * actual > 0
if (!sameDir) return 0
const ratio = Math.min(Math.abs(actual), Math.abs(predicted)) / Math.max(Math.abs(actual), Math.abs(predicted))
return Math.round(50 + ratio * 50)
} catch { return null }
}
// ── CausalFrise ───────────────────────────────────────────────────────────────
const FRISE_LANE_H = 24 // px par lane
@@ -784,8 +821,9 @@ function CausalFrise({
.map(ev => {
const tmpl = templates.find(t => t.id === ev.template_id)
if (!tmpl) return null
const absorptionDays = templateAbsorptionDays(tmpl)
const endDate = ev.end_date ?? (() => {
const d = new Date(ev.date); d.setDate(d.getDate() + 30)
const d = new Date(ev.date); d.setDate(d.getDate() + absorptionDays)
return d.toISOString().slice(0, 10)
})()
const x1 = tdToX(ev.date)
@@ -912,10 +950,10 @@ function CausalFrise({
<div className="text-[10px] text-slate-400 truncate mb-1.5">{ev.title}</div>
<div className="flex items-center gap-3 mb-2.5 text-[9px] text-slate-600">
<div className="flex items-center gap-3 mb-2 text-[9px] text-slate-600">
<span className="flex items-center gap-1">
<Clock className="w-2.5 h-2.5" />
{fmtDateFR(ev.date)} {ev.end_date ? fmtDateFR(ev.end_date) : '+30j'}
{fmtDateFR(ev.date)} {ev.end_date ? fmtDateFR(ev.end_date) : `+${templateAbsorptionDays(tmpl)}j`}
</span>
{ev.impact_score != null && (
<span className="text-amber-600"> {ev.impact_score.toFixed(1)}</span>
@@ -927,6 +965,26 @@ function CausalFrise({
)}
</div>
{/* Per-graph comprehension score */}
{(() => {
const inst = causalInsts.find(ci => tmpl.instruments.includes(ci)) ?? causalInsts[0]
const s = inst ? comprehensionScore(ev, tmpl, inst) : null
if (s == null) return null
const barCls = s >= 70 ? 'bg-emerald-500' : s >= 40 ? 'bg-amber-500' : 'bg-red-500'
const txtCls = s >= 70 ? 'text-emerald-400' : s >= 40 ? 'text-amber-400' : 'text-red-400'
return (
<div className="mb-2">
<div className="flex items-center justify-between text-[9px] text-slate-600 mb-0.5">
<span>Précision prédiction</span>
<span className={txtCls}>{s}%</span>
</div>
<div className="h-1 bg-slate-800 rounded-full overflow-hidden">
<div className={clsx('h-full rounded-full', barCls)} style={{ width: `${s}%` }} />
</div>
</div>
)
})()}
<div className="flex gap-1.5">
<button
onClick={() => { if (ev.id) navigate(`/market-events?event=${ev.id}`); setActiveChip(null) }}
@@ -956,47 +1014,55 @@ function CausalFrise({
// ── ExplanationScore ──────────────────────────────────────────────────────────
function ExplanationScore({
events, templates, selectedDate, causalInsts,
events, templates, causalInsts,
}: {
events: SnapshotEvent[]
templates: CausalTemplate[]
selectedDate: string | null
causalInsts: string[]
}) {
// Templates from events that have a causal graph touching this instrument
const linkedEvents = events.filter(ev => {
if (!ev.template_id) return false
// For each linked event, compute the comprehension score and collect scored ones
const scored: number[] = []
const totalLinked: number[] = []
for (const ev of events) {
if (!ev.template_id) continue
const tmpl = templates.find(t => t.id === ev.template_id)
return tmpl != null && causalInsts.some(ci => tmpl.instruments.includes(ci))
})
const linkedTemplateIds = new Set(linkedEvents.map(ev => ev.template_id!))
const periodTmpl = templates.filter(t => linkedTemplateIds.has(t.id))
const touchedActive = periodTmpl.filter(t =>
linkedEvents.filter(ev => ev.template_id === t.id).some(ev => isActiveAt(ev, selectedDate))
)
const score = periodTmpl.length > 0 ? (touchedActive.length / periodTmpl.length) * 100 : 0
const verdict = score >= 60 ? 'Bien expliqué' : score >= 30 ? 'Partiellement' : 'Peu lisible'
const badgeCls = score >= 60
if (!tmpl) continue
const inst = causalInsts.find(ci => tmpl.instruments.includes(ci))
if (!inst) continue
totalLinked.push(ev.id ?? 0)
const s = comprehensionScore(ev, tmpl, inst)
if (s != null) scored.push(s)
}
const globalScore = scored.length > 0
? Math.round(scored.reduce((a, b) => a + b, 0) / scored.length)
: null
const verdict = globalScore == null ? 'En attente'
: globalScore >= 70 ? 'Bien expliqué' : globalScore >= 40 ? 'Partiellement' : 'Peu lisible'
const badgeCls = globalScore == null
? 'text-slate-500 bg-slate-800/60 border-slate-700/30'
: globalScore >= 70
? 'text-emerald-400 bg-emerald-900/30 border-emerald-700/40'
: score >= 30
: globalScore >= 40
? 'text-amber-400 bg-amber-900/30 border-amber-700/40'
: 'text-slate-400 bg-slate-800/60 border-slate-700/30'
const barCls = score >= 60 ? 'bg-emerald-500' : score >= 30 ? 'bg-amber-500' : 'bg-slate-600'
: 'text-red-400 bg-red-900/20 border-red-700/30'
const barCls = globalScore == null ? 'bg-slate-600'
: globalScore >= 70 ? 'bg-emerald-500' : globalScore >= 40 ? 'bg-amber-500' : 'bg-red-500'
return (
<div className="flex items-center gap-3 px-4 py-2.5 rounded-xl border border-slate-700/30 bg-dark-800/50">
<span className="text-xs text-slate-500 whitespace-nowrap">Note globale</span>
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div className={clsx('h-full rounded-full transition-all', barCls)} style={{ width: `${score}%` }} />
<div className={clsx('h-full rounded-full transition-all', barCls)} style={{ width: `${globalScore ?? 0}%` }} />
</div>
<span className={clsx('text-xs font-semibold px-2 py-1 rounded-lg border whitespace-nowrap', badgeCls)}>
{Math.round(score)}% {verdict}
{globalScore != null ? `${globalScore}% — ` : ''}{verdict}
</span>
<span className="text-[10px] text-slate-600 whitespace-nowrap">
{scored.length}/{totalLinked.length} graphes
</span>
{periodTmpl.length > 0 && (
<span className="text-[10px] text-slate-600 whitespace-nowrap">
{touchedActive.length}/{periodTmpl.length} graphes
</span>
)}
</div>
)
}
@@ -1430,7 +1496,6 @@ export default function InstrumentDashboard() {
<ExplanationScore
events={snapshot.events}
templates={templates}
selectedDate={effectiveDate}
causalInsts={causalInsts}
/>
</div>