feat: instrument analysis
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user