← All methods

Team Form

Summary

A trailing 7-match rolling average of points-per-game computed per team, per season, in chronological order. Each match earns W=3, D=1, L=0, and the rolling-form value at matchday N is the average points across the seven most recent matches up to and including N. Early-season points use a partial window (matchday 3 averages the three matches played so far, etc.) and the window_size is reported alongside every point so the UI can render the "still warming up" segment differently.

The page at /analysis/form shows each team's series across a selected season or stitched into a career trajectory across all seasons. Backed by /api/team-form. Math and persistence live in backend/scripts/compute_team_form.py; the rolling values are stored in the team_form_series table so the API can serve them without recomputing.

Regular season only. Playoff matches are excluded by design (see Choices).

Data

  • Source table: matches, filtered to is_playoff = 0 AND home_goals IS NOT NULL, ordered (date, id).
  • Output table: team_form_series, one row per team per matchday. Stored columns: team_id, season, matchday, match_id, match_date, result, points, rolling_ppg, window_size.
  • Time range: 2019 onward. Every CPL regular season is included.
  • Refresh: per-season rebuild on every nightly sync. Rebuild is cheap (a few hundred matches), so the script wipes and rewrites each season's rows rather than attempting a per-row upsert.
  • Sample size: ~28 regular-season matches per team per season (2021 onward); 8-10 in 2019; 7-11 in 2020.

Choices

Window length: 7 matches

The rolling window is 7 matches (WINDOW = 7 in compute_team_form.py). This is the central judgement call.

  • A 28-match CPL regular season makes 7 a quarter of the season. Long enough to smooth single-result swings, short enough that a genuine four-or-five-match run actually moves the line.
  • The 5-match window common in tactical-analysis writeups felt too jumpy in CPL: a single bad result drops the line ~0.6 PPG, which reads as a slump even when a team is playing fine.
  • A 10-match window is more stable but blurs in-season pivots. By the time it noticed a four-match losing streak, the streak would already be ending.
  • 7 matches happens to be the modal "round of fixtures" duration in CPL (each team plays roughly once a week during the regular season), so the value reads as roughly "form over the last six to eight weeks."

The window is not exposed as a UI control. Adding a slider was considered but deferred: a single defensible default keeps the chart legible across many teams, and a user who wants a different window can read the per-match results off the same page.

Points scheme: 3-1-0

Wins are 3, draws 1, losses 0. The PPG average is then bounded between 0 and 3. This is the standard league points scheme; no weighting for opponent strength, venue, or margin of victory.

Partial window for early matchdays

Matchday N with N < 7 averages whatever matches exist (max(0, idx - 6) to idx inclusive). The reported window_size shrinks accordingly: matchday 1 has window_size=1, matchday 4 has window_size=4, matchday 7+ has window_size=7.

The frontend uses window_size to render those early points with a different style (dashed segment) so a viewer doesn't misinterpret an early three-game perfect run as a sustained 3.0 PPG team. The data is reported, but visibly flagged as under-windowed.

Alternatives considered:

  • Skip the first six matchdays entirely: rejected because then no team has a form line until matchday 7, and the chart looks empty for the first quarter of every season.
  • Use the prior season's tail to seed the window: rejected because squad turnover between seasons is large enough that carrying the last seven matches of 2024 into the start of 2025 would mislead more than it informs. The stitched career view (all-years mode) does concatenate series across seasons but does not bleed the window across the boundary; each season starts its own window from scratch.

Regular season only

Playoff matches are excluded so every team's series ends on the same matchday and the chart is a clean comparison tool. Including playoffs would distort the tail (only the four playoff teams would have additional data points) and would also dilute the regular- season form signal that the page is built to show. A separate "into-playoffs momentum" view could be added later off the same table if the question becomes interesting; the per-team Playoffs page already has Elo-based playoff swing.

Single-season vs all-years rendering

Two query modes share the same stored data:

  • ?season=N: returns one series per team for that season, using the era-correct franchise name for that season (e.g. "York9 FC" in 2019, "York United FC" from 2020 onward) via the franchise resolver.
  • No season parameter: stitches every team's per-season series into a single career trajectory, ordered by season and matchday, with the matchday renumbered to a sequential career index. The current franchise name is used for grouping. The window_size flags from each season are preserved so the dashed early-season segments remain visible at each season boundary, signalling the reset.

Validation

  • Full-window math: test_rolling_ppg_full_window seeds 7 wins followed by a loss. Matchday 7 reports rolling_ppg = 3.0 with window_size = 7; matchday 8 reports 18/7 ≈ 2.5714 with window_size = 7.
  • Partial-window math: test_rolling_ppg_partial_window seeds W, D, L. The series reports rolling_ppg of 3.0, 2.0 (= (3+1)/2), 4/3 (= (3+1+0)/3) with window_size 1, 2, 3.
  • API integration: test_team_form_endpoint exercises /api/team-form?season=2025 and verifies the series shape, point ordering, and rolling-PPG values. test_team_form_empty_season confirms an empty list is returned for a season with no data.
  • Era-correct names: by virtue of running through the franchise resolver in single-season mode, 2019 returns "York9 FC" while 2020+ returns "York United FC". Spot-checked manually against the resolver tests that already cover this transition.

Failure modes

  • 2020 Island Games (7-11 matches per team): with a 7-match window and 7-11-match seasons, the rolling line is essentially the season's PPG by the end. The smoothing intent is defeated. The bubble-format season is also playing under abnormal conditions (no travel, no fans, neutral venue). Read 2020 form lines as season-PPG-with-extra-steps, not as a trend.
  • Cross-season transitions in the stitched view: each season's window starts fresh, so the line snaps at every season boundary. The dashed early-season segments make this visible, but a viewer scanning a career chart should expect to see "drops" at season starts that are really just window resets, not regression.
  • Schedule density not modelled: a team that plays three matches in a week is averaged the same way as a team that plays three across six weeks. The matchday axis is fixture index, not elapsed time. This usually does not matter for CPL (single-game- per-week is the norm), but matters during fixture pile-ups and for the August-September portion of the schedule where some teams play more frequently than others.
  • No opponent adjustment: a 2-1 win over the table-leader and a 2-1 win over the bottom side count identically. A team that picks up nine points across three matches against the bottom three reads as "in form" the same as a team that does it against the top three.
  • No goal-difference component: a 1-0 and a 5-0 are the same to this metric. A team grinding out 1-0 wins and a team destroying opponents 4-0 read identically. Form-by-PPG was picked because it maps cleanly onto the league points scheme; a goal-difference variant would be a separate series, not a replacement.

What would make it better

  • Opponent-adjusted form, weighting points by opponent Elo at the time of the match. Same shape, less noise from imbalanced schedules.
  • Goal-difference form (rolling 7-match GD per match) as a second series on the same chart, so a viewer can see when a team's results are over- or under-running their underlying performances.
  • Variable-window control (3-match, 5-match, 10-match) so users can stress-test how stable a team's recent form is.
  • Cross-season window carryover as an opt-in toggle, for viewers more interested in the "true" trailing-7-match value than in respecting season boundaries.
  • Schedule-density awareness: an x-axis option to plot form against calendar date rather than fixture index, so weeks with three matches don't visually compress into a single point.