← All methods

Team Elo

Summary

Elo is a rolling team strength rating updated after every match. Each team enters its first ever appearance in CPL at a baseline of 1500, gains rating for results better than expected, and loses rating for results worse than expected, where "expected" is set by the rating gap between the two sides plus a home-field bonus. The rating is zero-sum within a match (whatever one side gains, the other loses) so the league average drifts only when teams enter or leave the league.

The page at /analysis/elo shows current ratings, season trajectories, and per-match before/after snapshots. Backed by /api/elo and /api/elo/current. Math lives in backend/app/elo.py; the chronological walk that builds team_elo_history lives in backend/scripts/compute_elo.py and runs as part of the nightly sync.

Unlike most other surfaces on the site, Elo intentionally includes playoff matches. The page itself surfaces this in the header copy.

Data

  • Source table: matches, walked in (date, id) order. Every match with home_goals IS NOT NULL is replayed.
  • Output table: team_elo_history, two rows per match (one per side), each capturing elo_before, elo_after, opponent rating, goal difference, result, matchday, season, and is_playoff.
  • Time range: 2019 onward. All seven CPL seasons are included, including the 2020 Island Games (no special handling, see failure modes).
  • Sample size: 309+ matches across 7 seasons as of writing.
  • Refresh: full rebuild on every nightly sync. Elo is a sequential chain, so once any rule or input changes the entire history has to be replayed; there is no safe partial update.

Choices

Starting rating: 1500

Every team enters the system at 1500.0 (elo.INITIAL_RATING) the first time they appear in the schedule. 1500 is the conventional chess-Elo baseline and has no league-specific meaning. It is just an anchor: what matters is the spread between teams, not the absolute number.

Practical consequences in CPL specifically:

  • All eight founding clubs started at 1500 before their first match on 2019-04-27. The opening weekend's results were the first divergences.
  • Atlético Ottawa entered at 1500 for their 2020 debut, even though the rest of the league had already played a season. They were treated as an unknown quantity, same as any expansion side.
  • Vancouver FC entered at 1500 for their 2023 debut on the same basis.
  • Franchise rebrands keep their rating. York9 → York United, for example, is one continuous team_id in the schema and therefore one continuous Elo chain.

We considered seeding expansion teams below 1500 to reflect the common reality of expansion-year struggle, but rejected it: priors-against-expansion-teams are not strong enough in an 8-to-9-team league to justify hard-coding it, and the system corrects within a handful of matches anyway.

Logistic expected-score formula

Standard chess Elo:

expected_A = 1 / (1 + 10^((rating_B - rating_A - home_adv) / 400))

The 400-point scale is the chess convention (a 400-point advantage implies a 10:1 expected-score ratio). It was kept as-is rather than re-tuned for CPL because re-deriving the scale from a 309-match sample would overfit to the league's particular run of upsets.

Home advantage: +100

A flat +100 rating bonus (elo.HOME_ADVANTAGE) is added to the home side's effective rating in the expected-score calculation. This is the value used by the World Football Elo project for international football and sits in the middle of the range used by club-football implementations (clubelo.com uses ~70-90; some MLS-specific implementations use 110-120). 100 was picked because:

  • It produces a baseline home-win expectation of ~64% between evenly-rated teams, which matches the long-run 2019-2025 CPL home-win rate within a few percentage points.
  • It is round, traceable, and not the product of optimisation against a small sample.

Re-deriving from match data would have been possible but is not worth the added complexity at current sample sizes; the gain over a defensible round number would be inside the noise.

K-factor: 20 regular, 30 playoff

The K-factor controls how much one match can move a rating.

  • Regular season: K = 20. A common middle-ground choice for league football, slow enough that ratings don't whip around on a single result, fast enough that a team's rating can meaningfully move across a 28-match season.
  • Playoffs: K = 30. Playoff matches carry more weight because they have higher stakes, are more selective (only top half of the league plays them), and are the only matches that decide championships. The 1.5x bump is a conventional choice borrowed from the World Football Elo treatment of major-tournament knockout matches.

