Using the chess rating system to predict the World Cup winners.

Python
Backtesting
Code
A walk-forward test of the chess rating system on four World Cups.
Published

June 30, 2026

Elo rates chess players. The same idea works for football, because the outcomes are the same: win, draw, loss. Each team gets a number, and the gap between two of them is the prediction.

So how well does it call the winners? We test it on four World Cups, 2014 through 2026.

The chess method on football

Every team starts on 1500, and each result moves it. Two formulas.

First, the gap between two ratings gives the favorite’s expected score. A win counts 1, a draw 0.5, a loss 0, and the expected score is the average the favorite should take over many games (nothing to do with goals). Spain on 2000 against Costa Rica on 1600 is a 400-point gap, so Spain’s expected score is 0.91, almost a full win. 0.5 would be a coin flip. It comes from the gap, so it already adjusts for who you played. A plain win rate would not.

expected = 1 / (1 + 10^(-gap / 400))

The 400 just sets the scale. The cell below shows the curve.

Second, the ratings update:

new = old + K * (actual - expected)

actual is what really happened, on that same 1, 0.5, 0 scale. Spain’s and Costa Rica’s expected scores add to 1 (0.91 and 0.09), so one team gains what the other loses.

K is how much a single game can move a rating. A result in line with the prediction moves it little: Spain win as expected and gain about 5 points. The further the result is from the prediction, the larger the move: if Spain lose, they fall about 50. Set K too low and ratings adjust too slowly; too high and a single result moves them too far. The World Football Elo Ratings weight matches from 20 to 60, and we follow that: 55 for a World Cup, 20 for a friendly.

# A feel for the curve: the favorite's expected score at a few rating gaps.
for gap in (0, 50, 100, 200, 400):
    exp = 1 / (1 + 10 ** (-gap / 400))
    print(f"rating gap {gap:>4}  ->  favorite expected to score {exp:.2f}")
rating gap    0  ->  favorite expected to score 0.50
rating gap   50  ->  favorite expected to score 0.57
rating gap  100  ->  favorite expected to score 0.64
rating gap  200  ->  favorite expected to score 0.76
rating gap  400  ->  favorite expected to score 0.91

A 100-point lead is worth only 0.64. Clear favorites lose often.

Load the data

Every men’s international with a final score, in date order, from martj42. The neutral flag tells us when home advantage applies.

A quant would usually process this data and check how accurate it is. That is not what this post is about, so we take it as is.

import pandas as pd, requests, io
from collections import defaultdict

url = "https://raw.githubusercontent.com/martj42/international_results/master/results.csv"
games = pd.read_csv(io.StringIO(requests.get(url, timeout=120).text))      # the full archive
games = games.dropna(subset=["home_score", "away_score"]).sort_values("date")
print("Matches:", f"{len(games):,}", " | through:", games["date"].max())
games.tail(3)[["date", "home_team", "away_team", "home_score", "away_score", "tournament"]]
Matches: 49,519  | through: 2026-07-18
date home_team away_team home_score away_score tournament
49516 2026-07-14 France Spain 0.0 2.0 FIFA World Cup
49517 2026-07-15 England Argentina 1.0 2.0 FIFA World Cup
49518 2026-07-18 France England 4.0 6.0 FIFA World Cup

Build the model

One pass through the archive, oldest first. We save each World Cup gap before the result updates the ratings, so no prediction sees its own outcome.

R = defaultdict(lambda: 1500.0)                 # every team starts at 1500
HA = 65                                          # home advantage, in rating points
def weight(t):                                   # K: how much a result is allowed to move a rating
    t = t.lower()
    if "friendly" in t: return 20
    if "qualification" in t: return 40
    if "world cup" in t or "euro" in t: return 55
    return 50 if any(w in t for w in ["copa", "african", "asian cup", "gold cup", "nations league"]) else 30

wc = []                                          # save World Cup matches for the test below
for m in games.to_dict("records"):
    h, a = m["home_team"], m["away_team"]
    neutral = str(m["neutral"]).upper() == "TRUE"
    gap = R[h] - R[a] + (0 if neutral else HA)   # rating gap before the match (home edge unless neutral)
    exp = 1 / (1 + 10 ** (-gap / 400))           # expected score for the home team (the chess formula)
    hs, aw = int(m["home_score"]), int(m["away_score"])
    score = 1.0 if hs > aw else (0.5 if hs == aw else 0.0)
    if m["tournament"] == "FIFA World Cup" and m["date"][:4] in ("2014", "2018", "2022", "2026"):
        wc.append((m["date"][:4], gap, score))   # store the gap BEFORE it is updated -> walk-forward
    margin = abs(hs - aw)
    g = 1 if margin <= 1 else (1.5 if margin == 2 else (11 + margin) / 8)   # goal margin, scaled sub-linearly
    move = weight(m["tournament"]) * g * (score - exp)
    R[h] += move; R[a] -= move

print(f"Trained on {len(games):,} matches and {len(R):,} teams. Top of the table:")
pd.Series(R).sort_values(ascending=False).head(8).round(0)
Trained on 49,519 matches and 337 teams. Top of the table:
Spain          2293.0
Argentina      2263.0
England        2188.0
France         2138.0
Colombia       2078.0
Brazil         2071.0
Portugal       2062.0
Netherlands    2043.0
dtype: float64

The top of the table is the teams you would expect: Spain, Argentina, France, England, Brazil. No tuning, it falls straight out of the data.

How we grade it

