Expected Points (xPts)
Summary
For every regular-season match, derive each team's win, draw, and loss
probability from the pre-match Elo ratings, then convert that into
expected points using the standard 3 * P(win) + 1 * P(draw). Sum across
a season to get an xPts total per team. Compare to actual league points
to surface over- and under-performers.
Surfaced in the analysis hub as the Performance vs Expectation view
(/analysis/expected). Sorted by Δ (actual minus expected) so the most
fortunate team sits on top of the table and the unluckiest at the
bottom.
Data
- Source:
team_elo_historyjoined tomatchesandstandingsfor the same season. - Time range: every CPL season from 2019 onward, regular season only.
- Sample: roughly 100 to 117 matches per season (35 in the 2020 Island Games), 200 to 234 match-team rows per season after splitting each match into a home and away record.
- Pre-match ratings: we use
elo_beforestraight fromteam_elo_history. These ratings already include the per-season carryover regression (25% pull toward the mean), so the first matches of a new season use slightly washed-out versions of the previous season's ratings rather than a clean reset. - Excluded: playoff matches. Doing this keeps the comparison to the regular-season league table apples-to-apples. A separate "playoff surprise" view could live elsewhere if it ever earns its keep.
- Known data gaps: see
DATA_QUALITY.md— none of them affect this metric directly because we only consume match results and Elo, not event data.
Choices
Pre-match Elo as the only feature
Elo already integrates form, opponent strength, and home advantage into a single number. This metric ships with the simplest possible expected-points formulation that is still defensible. The richer Match Outcome Model layers rest days, recent shot volume, and the Shot Quality Index on top.
Home advantage
Already built into Elo as a fixed +100 to the home side's effective rating. The empirical home/draw/away split across all 719 historical regular-season matches is 43.5 / 26.3 / 30.2 percent, which is consistent with the +100 we already use, so no adjustment was needed for this metric.
Flat empirical draw rate
Elo gives a single "expected score" per side under the W=1, D=0.5, L=0 mapping. To convert that into a discrete W/D/L distribution we need a draw probability. The clean choice would be to make draw probability a function of the absolute Elo difference (closer matches → more draws). We tested this against the historical data:
| |Elo diff| (bucket) | matches | draws | draw rate | |---|---|---|---| | 0–50 | 257 | 73 | 28.4% | | 50–100 | 234 | 58 | 24.8% | | 100–150 | 144 | 37 | 25.7% | | 150–200 | 57 | 13 | 22.8% | | 200–250 | 23 | 6 | 26.1% |
The draw rate is essentially flat across realistic mismatches. Any binned-lookup or fitted curve would just be tracing sample noise, and the pretty-pretty machine learning would actively hurt the metric.
So the chosen approach is the boring one: fit a single flat draw rate across all matches from completed seasons (currently 0.2629), use it for every match, and document this loudly. In-progress seasons are excluded from the fit so a season's xPts values don't shift as it accumulates matches. Once the empirical draw rate is fixed, the home and away win shares from Elo are renormalised to fit the remaining 1 - draw_rate of the probability mass:
raw_home = expected_score(home_elo, away_elo, home_advantage=+100)
raw_away = 1 - raw_home
p_draw = empirical_draw_rate # ≈ 0.263
p_home = raw_home * (1 - p_draw)
p_away = raw_away * (1 - p_draw)
xpts_home = 3 * p_home + 1 * p_draw
xpts_away = 3 * p_away + 1 * p_draw
The draw rate is recomputed every time the script runs, so as completed seasons accumulate it will drift to track reality without any code change.
Per-match storage, not season-aggregated
We store one row per (team, match) in team_match_expectations and
aggregate at query time. This is heavier than precomputing season
totals, but it sets up the match outcome model cleanly: per-match
probabilities are exactly what the match-page expectation panel needs,
with no recompute required.
Sort order
The default sort is by Δ descending. Teams that look exciting at the top of a normal standings table can sit at the bottom here, and that contrast is the entire point of the view.
Uncertainty: exact convolution, not simulation
A Δ of +3 over 28 matches is not the same kind of evidence as +3 over 100, so each team-season row carries a luck interval: the range of season points a team of exactly this modeled strength would post at least 90% of the time over these exact fixtures.
No bootstrap is needed. Season points are a sum of independent
per-match draws over {3: p_win, 1: p_draw, 0: p_loss}, all of which
are already stored per match, so the exact distribution is a small
convolution (app/xpts_distribution.py). No simulation, no random
seed, no sampling noise — the interval is the same number every time
it is computed.
Conventions, stated precisely:
- Quantiles use the smallest
kwithCDF(k) >= q; the reported interval is[q05, q95]of the exact season-points PMF. - Because points are discrete, coverage is at least 90%, never less. The interval is conservative.
- The API reports the interval recentred on xPts (
delta_lo,delta_hi) and asignificantflag: actual points strictly outside[q05, q95].
What the interval is not. It is the model's luck range, not an error bar on the observed Δ. A muted Δ on the table means "a team of exactly this strength posts a gap like this often enough that we can't call it anything but luck"; a full-colour Δ means the model and reality disagree beyond what match-level luck explains — which could be the team, or could be the model.
Intervals are reported for single seasons only. At franchise scale (~200 matches) the gap between actual and expected points is dominated by model bias rather than luck, and a tight all-time interval would invite over-reading, so the all-time view withholds it.
Validation
Total xpts ≈ total points awarded
Each match awards 3 points if decisive (74% of the time) and 2 points
if drawn (26%), so the per-match expectation should be ~2.74. For a
117-match season the total xPts across all teams should land near
117 * 2.74 ≈ 320.4. The 2025 season computes to 320.2 total xPts
across all 8 teams. Sanity check passes.
Per-match probabilities sum to 1
p_win + p_draw + p_loss is exactly 1.0 by construction; covered by a
regression test.
Luck intervals reproduce hand arithmetic
A two-match season with controlled probabilities has its full 7-entry
points PMF computed by hand and pinned in test_xpts_distribution.py;
the API-level interval for the same inputs is asserted end-to-end in
test_expected_points.py, including the >= 90% coverage property on a
realistic 28-match synthetic season.
Equal teams, home advantage shows up
Two equal teams playing once: the home side's xPts is meaningfully higher than the visiting side's, because the +100 home Elo bonus shifts the win share. Tested.
Eyeball known overperformers
2025 sample (after running the compute script):
| Team | Pts | xPts | Δ |
|---|---|---|---|
| Forge FC | 58 | 50.7 | +7.3 |
| Atlético Ottawa | 56 | 49.6 | +6.4 |
| Cavalry FC | 42 | 50.0 | -8.0 |
| Pacific FC | 23 | 31.9 | -8.9 |
Forge winning the league while xPts has them as the second-strongest side is a believable "great year + a bit lucky" result. Cavalry being strong on the underlying numbers but finishing 3rd matches the narrative of a well-coached side that bled too many late equalisers. Pacific being unlucky tracks with their actual season.
Failure modes
- Small samples per season. Single-season Δ is noisy. The luck interval quantifies this per row, but it can't fix it: most CPL season deltas are luck-compatible, and that is the honest reading.
- Luck intervals assume per-match independence. The convolution treats matches as independent given their stored pre-match probabilities. Elo evolves with results, so outcomes are mildly coupled through the rating chain; the interval ignores that second-order effect.
- In-progress seasons get moving intervals. The interval covers matches played to date, so early-season intervals are wide and shift with every sync until the season completes.
- Elo bootstrap in 2019. Every team starts at 1500. The first few weeks of 2019 have ratings that have not yet sorted themselves, so early-2019 xPts is more noise than signal. Fine for season totals, noisy if you ever drill down to early-season match-by-match.
- Carryover smoothing across seasons. Elo regresses 25% to the mean each new season. Early-season matches in any year use ratings that understate the true gap between teams. Affects Δ by a small constant that washes out across the season.
- Flat draw rate across realistic mismatches. Honest about the data. But if CPL ever signs a dominant superclub the way some leagues have, the assumption could start to leak. Recomputing the rate from updated data is one command.
- No accounting for cards, injuries, rest days, scheduling. That is the match outcome model's job.
- Friendlies, abandoned matches, walkovers. Not in the dataset, so not an issue today, but worth flagging if the dataset ever grows.
- The standings join uses the franchise's current
team_id. Teams that have rebranded mid-platform-history (York9 → York United → Inter Toronto) are linked correctly because all three rows share a single franchise row. New franchises that arrive without their ownteam_idwould silently miss their actual points until the standings table is updated.
What would make it better
- Match outcome model derivation — replace the renormalised Elo split with a properly-fit ordinal regression that includes form, rest, set-piece volume, the Shot Quality Index, and home advantage as actual model features rather than a hard-coded +100.
- Calibration plot — bin matches by predicted home win probability and check whether the empirical home win rate matches the prediction in each bin.
- xGD-derived xPts — shipped as SQI Expected Points: an independent xPts derived from each match's shot quality under a double-Poisson goals model, shown alongside this metric as a three-way comparison.
- In-season vs end-of-season ratings. This metric uses pre-match Elo, which means xPts retroactively benefits from later-season form via the rating evolution. A "what would the start-of-season Elo have predicted" version is a small extension and a cleaner forecast view.