From c93eba7a7f3ccda7e9b90d89f6bc70cb48f4aa8a Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Mon, 29 Jun 2026 22:54:57 +0200 Subject: [PATCH] fix: compute surprise_pct from actual/expected when NULL for auto-analysis scoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Events with surprise_pct=NULL (bootstrap or older events) got inputs={} → evaluate_graph returned {} → prediction_json='{}' → all scores showed 'En attente'. Three-step fallback in _run_auto_analysis: 1. Use event.surprise_pct if set (existing behaviour) 2. Compute from actual_value / expected_value stored in market_events 3. Look up most recent ff_calendar release by currency + date Frontend diagnostic now shows surprise_pct per event and exposes actual/expected in the refresh result panel to make the source visible. Co-Authored-By: Claude Sonnet 4.6 --- backend/routers/causal_lab.py | 78 +++++++++++++++------- frontend/src/pages/InstrumentDashboard.tsx | 18 +++-- 2 files changed, 68 insertions(+), 28 deletions(-) diff --git a/backend/routers/causal_lab.py b/backend/routers/causal_lab.py index 881568b..234304a 100644 --- a/backend/routers/causal_lab.py +++ b/backend/routers/causal_lab.py @@ -1182,27 +1182,56 @@ def _run_auto_analysis(event: dict, template_id: int) -> bool: except (ValueError, TypeError): pass - elif src == "surprise_bps" and event.get("surprise_pct") is not None: - raw = float(event["surprise_pct"]) - node_range = cfg.get("range") - if node_range and len(node_range) == 2: - lo, hi = float(node_range[0]), float(node_range[1]) - inputs[input_id] = round(max(lo, min(hi, raw)), 4) - else: - inputs[input_id] = round(raw, 4) - - elif src == "surprise" and event.get("surprise_pct") is not None: - raw = float(event["surprise_pct"]) - node_range = cfg.get("range") - if node_range and len(node_range) == 2: - lo, hi = float(node_range[0]), float(node_range[1]) - bound = max(abs(lo), abs(hi)) - if bound <= 10: - inputs[input_id] = round(max(lo, min(hi, raw / 100 * bound)), 4) + elif src in ("surprise_bps", "surprise"): + # Step 1: use stored surprise_pct + sp = event.get("surprise_pct") + # Step 2: compute from actual_value / expected_value + if sp is None: + try: + act_s = str(event.get("actual_value") or "").replace(",", ".").strip() + exp_s = str(event.get("expected_value") or "").replace(",", ".").strip() + if act_s and exp_s: + act_f = float(act_s) + exp_f = float(exp_s) + sp = (act_f - exp_f) / abs(exp_f) * 100 if abs(exp_f) > 1e-9 else (act_f - exp_f) + except (ValueError, TypeError): + pass + # Step 3: look up ff_calendar by currency + date + if sp is None: + try: + currency = (event.get("sub_type") or "").upper() + if currency: + row_ff = conn.execute(""" + SELECT actual_value, forecast_value FROM ff_calendar + WHERE currency = ? AND event_date <= ? AND actual_value IS NOT NULL + AND forecast_value IS NOT NULL + ORDER BY event_date DESC LIMIT 1 + """, (currency, edate_str)).fetchone() + if row_ff: + a_f = float(row_ff["actual_value"]) + e_f = float(row_ff["forecast_value"]) + sp = (a_f - e_f) / abs(e_f) * 100 if abs(e_f) > 1e-9 else (a_f - e_f) + except (ValueError, TypeError): + pass + if sp is not None: + raw = float(sp) + node_range = cfg.get("range") + if src == "surprise_bps": + if node_range and len(node_range) == 2: + lo, hi = float(node_range[0]), float(node_range[1]) + inputs[input_id] = round(max(lo, min(hi, raw)), 4) + else: + inputs[input_id] = round(raw, 4) else: - inputs[input_id] = round(max(lo, min(hi, raw)), 4) - else: - inputs[input_id] = round(raw / 100, 4) + if node_range and len(node_range) == 2: + lo, hi = float(node_range[0]), float(node_range[1]) + bound = max(abs(lo), abs(hi)) + if bound <= 10: + inputs[input_id] = round(max(lo, min(hi, raw / 100 * bound)), 4) + else: + inputs[input_id] = round(max(lo, min(hi, raw)), 4) + else: + inputs[input_id] = round(raw / 100, 4) elif src == "impact_score_scaled" and event.get("impact_score") is not None: inputs[input_id] = float(event["impact_score"]) / 10.0 @@ -1240,7 +1269,8 @@ def _run_auto_analysis(event: dict, template_id: int) -> bool: logger.info( f"[auto_analysis] event #{event_id} → tmpl #{template_id} | " - f"inputs={inputs} | instruments={all_instruments_csv} | actual_moves={actual_moves}" + f"surprise_pct={event.get('surprise_pct')} inputs={inputs} | " + f"nodes={len(node_values)} | instruments={all_instruments_csv} | actual_moves={actual_moves}" ) existing = conn.execute( @@ -1443,8 +1473,10 @@ def refresh_auto_analyses(): details.append(detail) continue ev = dict(ev_row) - detail["surprise_pct"] = ev.get("surprise_pct") - detail["start_date"] = ev.get("start_date") + detail["surprise_pct"] = ev.get("surprise_pct") + detail["actual_value"] = ev.get("actual_value") + detail["expected_value"] = ev.get("expected_value") + detail["start_date"] = ev.get("start_date") ok = _run_auto_analysis(ev, row["template_id"]) detail["result"] = "ok" if ok else "failed" if ok: diff --git a/frontend/src/pages/InstrumentDashboard.tsx b/frontend/src/pages/InstrumentDashboard.tsx index 0dce606..9416a15 100644 --- a/frontend/src/pages/InstrumentDashboard.tsx +++ b/frontend/src/pages/InstrumentDashboard.tsx @@ -1028,7 +1028,7 @@ function ExplanationScore({ // For each linked event, compute the comprehension score and collect scored ones const scored: number[] = [] const totalLinked: number[] = [] - const debugRows: {id?: number; name: string; pred: string; actual: string; score: number | null; reason: string}[] = [] + const debugRows: {id?: number; name: string; pred: string; actual: string; score: number | null; reason: string; surprisePct?: number | null}[] = [] for (const ev of events) { if (!ev.template_id) continue @@ -1062,6 +1062,7 @@ function ExplanationScore({ pred: hasPred ? '✓' : '✗', actual: hasActual ? '✓' : '✗', score: null, reason, + surprisePct: ev.surprise_pct ?? null, }) const s = comprehensionScore(ev, tmpl, inst) @@ -1136,9 +1137,12 @@ function ExplanationScore({
{r.pred} {r.actual} - {r.name} + {r.name} {r.reason} - {r.score != null && {r.score}%} + {r.surprisePct != null + ? {r.surprisePct > 0 ? '+' : ''}{r.surprisePct.toFixed(1)}% + : surp?} + {r.score != null && {r.score}%}
))} {debugInfo && ( @@ -1149,8 +1153,12 @@ function ExplanationScore({ } {debugInfo.details?.map((d: any, i: number) => (
- #{d.event_id} {(d.event_name ?? '').slice(0, 25)} → {d.result} - {d.surprise_pct != null && surp:{d.surprise_pct}} + #{d.event_id} {(d.event_name ?? '').slice(0, 22)} → {d.result} + {d.surprise_pct != null + ? surp:{Number(d.surprise_pct).toFixed(1)}% + : d.actual_value != null + ? act:{d.actual_value} exp:{d.expected_value ?? '?'} + : surp=null}
))}