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:
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user