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}

"""