Alternatives considered: a single K across all matches, a continuous K that decays through the season, or per-team K based on rating volatility (à la chess Glicko). All were rejected as over-engineering at current sample sizes.

Goal-difference multiplier

multiplier = 1.0                if |gd| <= 1
           = 1 + ln(|gd|)       otherwise

A 2-0 result earns ~1.69x the rating change of a 1-0; a 4-0 earns ~2.39x. The logarithm caps blowout influence so a fluky 6-0 doesn't distort a team's rating beyond what the underlying performance suggests. This is the World Football Elo formulation, used because it is well-tested and provides intuitive behaviour at the margins.

Season carryover: 25% regression toward 1500

At the start of each new season a team plays in, their rating is pulled 25% of the way back toward 1500:

new_rating = old_rating + (1500 - old_rating) * 0.25

So a team finishing 2024 at 1700 enters 2025 at 1650; a team finishing at 1350 enters at 1387.5.

The reasoning:

  • CPL squads turn over meaningfully but not entirely between seasons. A full reset would discard real information; no regression at all would over-weight last year's roster.
  • 25% is the same value used by FiveThirtyEight's club soccer Elo, which they tuned against multi-decade European league data. Without enough CPL history to re-tune, borrowing a defensible default is preferable to picking a number out of the air.
  • The carryover applies the first time a team plays in a new season, not at any fixed calendar date. Off-season transfers are not modelled directly; the regression is the proxy.

The very first season a team plays in is exempt (there is nothing to carry over).

Playoff matches included

Elo walks every match including playoffs. This is a deliberate asymmetry with the rest of the site: form, splits, xPts, SQI, predictions, and standings all silently exclude playoffs because 2-3 post-season matches per team would distort season-rate metrics. Elo is a chain rather than a rate, so dropping playoffs would falsify the historical record; the 2020 final, the 2021 final, etc. all happened, and the rating going into the next season should reflect them. The Playoffs page (/analysis/playoffs) reports a playoff-only Elo delta on top of the same team_elo_history table to make the post-season swing visible separately.

Rating deviation (Glicko-1 style)

A bare "1623" implies more precision than 28 matches a season can deliver, so the ratings table also carries a rating deviation (RD), displayed as "1623 ± 42". The point ratings are byte-identical to before: RD is tracked alongside the Elo walk and never feeds back into the rating update.

The update is standard Glicko-1 with a one-match rating period. After each match,

g(RD_opp) = 1 / sqrt(1 + 3 q² RD_opp² / π²)      q = ln(10)/400
1/d²      = q² g(RD_opp)² E (1 − E)
RD'       = sqrt( 1 / (1/RD² + 1/d²) )

where E is the same home-advantage-inclusive expected score the Elo update uses. That choice is deliberate: RD measures how much the match told us under our actual prediction, so a fixture we already called at 85% carries less information than a coin flip. Playoff K does not apply to RD — K is a stakes choice, RD is information content, and a playoff match is not more informative about strength than a regular one.

Constants, all in app/elo.py next to the other tunables:

Constant Value Why
RD_INITIAL 350 Glicko-1's standard for an unknown competitor; expansion sides are exactly that.
RD_FLOOR 40 Nine teams meeting three times a season can never justify claiming better than ±40.
RD_SEASON_INFLATION 100 One offseason of squad turnover: a settled RD ~65 returns to sqrt(65² + 100²) ≈ 119, roughly half a season of information forgotten — consistent with the 25% rating regression.
RD_MAX 350 Inflation can never make a team more uncertain than a brand-new one.

Across each offseason RD inflates in quadrature (sqrt(RD² + 100²), capped at RD_MAX), in the same code branch that applies the rating carryover. Typical trajectory: a new franchise debuts at 350 and ends its first 28-match season around 75; offseason inflation lifts that to ~120; settled teams cycle between ~55-65 (end of season) and ~115-125 (start of season).

The ± is honest about what it is: uncertainty in the rating as an estimate of true strength under this model, not a betting interval on the next result.

