fix: compute surprise_pct from actual/expected when NULL for auto-analysis scoring

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 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-29 22:54:57 +02:00
parent 959c2825df
commit c93eba7a7f
2 changed files with 68 additions and 28 deletions

View File

@@ -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:

View File

@@ -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({
<div key={i} className="flex gap-2 py-0.5 border-b border-slate-800/40 last:border-0">
<span className={clsx('shrink-0 w-4', r.pred === '✓' ? 'text-emerald-600' : 'text-red-600')}>{r.pred}</span>
<span className={clsx('shrink-0 w-4', r.actual === '✓' ? 'text-emerald-600' : 'text-red-600')}>{r.actual}</span>
<span className="text-slate-400 w-36 shrink-0 truncate" title={r.name}>{r.name}</span>
<span className="text-slate-400 w-32 shrink-0 truncate" title={r.name}>{r.name}</span>
<span className={clsx('flex-1', r.reason === 'ok' ? 'text-emerald-600' : 'text-amber-600')}>{r.reason}</span>
{r.score != null && <span className="text-violet-400">{r.score}%</span>}
{r.surprisePct != null
? <span className="text-cyan-600 shrink-0">{r.surprisePct > 0 ? '+' : ''}{r.surprisePct.toFixed(1)}%</span>
: <span className="text-red-800 shrink-0">surp?</span>}
{r.score != null && <span className="text-violet-400 shrink-0">{r.score}%</span>}
</div>
))}
{debugInfo && (
@@ -1149,8 +1153,12 @@ function ExplanationScore({
}
{debugInfo.details?.map((d: any, i: number) => (
<div key={i} className="mt-0.5">
#{d.event_id} {(d.event_name ?? '').slice(0, 25)} <span className={d.result === 'ok' ? 'text-emerald-600' : 'text-red-500'}>{d.result}</span>
{d.surprise_pct != null && <span className="text-slate-500 ml-1">surp:{d.surprise_pct}</span>}
#{d.event_id} {(d.event_name ?? '').slice(0, 22)} <span className={d.result === 'ok' ? 'text-emerald-600' : 'text-red-500'}>{d.result}</span>
{d.surprise_pct != null
? <span className="text-cyan-700 ml-1">surp:{Number(d.surprise_pct).toFixed(1)}%</span>
: d.actual_value != null
? <span className="text-amber-700 ml-1">act:{d.actual_value} exp:{d.expected_value ?? '?'}</span>
: <span className="text-red-800 ml-1">surp=null</span>}
</div>
))}
</div>