from __future__ import annotations
import html
from underdog_lab.domain import Forecast, MatchRecord
from underdog_lab.forecasting.scoring import brier_score, log_loss
from underdog_lab.scenarios.schemas import AdjustmentResult, ScenarioExtraction
from underdog_lab.world_cup.flags import team_label
def _format_cutoff(cutoff_iso: str) -> str:
if not cutoff_iso:
return ""
return cutoff_iso[:16].replace("T", " ") + " UTC"
def hero_html(extractor_name: str, cutoff: str = "", overdue_note: str = "") -> str:
meta_bits = []
if cutoff:
meta_bits.append(f"Forecasts last updated {_format_cutoff(cutoff)}")
if overdue_note and overdue_note != "All recorded results are up to date.":
meta_bits.append(overdue_note)
meta_html = (
f'
{html.escape(" · ".join(meta_bits))}
'
if meta_bits
else ""
)
return f"""
FIFA World Cup 2026 · Forecast Dashboard
World Cup 2026 Forecaster
Every group match, a full tournament simulation, and a sandbox where
you can drop in a headline like "star striker is out" and watch the
odds move.
Built on Elo ratings and a statistical model, checked
against a decade of real results. This is a hobby project, not betting
advice or an official FIFA product. The Methodology
tab has the full breakdown if you're curious.
{meta_html}
Coverage 48 teams · 72 group matches
Model Elo + Dixon-Coles statistics
Scenario reader {html.escape(extractor_name)}, runs locally
"""
def match_html(match: MatchRecord) -> str:
venue = "Neutral venue" if match.neutral_venue else "Recorded venue advantage"
return f"""
{html.escape(match.competition)} / {html.escape(match.stage)} / {match.kickoff_date.year}
{team_label(match.home_team)}
VS
{team_label(match.away_team)}
{html.escape(match.context)}
{html.escape(match.venue)} / {venue}
"""
def forecast_html(
forecast: Forecast,
match: MatchRecord,
title: str,
*,
comparison: Forecast | None = None,
) -> str:
values = (
("home", match.home_team, forecast.p_home, "home-fill"),
("draw", "Draw", forecast.p_draw, "draw-fill"),
("away", match.away_team, forecast.p_away, "away-fill"),
)
rows = []
for outcome, label, probability, css_class in values:
delta = ""
if comparison is not None:
before = getattr(comparison, f"p_{outcome}")
change = probability - before
delta = f" ({change:+.1%})"
label_html = team_label(label) if outcome != "draw" else html.escape(label)
rows.append(
f"""
{label_html}
{probability:.0%}{delta}
"""
)
return f"""
{html.escape(title)}
{''.join(rows)}
Expected goals: {match.home_team} {forecast.lambda_home:.2f},
{match.away_team} {forecast.lambda_away:.2f}. Most likely score:
{forecast.most_likely_score}.
"""
def factors_html(
extraction: ScenarioExtraction,
result: AdjustmentResult,
*,
backend: str | None = None,
backend_error: str | None = None,
) -> str:
backend_block = ""
if backend:
degraded = "fallback" in backend.lower()
css_class = "factor-chip dropped" if degraded else "factor-chip"
backend_block = f"""
Extracted by {html.escape(backend)}
{
"The local model was unavailable; deterministic rules handled this request."
if degraded
else "Local grammar-constrained model inference."
}
"""
if degraded and backend_error:
backend_block += (
'Runtime fallback reason: '
+ html.escape(backend_error)
+ "
"
)
if not extraction.factors and not extraction.unsupported_claims:
return f"""
Scenario evidence
{backend_block}
No supported factor was detected.
"""
chips = []
for applied in result.adjustments:
factor = applied.factor
state = "" if applied.applied else " dropped"
status = "Applied" if applied.applied else "Not applied"
chips.append(
f"""
{html.escape(factor.factor_type.value.replace("_", " ").title())} / {factor.team}
Severity {factor.severity:.0%}, certainty {factor.certainty:.0%}.
{status}: {html.escape(applied.explanation)}
"""
)
unsupported = ""
if extraction.unsupported_claims:
unsupported = (
'Unsupported: '
+ html.escape("; ".join(extraction.unsupported_claims))
+ "
"
)
ambiguities = ""
if extraction.ambiguities:
ambiguities = (
'Ambiguous: '
+ html.escape("; ".join(extraction.ambiguities))
+ "
"
)
return f"""
Scenario evidence
{backend_block}{''.join(chips)}
{unsupported}{ambiguities}
"""
def reveal_html(
match: MatchRecord,
baseline: Forecast,
adjusted: Forecast,
user_home: float,
user_draw: float,
) -> str:
user_away = 100.0 - user_home - user_draw
if user_away < 0:
return """
Home and draw probabilities exceed 100%.
Reduce one slider before committing.
"""
from underdog_lab.domain import UserForecast
user = UserForecast(
p_home=user_home / 100.0,
p_draw=user_draw / 100.0,
p_away=user_away / 100.0,
)
observed = match.observed_outcome
scores = (
("Baseline", log_loss(baseline, observed), brier_score(baseline, observed)),
("Scenario", log_loss(adjusted, observed), brier_score(adjusted, observed)),
("You", log_loss(user, observed), brier_score(user, observed)),
)
boxes = "".join(
f"""
{name}
{loss:.3f}
log loss / Brier {brier:.3f}
"""
for name, loss, brier in scores
)
return f"""
Result revealed
{team_label(match.home_team)} {match.home_goals}-{match.away_goals} {team_label(match.away_team)}
{html.escape(match.reveal_notes or "")}
{boxes}
Lower scores are better. A surprising outcome does not
by itself invalidate a calibrated forecast.
"""
def derived_away_html(home: float, draw: float) -> str:
away = 100.0 - home - draw
tone = "warning" if away < 0 else "context"
value = max(0.0, away)
message = (
"Reduce home or draw; the total is above 100%."
if away < 0
else "The third probability is derived so the forecast totals 100%."
)
return f"""
Your away probability
{value:.0f}%
{message}
"""