Validation

  • Zero-sum-within-a-match: covered by test_match_update_is_zero_sum. Whatever one side gains, the other loses, exactly. Floating-point sum stays at 3000.0 across the match.
  • Home advantage symmetry: home expected score with +HOME_ADVANTAGE and away expected score with -HOME_ADVANTAGE sum to 1.0 (test_expected_score_with_home_advantage).
  • Favourite probability sanity: a +200 rating gap on neutral ground gives the favourite ~76%, matching the chess-Elo expected value (test_expected_score_with_rating_gap).
  • Goal-difference monotonicity: 0-1 returns 1.0; 2 > 1; 3 > 2; 5 > 3; symmetric in sign (test_goal_diff_multiplier).
  • K-factor: playoff swing on identical inputs is exactly K_PLAYOFF / K_REGULAR = 1.5x the regular-season swing (test_k_factor_playoff_vs_regular).
  • Season regression: 1700 carries over to 1650.0 exactly under the 25% rule (test_season_carryover).
  • Rating deviation behaves like uncertainty should: g(0) = 1 and strictly decreasing; RD shrinks after every match, shrinks more against a well-known opponent and more for a coin-flip fixture than a near-certain one; the floor holds under arbitrarily long runs; offseason inflation is exactly the quadrature carryover and caps at RD_MAX (test_elo.py RD tests, test_compute_elo.py rebuild integration). A test also reproduces the rating walk with the pure math to prove RD changes nothing (test_rebuild_rd_does_not_change_ratings).
  • End-to-end face validity: the all-time top of the rating table matches the league's actual top performers (Forge, Cavalry, Pacific have rotated through the top three across the seven-year history; Forge's 2019-2020-2022 title runs and Cavalry's 2023-2024-2025 three-peat both produce visible peaks on the trajectory chart). Expansion sides (Atlético Ottawa, Vancouver FC) show the expected sub-1500 dip in their first season followed by recovery as results accumulate.

Failure modes

  • 2020 Island Games: 7-11 matches per team in a bubble format with no travel and no fans. Elo treats every match identically, including the home-advantage bonus, which is harder to defend in a neutral-venue tournament. The compounding effect is mild because the entire league played in the same venue for the same number of matches, but a team's 2020 trajectory should be read with that context. No special handling is applied.
  • Two-leg ties: 2019 and 2021 finals were two-leg. Each leg is treated as an independent match, and the home-advantage bonus applies to whichever side hosted that leg. This is correct Elo-wise but does not capture aggregate-score logic; the second leg in a tied aggregate could update ratings as if a draw on the night were a true draw, even though one side advanced.
  • Tiny league, tiny samples: in an 8-to-9-team league the same pairs play three times a season. A team's rating is heavily shaped by a handful of head-to-heads. Single-season ratings carry more variance than they would in a 20-team league with 38 fixtures.
  • No squad-strength prior: an expansion side or a team that cleaned out its roster in the offseason is treated identically to a returning side. The 25% season carryover is the only nod to squad turnover.
  • Match-importance flag is binary: every regular-season match uses K=20 regardless of whether it is a meaningless final-week fixture or a top-of-table six-pointer. Implementing a continuous importance scale was out of scope.
  • No injury / lineup awareness: the rating reflects results, not the strength of the XI on the pitch.

What would make it better

  • Tune home-advantage from CPL data once the sample is larger. 100 is defensible, but a CPL-specific value derived from venue and altitude effects would be tighter.
  • Per-leg-tie and aggregate-score handling so two-leg finals update against the actual advancement outcome, not just the per-leg score.
  • Match-importance weighting (e.g. K scaled by playoff-spot contention or relegation-equivalent stakes) so a meaningless final matchday doesn't update ratings as much as a season-defining one.
  • Squad-stability prior that nudges season-carryover up or down per team based on the share of minutes returning from the prior season, instead of a flat 25% regression for everyone. The same prior could feed the RD offseason inflation.
  • Calibration plot on the Elo page (predicted vs actual win rate in rating-gap buckets) so a reader can see how well the curve fits the league's actual upset rate.