Skip to content

Implementing a new league

This is system context for an AI agent adding a sport league (Sportradar refresh, admin visibility, listener subscribe) without prior knowledge of the repository.

Stop when the user’s slice is done. Typical first slice is refresh so matches appear in admin and a listener can record. Later slices (match_state, period auto-fill, question definitions, official-result fetchers) are separate.

This page is the source of truth. After each league slice, append concrete learnings here. Do not leave discoveries only in chat.

Required reading

Doc / rule Why
.cursor/rules/multi-league-event-semantics.mdc Period extractors in an ordered tuple; do not grow if league == trees
.cursor/rules/general-conventions.mdc Refresh naming (not sync); no _impl suffix; Celery wrappers in tasks/jobs/
../development/celery.md Full worker vs listener worker; Beat / RedBeat
../guides/match_listener_startup.md Dispatch, settings merge, local make listen
MLB tree under src/lib/sportradar/mlb/ Reference implementation for a full refresh stack

Reuse first. Shared upserts live in src/lib/sportradar/base_client.py (upsert_league_to_db, upsert_team_to_db, upsert_season_to_db, upsert_match_to_db, get_or_create_season). Do not duplicate status-heal, team-lookup, or season-stub logic.

Phased checklist

Copy and track. Skip phases the user did not ask for.

Progress:
- [ ] 1. Enum / admin / PG enum
- [ ] 2. Sportradar client (confirm URL prefix)
- [ ] 3. Refresh league → teams → seasons → matches
- [ ] 4. Celery wrappers, full-worker register, Beat, import-smoke
- [ ] 5. Bootstrap CLI scripts/db/refresh_<league>.py
- [ ] 6. Listener subscribe path + period extractor
- [ ] 7. srsim subscribe route (if replay is needed)
- [ ] 8. Later slices (out of a refresh-only pass)

1. Enum / admin / PG enum

LeagueTypes.NFL and the admin option already exist. Do not rebuild them.

2. Sportradar client

Add src/lib/sportradar/<league>/client.py extending SportradarClient.

Confirm the REST URL prefix before writing _build_url. The base helper emits {base}/{league}/{access}/{version}/{language}/…. Some products insert an extra segment (NFL: official between league slug and access). Keep LEAGUE as the slug used in our DB / admin (nfl), and override path building when the vendor path differs.

Wrap only the endpoints this slice needs (teams, seasons, schedule windows). Default access_level=trial unless the user says otherwise.

3. Refresh league → teams → seasons → matches

Mirror the MLB file layout:

Module Job
refresh_<league>_league.py Teams feed → upsert_league_to_db(..., LeagueTypes.<X>)
refresh_<league>_teams.py Require the league row; upsert teams[]
refresh_<league>_seasons.py Keep year >= 2026 unless the user sets another cutoff
Beat-cadence matches Name the job after the feed (daily vs weekly), not a copied MLB name
refresh_<league>_matches.py First-load full-season schedule; 1s sleep between season types. Also called by the daily active-season job for non-closed types.

Payload nesting is league-specific — see Learnings. Flatten to a list of game dicts, then call upsert_match_to_db. Missing teams skip the match (upsert already logs and increments errors).

Name Beat/current-window jobs after the vendor feed. NFL has no daily schedule endpoint — the analog is weekly.

When adding a league, always load the entire current season once (all season types the feed offers) so admin can browse future weeks — including types not yet in the seasons table. Then register the season-schedule function on SEASON_SCHEDULE_REFRESHERS so the daily job picks it up.

Do the first load in the bootstrap CLI and, on first staging/main deploy, enqueue refresh_<league>_matches after league → teams → seasons.

4. Celery / Beat / import-smoke

  • Wrappers under src/tasks/jobs/<league>_*.py (lock + sync_run_async)
  • Import them in register_full_worker_tasks() only — not on the listener worker (tasks.celery_listener)
  • Beat in src/tasks/beat_schedules.py, typically enabled=_not_develop:
  • Window (10 min): daily or weekly feed, live status
  • Active seasons (refresh_active_season_matches at 07:00): every scheduled / inprogress season on enabled, registered leagues. Closed seasons are not refetched daily.
  • Full-season refresh_<league>_matches stays on-demand (not its own Beat entry). Enqueue it once when the league is first added.
  • Import-smoke: src/lib/tests/test_base_memory_import_smoke.py — listener must not load sport refresh jobs or active_season_matches; find_app('tasks.celery:celery') must register the window job and refresh_active_season_matches

5. Bootstrap CLI

scripts/db/refresh_<league>.py — click CLI, db_session, order league → teams → seasons → full-season matches. On staging/main, the same order via make enqueue (include refresh_<league>_matches). Beat then keeps the live window plus non-closed seasons current.

6. Listener subscribe + period extractor

  • Subscribe path is league identity (URLs). NFL already uses nfl/official/{env}/stream/en/events/subscribe in src/listener/listen.py.
  • Period / phase labels: add an extractor and append it to PERIOD_LABEL_EXTRACTORS in src/lib/event_period.py. Do not special-case inside Match, the listener, or admin.
  • Local record: make listen SR_ID=<sportradar-match-id> (defaults LEAGUE=nfl).

match_state may stay a no-op (NoOpMatchStateUpdater) for a record-only slice. NFL now has NflMatchStateUpdater (drive totals the push feed omits).

7. srsim

If operators will replay archives, add the same subscribe path the listener uses. NFL already has /nfl/official/{access_level}/stream/{language_code}/events/subscribe.

