feat: instrument analysis
This commit is contained in:
@@ -543,9 +543,11 @@ def _get_relevant_events(
|
|||||||
SELECT e.*,
|
SELECT e.*,
|
||||||
(SELECT GROUP_CONCAT(DISTINCT a.instrument)
|
(SELECT GROUP_CONCAT(DISTINCT a.instrument)
|
||||||
FROM causal_event_analyses a
|
FROM causal_event_analyses a
|
||||||
WHERE a.market_event_id = e.id) AS analyzed_instruments,
|
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.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.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
|
FROM market_events e
|
||||||
ORDER BY e.start_date DESC
|
ORDER BY e.start_date DESC
|
||||||
""").fetchall()
|
""").fetchall()
|
||||||
@@ -553,7 +555,9 @@ def _get_relevant_events(
|
|||||||
# Use cea_template_id (from causal_event_analyses) explicitly to avoid
|
# Use cea_template_id (from causal_event_analyses) explicitly to avoid
|
||||||
# any collision with an e.* column named template_id
|
# any collision with an e.* column named template_id
|
||||||
all_events = [
|
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
|
for r in rows
|
||||||
]
|
]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ const NO_CHART_EVENTS: never[] = []
|
|||||||
interface CausalTemplate {
|
interface CausalTemplate {
|
||||||
id: number; name: string; category: string; sub_type: string
|
id: number; name: string; category: string; sub_type: string
|
||||||
instruments: string[]; description: 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 {
|
interface InstrumentConfig {
|
||||||
@@ -39,6 +42,8 @@ interface SnapshotEvent {
|
|||||||
category: string; sub_type?: string; description: string; impact_score: number
|
category: string; sub_type?: string; description: string; impact_score: number
|
||||||
expected_value?: string | null; actual_value?: string | null
|
expected_value?: string | null; actual_value?: string | null
|
||||||
surprise_pct?: number | null; unit?: string | null; absorption_pct?: number | 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 {
|
interface RegimeSignals {
|
||||||
@@ -690,6 +695,38 @@ function makeTdToX(priceData: PriceCandle[], width: number): (d: string) => numb
|
|||||||
return (d: string) => (snap(d) / (N - 1)) * width
|
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 ───────────────────────────────────────────────────────────────
|
// ── CausalFrise ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const FRISE_LANE_H = 24 // px par lane
|
const FRISE_LANE_H = 24 // px par lane
|
||||||
@@ -784,8 +821,9 @@ function CausalFrise({
|
|||||||
.map(ev => {
|
.map(ev => {
|
||||||
const tmpl = templates.find(t => t.id === ev.template_id)
|
const tmpl = templates.find(t => t.id === ev.template_id)
|
||||||
if (!tmpl) return null
|
if (!tmpl) return null
|
||||||
|
const absorptionDays = templateAbsorptionDays(tmpl)
|
||||||
const endDate = ev.end_date ?? (() => {
|
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)
|
return d.toISOString().slice(0, 10)
|
||||||
})()
|
})()
|
||||||
const x1 = tdToX(ev.date)
|
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="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">
|
<span className="flex items-center gap-1">
|
||||||
<Clock className="w-2.5 h-2.5" />
|
<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>
|
</span>
|
||||||
{ev.impact_score != null && (
|
{ev.impact_score != null && (
|
||||||
<span className="text-amber-600">★ {ev.impact_score.toFixed(1)}</span>
|
<span className="text-amber-600">★ {ev.impact_score.toFixed(1)}</span>
|
||||||
@@ -927,6 +965,26 @@ function CausalFrise({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="flex gap-1.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => { if (ev.id) navigate(`/market-events?event=${ev.id}`); setActiveChip(null) }}
|
onClick={() => { if (ev.id) navigate(`/market-events?event=${ev.id}`); setActiveChip(null) }}
|
||||||
@@ -956,47 +1014,55 @@ function CausalFrise({
|
|||||||
// ── ExplanationScore ──────────────────────────────────────────────────────────
|
// ── ExplanationScore ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ExplanationScore({
|
function ExplanationScore({
|
||||||
events, templates, selectedDate, causalInsts,
|
events, templates, causalInsts,
|
||||||
}: {
|
}: {
|
||||||
events: SnapshotEvent[]
|
events: SnapshotEvent[]
|
||||||
templates: CausalTemplate[]
|
templates: CausalTemplate[]
|
||||||
selectedDate: string | null
|
|
||||||
causalInsts: string[]
|
causalInsts: string[]
|
||||||
}) {
|
}) {
|
||||||
// Templates from events that have a causal graph touching this instrument
|
// For each linked event, compute the comprehension score and collect scored ones
|
||||||
const linkedEvents = events.filter(ev => {
|
const scored: number[] = []
|
||||||
if (!ev.template_id) return false
|
const totalLinked: number[] = []
|
||||||
|
|
||||||
|
for (const ev of events) {
|
||||||
|
if (!ev.template_id) continue
|
||||||
const tmpl = templates.find(t => t.id === ev.template_id)
|
const tmpl = templates.find(t => t.id === ev.template_id)
|
||||||
return tmpl != null && causalInsts.some(ci => tmpl.instruments.includes(ci))
|
if (!tmpl) continue
|
||||||
})
|
const inst = causalInsts.find(ci => tmpl.instruments.includes(ci))
|
||||||
const linkedTemplateIds = new Set(linkedEvents.map(ev => ev.template_id!))
|
if (!inst) continue
|
||||||
const periodTmpl = templates.filter(t => linkedTemplateIds.has(t.id))
|
totalLinked.push(ev.id ?? 0)
|
||||||
const touchedActive = periodTmpl.filter(t =>
|
const s = comprehensionScore(ev, tmpl, inst)
|
||||||
linkedEvents.filter(ev => ev.template_id === t.id).some(ev => isActiveAt(ev, selectedDate))
|
if (s != null) scored.push(s)
|
||||||
)
|
}
|
||||||
const score = periodTmpl.length > 0 ? (touchedActive.length / periodTmpl.length) * 100 : 0
|
|
||||||
const verdict = score >= 60 ? 'Bien expliqué' : score >= 30 ? 'Partiellement' : 'Peu lisible'
|
const globalScore = scored.length > 0
|
||||||
const badgeCls = score >= 60
|
? 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'
|
? '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-amber-400 bg-amber-900/30 border-amber-700/40'
|
||||||
: 'text-slate-400 bg-slate-800/60 border-slate-700/30'
|
: 'text-red-400 bg-red-900/20 border-red-700/30'
|
||||||
const barCls = score >= 60 ? 'bg-emerald-500' : score >= 30 ? 'bg-amber-500' : 'bg-slate-600'
|
const barCls = globalScore == null ? 'bg-slate-600'
|
||||||
|
: globalScore >= 70 ? 'bg-emerald-500' : globalScore >= 40 ? 'bg-amber-500' : 'bg-red-500'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 px-4 py-2.5 rounded-xl border border-slate-700/30 bg-dark-800/50">
|
<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>
|
<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="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>
|
</div>
|
||||||
<span className={clsx('text-xs font-semibold px-2 py-1 rounded-lg border whitespace-nowrap', badgeCls)}>
|
<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>
|
</span>
|
||||||
{periodTmpl.length > 0 && (
|
|
||||||
<span className="text-[10px] text-slate-600 whitespace-nowrap">
|
|
||||||
{touchedActive.length}/{periodTmpl.length} graphes
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1430,7 +1496,6 @@ export default function InstrumentDashboard() {
|
|||||||
<ExplanationScore
|
<ExplanationScore
|
||||||
events={snapshot.events}
|
events={snapshot.events}
|
||||||
templates={templates}
|
templates={templates}
|
||||||
selectedDate={effectiveDate}
|
|
||||||
causalInsts={causalInsts}
|
causalInsts={causalInsts}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user