World Cup 2026 — Team Strength
Football > World Cup 2026 > Team Strength
Football · World Cup 2026 · Team Ratings
How strong is each squad?
One headline number per team: Tiento — goals better (or worse) than the average World Cup side on a neutral pitch, blended from our four independent rating systems and named for the ball that played the first half of the first-ever World Cup final. The full seven-system table sits underneath. Panna / offense / defense aggregate squad player ratings; EPR converts to per-90 expected possession value; PSR captures box-score skills; Elo is the classic team rating; BT compresses the match model’s predictions for all 1,128 possible pairings into a single Bradley-Terry strength per team (host advantage for the USA, Canada and Mexico is baked in — it reads as expected strength at this tournament).
Show code
statsEsc = window.statsEsc
// The strength ratings come from the parquet, but the Champ % column is
// overlaid from the LIVE in-browser sim (wc-live-sim.js) so it tracks real +
// in-progress results, re-run every minute while games are live.
_wcLiveTeams = window.wcLiveSim.simStream()
_wcStrengthRaw = {
try { return await window.fetchParquet(window.DATA_BASE_URL + "football/wc2026_team_strength.parquet") }
catch (e) { console.error("[wc2026] team_strength load failed:", e); return null }
}
_wcStrength = {
if (_wcStrengthRaw == null) return null
const champ = new Map((_wcLiveTeams || []).map(t => [t.team, t.p_champ]))
return _wcStrengthRaw.map(r => champ.has(r.team) ? { ...r, p_champ: champ.get(r.team) } : r)
}
// Fixture predictions feed the goals-scale calibration of the aggregate rating
_wcStrengthPreds = {
try { return await window.fetchParquet(window.DATA_BASE_URL + "football/wc2026_predictions.parquet") }
catch (e) { console.warn("[wc2026] predictions load failed (rating uses fallback scale):", e); return null }
}
// Aggregate rating: z-blend of panna/EPR/PSR/Elo on a goals-above-average
// scale (see wcMaps.computeTeamRating for weights + why BT is excluded)
wcRating = {
if (_wcStrength == null) return null
return window.wcMaps.computeTeamRating(_wcStrength, _wcStrengthPreds)
}Show code
viewof wcStrengthCategory = {
const cats = [
["rating", window.wcMaps.RATING_NAME],
["panna", "Panna"], ["offense", "Offense"], ["defense", "Defense"],
["epr", "EPR"], ["psr", "PSR"], ["elo", "Elo"], ["bt", "BT"], ["p_champ", "Champ %"]
]
const wrap = document.createElement("div")
wrap.className = "epv-toggle"
wrap.style.cssText = "margin:0.5rem 0"
wrap.value = "rating"
for (const [val, label] of cats) {
const btn = document.createElement("button")
btn.className = "epv-toggle-btn" + (val === "rating" ? " active" : "")
btn.textContent = label
btn.addEventListener("click", () => {
wrap.querySelectorAll(".epv-toggle-btn").forEach(b => b.classList.remove("active"))
btn.classList.add("active")
wrap.value = val
wrap.dispatchEvent(new Event("input", { bubbles: true }))
})
wrap.appendChild(btn)
}
return wrap
}
{
if (_wcStrength == null) return html`<p class="text-muted">Data failed to load — try refreshing (see console for details).</p>`
if (_wcStrength.length === 0) return html`<p class="text-muted">No strength data available.</p>`
const sortCol = wcStrengthCategory
const fmt3 = x => x?.toFixed(3) ?? ""
const fmtGoals = x => x == null ? "" : (x >= 0 ? "+" : "−") + Math.abs(x).toFixed(2)
const rows = _wcStrength.map(r => ({
...r,
rating: wcRating ? wcRating.ratings.get(r.team) ?? null : null
}))
return window.statsTable(rows, {
columns: ["team", "group", "rating", "panna", "offense", "defense", "epr", "psr", "elo", "bt", "p_champ"],
header: {
team: "Team", group: "Grp", rating: window.wcMaps.RATING_NAME,
panna: "Panna", offense: "Off", defense: "Def",
epr: "EPR", psr: "PSR", elo: "Elo", bt: "BT", p_champ: "Champ %"
},
groups: [
{ label: "", span: 2 },
{ label: "Overall", span: 1 },
{ label: "Player ratings", span: 5 },
{ label: "Team ratings", span: 2 },
{ label: "", span: 1 }
],
format: {
rating: fmtGoals,
panna: fmt3, offense: fmt3, defense: fmt3,
epr: fmt3, psr: fmt3, elo: x => x?.toFixed(0) ?? "", bt: fmt3,
p_champ: x => x != null ? x.toFixed(1) + "%" : ""
},
render: {
team: (v, r) => window.wcMaps.teamLinkHtml(r.team, { flag: false, cls: "player-link" })
},
heatmap: {
// `rating` is goals above/below the average WC team — diverging, centred 0.
// `defense` in wc2026_team_strength.parquet is PRE-SIGN-FLIPPED at the
// pannadata layer ("positive = good" per WC2026_BLOG_IMPLEMENTATION.md).
// Diverges from football/stat-defs.js + team-ratings.qmd which use
// "low-good" because their defense column is raw xG-conceded. Don't
// align these by reflex — the underlying columns aren't comparable.
rating: "diverging",
panna: "high-good", offense: "high-good", defense: "high-good",
epr: "high-good", psr: "high-good", elo: "high-good", bt: "high-good",
p_champ: "high-good"
},
sort: sortCol,
reverse: true,
rows: 48
})
}Show code
Show code
{
const inner = document.createElement("div")
inner.className = "side-rail-inner"
const { railBlock, btnTile, tableSource } = window.editorial
const asAt = await wcStrengthAsAt
if (_wcStrength && _wcStrength.length > 0) {
// Lead tiles with self-explanatory numbers (agreement count, Elo, champ %)
// — raw panna / BT decimals mean nothing without the table for scale.
const systems = ["panna", "offense", "defense", "epr", "psr", "elo", "bt"]
const counts = new Map()
for (const k of systems) {
const top = [..._wcStrength].sort((a, b) => (b[k] ?? -Infinity) - (a[k] ?? -Infinity))[0]
if (top) counts.set(top.team, (counts.get(top.team) || 0) + 1)
}
const [topTeam, nAgree] = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]
const byElo = [..._wcStrength].sort((a, b) => b.elo - a.elo)[0]
const byChamp = [..._wcStrength].sort((a, b) => (b.p_champ ?? 0) - (a.p_champ ?? 0))[0]
const btn = railBlock("By the numbers")
btn.appendChild(btnTile(`${nAgree} of 7`, [
{ text: "systems rank " }, { text: topTeam, bold: true }, { text: " the strongest squad" }
]))
btn.appendChild(btnTile(byElo.elo.toFixed(0), [
{ text: "Highest Elo · " }, { text: byElo.team, bold: true }
]))
if (byChamp?.p_champ != null) {
btn.appendChild(btnTile(`${byChamp.p_champ.toFixed(0)}%`, [
{ text: "Best title odds · " }, { text: byChamp.team, bold: true }
]))
}
btn.appendChild(btnTile("48", [{ text: "Teams · 7 rating systems" }]))
inner.appendChild(btn)
}
const links = railBlock("Read next")
const l1 = document.createElement("div"); l1.innerHTML = `<a href="world-cup-title-race.html"><strong>Title Race</strong></a><br><span class="text-muted" style="font-size:0.78rem">Champion probabilities</span>`
links.appendChild(l1)
const l2 = document.createElement("div"); l2.style.marginTop = "0.7rem"
l2.innerHTML = `<a href="world-cup-groups.html"><strong>Group Projections</strong></a><br><span class="text-muted" style="font-size:0.78rem">12 groups · advance %</span>`
links.appendChild(l2)
inner.appendChild(links)
inner.appendChild(tableSource({
source: "pannadata",
sourceUrl: "https://github.com/peteowen1/pannadata",
sourceNote: "Opta scrape",
license: "CC BY 4.0",
asAt: asAt || "Refreshed weekly",
hint: "7 rating systems"
}))
return inner
}