The favorite is the higher-rated team, and the win rate is the percentage of favorites that win. We also compare the favorite’s expected score, set before the game, with what it actually averaged. If the two match, the rating was right.

def grade(rows):                                 # rows = list of (gap, score), score from the home side
    favw = draws = ups = 0; exp_sum = act_sum = 0.0; n = len(rows)
    for gap, score in rows:
        exp = 1 / (1 + 10 ** (-gap / 400)); fav_home = exp >= 0.5
        exp_sum += exp if fav_home else 1 - exp                 # the favorite's expected score
        fa = score if fav_home else 1 - score; act_sum += fa    # the favorite's actual score
        if score == 0.5: draws += 1
        elif fa == 1.0:  favw += 1                              # favorite won
        else:            ups += 1                              # favorite lost
    return dict(n=n, favw=favw, draws=draws, upsets=ups, exp=exp_sum/n, act=act_sum/n)

The 2026 World Cup

We pick the higher-rated team in every group game so far.

g = grade([(gap, s) for yr, gap, s in wc if yr == "2026"])
dec = g["favw"] + g["upsets"]
print(f"Played group matches:   {g['n']}")
print(f"Straight winner pick:   {g['favw']}/{g['n']} = {g['favw']/g['n']:.0%}   (a draw counts as a miss)")
print(f"The {g['n']-g['favw']} misses:          {g['draws']} draws, {g['upsets']} lost by the favorite")
print(f"On decisive matches:    {g['favw']}/{dec} = {g['favw']/dec:.0%}")
print(f"Expected score {g['exp']:.2f}  vs  actual {g['act']:.2f}   (close means the model got it right)")
Played group matches:   103
Straight winner pick:   66/103 = 64%   (a draw counts as a miss)
The 37 misses:          24 draws, 13 lost by the favorite
On decisive matches:    66/79 = 84%
Expected score 0.72  vs  actual 0.76   (close means the model got it right)

The winner rate looks low only because draws count as misses. On games with a winner, the favorite usually won. And the expected score lands almost exactly on the actual.

Past World Cups

2026 is live, so the sample is small. Instead, we can view the three finished World Cups. The ratings are pre-match.

rows = []
for yr in ("2014", "2018", "2022", "2026"):
    g = grade([(gap, s) for y, gap, s in wc if y == yr]); dec = g["favw"] + g["upsets"]
    rows.append({"World Cup": yr + ("" if yr != "2026" else " (so far)"), "matches": g["n"],
                 "winner, decisive": f"{g['favw']}/{dec} = {g['favw']/dec:.0%}",
                 "expected score": round(g["exp"], 2), "actual score": round(g["act"], 2)})
pd.DataFrame(rows)
World Cup matches winner, decisive expected score actual score
0 2014 64 37/51 = 73% 0.67 0.68
1 2018 64 36/51 = 71% 0.68 0.66
2 2022 64 35/49 = 71% 0.69 0.66
3 2026 (so far) 103 66/79 = 84% 0.72 0.76

Same every time. Expected and actual are within a hundredth or two: 0.67 vs 0.68 in 2014, 0.68 vs 0.66 in 2018, 0.69 vs 0.66 in 2022. All on games the model never saw.

What it means

Naming the exact winner is only okay, because draws are common and this pick always names a team. But the strength behind it, Elo gets right.

One last check, at every level

We sort the games by the favorite’s expected score, then plot what each group actually averaged. One dot per group, with its game count. A 0.6 group should average 0.6, a 0.9 group 0.9. The dotted line is a perfect match.

%matplotlib inline
import numpy as np, matplotlib.pyplot as plt
fe = np.array([max(e, 1 - e) for e in (1 / (1 + 10 ** (-gap / 400)) for _, gap, _ in wc)])
fa = np.array([(s if (1 / (1 + 10 ** (-gap / 400))) >= 0.5 else 1 - s) for _, gap, s in wc])
edges = np.linspace(0.5, 1.0, 6); b = np.clip(np.digitize(fe, edges) - 1, 0, 4)
xs, ys, ns = [], [], []
for k in range(5):
    sel = b == k
    if sel.sum() >= 10: xs.append(fe[sel].mean()); ys.append(fa[sel].mean()); ns.append(int(sel.sum()))
plt.figure(figsize=(7, 7))
plt.plot([0.5, 1], [0.5, 1], "--", color="gray", label="perfect match")
plt.plot(xs, ys, "o-", color="#1F77B4", lw=2, label="Elo")
for x, y, nn in zip(xs, ys, ns):
    plt.annotate(f"{nn} games", (x, y), textcoords="offset points", xytext=(8, -4), fontsize=9, color="gray")
plt.xlabel("What the model expected"); plt.ylabel("What actually happened")
plt.title(f"Expected vs actual, four World Cups ({len(wc)} games)"); plt.legend(); plt.tight_layout()
plt.savefig("calibration.png", dpi=120, bbox_inches="tight"); plt.show()

Every dot lands near the line. 0.7 expected, 0.7 actual. 0.9 expected, 0.9 actual. What the model predicts is what happens, so the probabilities hold.

What you do with it

The point is not to perfectly predict who wins but rather to treat the probabilities as scores for “value investing”.

Elo gives a probability, and a bookmaker gives odds, which are a probability with a margin on top. Line the two up, and the difference is the value: if Elo says 60% while the market implies 45%, you are buying a bet that is too cheap, and the rating is what spots it.

The same idea could work for stocks too. Take an earnings announcement, where the reaction on the day often overshoots. But that I leave to the reader.

Disclaimer: This post is a teaching example. Not betting advice.