← All methods

Playoffs

Summary

Most analytic surfaces on the site silently exclude playoff matches at the compute layer (form, splits, xPts, SQI, predictions, standings) so that derived metrics aren't distorted by tiny post-season samples. Elo, by contrast, walks every match including playoffs. The result is that pre-this-feature, the only place a user could see playoff performance was the per-match pages and a "PO" badge on match cards.

The Playoffs page at /analysis/playoffs is the inverse of every other analysis page: it surfaces only playoff matches and aggregates them into team records, an Elo-during-playoffs view, championship counts, top performers, and the full results book.

Data

  • Source tables:
    • matches (filtered to is_playoff = 1) for results, scores, and finals/titles attribution.
    • team_elo_history joined to matches so playoff status is read from matches.is_playoff (the source of truth) rather than the snapshot column on Elo history. The Elo history is_playoff column lags any playoff backfill until the next Elo recompute, so the join keeps the page accurate without forcing a recompute.
    • match_players (joined to matches on match_id and filtered to playoff matches) for per-player goals/assists/minutes/appearances.
  • Filter: is_playoff = 1 plus, for the records computation, home_goals IS NOT NULL AND away_goals IS NOT NULL so unplayed fixtures don't inflate played/PPG.
  • Time range: 2019 onward. Every CPL season has at least one playoff match (final-only in 2019, full Island Games tournament in 2020, semis + finals from 2021 on).
  • Sample size: 32 playoff matches across 7 seasons as of writing (2-7 per season). Numbers are small. The page is meant to be a record book + run-summary, not an inferential statistical surface.

Choices

Records

  • Aggregates W/D/L/GF/GA/GD/CS/Pts/PPG over all playoff matches in scope (single season when season=N, all seasons when omitted).
  • seasons_in_playoffs = count of distinct seasons in which the franchise appeared in any playoff match. Always 1 in per-season scope (hidden in the UI for that scope to reduce noise).
  • finals = number of seasons in which the franchise played in the championship-deciding match. titles = number of seasons in which the franchise won that match. Both are derived from a single rule: the chronologically latest playoff match of the season (date DESC, id DESC tiebreak) is treated as the final. Both teams in that match get +1 final; the winner gets +1 title.
  • Sorted by points desc, GD desc, GF desc, team name.

Elo deltas

  • Per-season scope: reports the team's Elo entering its first playoff match of the season (elo_before of the earliest row) and its Elo after the last playoff match (elo_after of the latest row). delta = elo_after - elo_before. Captures the "playoff run" swing.
  • All-time scope: sums per-match Elo changes (elo_after - elo_before) across every playoff match in the franchise's history. Captures whether playoff performance has been a net builder or destroyer of the franchise's rating across its full run, without conflating it with regular-season movement. elo_before / elo_after are null because they aren't meaningful when summed across separate playoff runs in different seasons.
  • Sorted by delta desc.

Leaderboards

  • Aggregates match_players.stats_json for matches where is_playoff = 1. Reads the SDP per-match goals and assists stat keys directly so totals reconcile with the season pages, scoped to playoff matches.
  • Goal-attribution caveat: own goals are credited to the conceding team in match scores but never appear in the scorer's goals stat, so the sum across the leaderboard can be one or two below the scoreline-derived goal total in seasons with own goals.
  • goal_contributions = goals + assists.
  • team is the player's most-frequent (team_id, season) combination within scope, then resolved era-correctly.
  • Each category returns the top 10 with min_value=1 so leaderboards don't fill up with players who hold all-zeros across the metric.

Champions strip (frontend-derived)

  • The "Champions" panel on the all-time view is derived client-side from the same results list, applying the same final-detection rule as the backend (groupBy(season).first on a season-DESC, date-DESC feed). Saves an extra endpoint and keeps the rule in lockstep with the records computation.

Validation

  • All 7 known CPL champions are correctly attributed by the last-match-winner rule (Forge 2019, 2020, 2022; Pacific 2021; Cavalry 2023, 2024, 2025).
  • All-time records spot-check against publicly available CPL playoff histories: Forge leads in playoff appearances (7), finals (6), and titles (4), matching the league record.
  • Elo joins via matches.is_playoff rather than team_elo_history.is_playoff. A regression test zeroes the Elo history flag and asserts deltas still surface, guarding against a class of stale-derived-data bug we've hit elsewhere.
  • Leaderboards exclude regular-season matches by joining to matches.is_playoff = 1. A regression test seeds a 5-goal regular-season match for the same player and asserts it does not appear in the playoff totals.
  • Era-correct naming: a 2019-scoped query returns "York9 FC", an all-time query returns the franchise's current name.

Failure modes

  • Two-leg finals (2019, 2021): only the second leg is treated as "the final" by the last-match rule. In both years the same two teams played both legs and the aggregate winner also won the second leg, so titles and finals attribution still come out correct. A future format with a 2-leg final where the trailing team wins the second leg but loses on aggregate would be misattributed; if this becomes a real possibility, replace the rule with a bracket/round table.
  • Own goals: the goals leaderboard under-counts total scoreline goals by the number of own goals in the period. This is intentional (own goals shouldn't be credited to a player) but worth noting if a user tries to reconcile player goals to scoreline totals.
  • Per-match stat coverage: the goals and assists stat keys in match_players.stats_json rely on SDP populating them. Older seasons have full coverage today thanks to the data-quality backfill, but a player who appears in a playoff match without a stats blob (extremely rare in current data) would show appearances/minutes from match_players but zero goals/assists.
  • Tiny samples: per-season records are computed over 1-3 matches per team. Sort-by-PPG can put a team that won a single match above one that won 6 of 8. The default sort is by raw points to mitigate.

What would make it better

  • A real bracket / round table so seeding, semi vs final, and away-goals tiebreaks can render cleanly instead of being inferred from chronology.
  • Player playoff percentiles — currently the Player Profiles page treats season=N as all-competitions. A dedicated playoff percentile cohort (or at minimum a "Playoff" tab on the player profile) would let a user see whether top performers in playoffs deviate from their regular-season rates.
  • Toggle on existing pages for "include playoffs" so users can re-run form/splits/xPts/SQI including post-season matches and see how the picture shifts.
  • A playoff vs regular-season delta column on the team records table (this team's regular-season PPG vs their all-time playoff PPG) to quantify the "play big in October" effect.