8. Later slices (not a refresh-only pass)

  • Per-league match_state updater in src/lib/match_event_state/
  • AUTO_PERIOD_DISTRIBUTION_KEYS when operators need period auto-fill
  • Question groups, then definitions (use the author-question-definitions skill)
  • Official-result REST for Match questions (NFL boxscore is already wired)

Reuse map

Need Reuse
HTTP + upserts SportradarClient, upsert_*, get_or_create_season
Team abbr upsert_team_to_db accepts alias or abbr
Season type Nested type.code (PRE / REG / PST / …)
Match status normalize_match_status + Match.refreshed_status heal
Celery lock task_lock in the job wrapper
Period labels New extractor in the ordered tuple

Do not invent a second upsert path. apply_match_question_side_effects on match upsert is a no-op when the league has no Match-question definitions.

Learnings

Shared steps stay league-neutral. Sport-specific notes are labeled.

Shared

  • upsert_team_to_db accepts alias or abbr.
  • Seasons need a nested type object with code; a bare string is skipped.
  • Missing home/away team rows skip the match (refresh teams first).
  • Deleted schedule rows (deleted: true) are skipped.
  • Listener worker must not import sport refresh jobs (RSS).
  • Full-season schedule jobs are on-demand for first load (types not yet in DB). After that, Beat refresh_active_season_matches (07:00) refreshes every non-closed season on registered leagues. Closed seasons are skipped. Register the league’s season-schedule function on SEASON_SCHEDULE_REFRESHERS.

MLB

  • REST: mlb/{access}/v8/en/… (base _build_url is correct).
  • Daily: games/{Y}/{MM}/{DD}/schedule, flat games[].
  • Season: games/{year}/{season_type}/schedule, flat games[].
  • Season types on the full-season job: PRE / REG / PST / AST / WBC.
  • Beat analog is daily (refresh_mlb_daily_matches, 10 min window of yesterday / today / tomorrow ET).

NHL

  • REST: nhl/{access}/v7/en/… (base _build_url is correct; no official/).
  • Daily: games/{Y}/{MM}/{DD}/schedule, flat games[]. Each game has season.{year,type} where type is a bare string (REG), not a nested {code, name} object. Daily refresh must use that year (start year), not the calendar date — a January game is the previous start year.
  • Season: games/{year}/{season_type}/schedule, flat games[].
  • Seasons feed: league/seasons with nested type.{code,name} (PRE / REG / PST). Season year is the year the season begins (2025 = 2025–26). Future types (often PST) may omit start_date / end_date; upsert_season_to_db skips those until the feed has dates. Match upsert can still create a stub season.
  • Season types on the full-season job: PRE / REG / PST.
  • Default omitted season_year for refresh_nhl_matches uses a July cutover (month >= 7 → this calendar year, else previous). Do not copy MLB’s datetime.now().year.
  • Beat analog is daily (refresh_nhl_daily_matches, 10 min window of yesterday / today / tomorrow ET).
  • Listener subscribe path was already nhl/{env}/stream/en/events/subscribe before the refresh stack existed. Period labels already use extract_nhl_nfl_period. match_state is still a no-op.

NFL

  • REST: nfl/official/{access}/v7/en/…. The official segment is why _build_url(league="nfl") is wrong. Keep LEAGUE = "nfl"; insert official in the client path builder.
  • Current week: games/current_week/schedule. Games are under week.games, not top-level games. Season year/type are top-level year / type (not a nested season object).
  • Weekly: games/{year}/{season_type}/{week}/schedule — also week.games.
  • Season: games/{year}/{season_type}/schedule — games under weeks[].games.
  • Season types: PRE / REG / PST.
  • Beat analog is weekly (refresh_nfl_weekly_matches): current week plus previous week (status catch-up after Monday Night Football). No daily schedule feed.
  • Previous week is the same season type with week.sequence - 1. Week 1 of a type skips the previous-week fetch (no PRE → REG hop).
  • Flatten helpers live in src/lib/sportradar/nfl/schedule.py (week.games vs weeks[].games; top-level year / type).
  • Listener subscribe path already included official/ before the refresh stack existed.
  • Period labels already use extract_nhl_nfl_period. Live plays nest payload.event.period {id, number, sequence} — prefer sequence (overtime is sequence: 5 with number: 1). payload.game.quarter is the integer scoreboard quarter.
  • Push payload.event.type is play or event (with event_type such as period_end). There is no drive_start / drive_end type. Drive identity and end_reason / start_reason / first_downs / net_yards live on payload.event.drive. Situation is start_situation / end_situation (down, yfd, possession, location.yardline) — not yards_to_opponent_goal.
  • Live score: payload.game.summary.home|away.points and payload.event.home_points / away_points. Official Match-question resolve uses Game Boxscore (games/{game_id}/boxscore). There is no Game Summary feed. Final scores are summary.home.points / summary.away.points. Both game_summary and game_boxscore resolution keys call this endpoint.
  • Review: payload.event.official === false and/or description “Play is under review.” Unit id is the play id (play_id in store_state).
  • match_state publishes only facts the feed lacks: drive_key, drive_rush_yards, drive_pass_yards, drive_pass_completions, home_pass_completions. Read drive.first_downs / end_reason from the event. AUTO_PERIOD_DISTRIBUTION_KEYS for NFL is 14.
  • Question groups: drive ({"dist": "even"}) and game (max_per_match=1). Staging SQL is in question_definitions_league_event_state.md.

Maintenance

After each league slice:

  1. Append URL shape, payload nesting, season types, and Beat naming here.
  2. Update scripts/db/README.md, ../development/celery.md, and ../guides/match_listener_startup.md when operators or agents need the new job/CLI.
  3. Keep this page longer than the Cursor skill. The skill only points here.