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¶
LeagueTypesinsrc/lib/types.py- Postgres enum label (Alembic if the value is new)
- Admin league dropdown in
src/api/admin/templates/_macros.jinja2
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, typicallyenabled=_not_develop: - Window (10 min): daily or weekly feed, live status
- Active seasons (
refresh_active_season_matchesat 07:00): everyscheduled/inprogressseason on enabled, registered leagues. Closed seasons are not refetched daily. - Full-season
refresh_<league>_matchesstays 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 oractive_season_matches;find_app('tasks.celery:celery')must register the window job andrefresh_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/subscribeinsrc/listener/listen.py. - Period / phase labels: add an extractor and append it to
PERIOD_LABEL_EXTRACTORSinsrc/lib/event_period.py. Do not special-case insideMatch, the listener, or admin. - Local record:
make listen SR_ID=<sportradar-match-id>(defaultsLEAGUE=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_stateupdater insrc/lib/match_event_state/ AUTO_PERIOD_DISTRIBUTION_KEYSwhen 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_dbacceptsaliasorabbr.- Seasons need a nested
typeobject withcode; 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 onSEASON_SCHEDULE_REFRESHERS.
MLB¶
- REST:
mlb/{access}/v8/en/…(base_build_urlis correct). - Daily:
games/{Y}/{MM}/{DD}/schedule, flatgames[]. - Season:
games/{year}/{season_type}/schedule, flatgames[]. - 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_urlis correct; noofficial/). - Daily:
games/{Y}/{MM}/{DD}/schedule, flatgames[]. Each game hasseason.{year,type}wheretypeis 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, flatgames[]. - Seasons feed:
league/seasonswith nestedtype.{code,name}(PRE / REG / PST). Season year is the year the season begins (2025= 2025–26). Future types (often PST) may omitstart_date/end_date;upsert_season_to_dbskips 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_yearforrefresh_nhl_matchesuses a July cutover (month>= 7→ this calendar year, else previous). Do not copy MLB’sdatetime.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/subscribebefore the refresh stack existed. Period labels already useextract_nhl_nfl_period.match_stateis still a no-op.
NFL¶
- REST:
nfl/official/{access}/v7/en/…. Theofficialsegment is why_build_url(league="nfl")is wrong. KeepLEAGUE = "nfl"; insertofficialin the client path builder. - Current week:
games/current_week/schedule. Games are underweek.games, not top-levelgames. Season year/type are top-levelyear/type(not a nestedseasonobject). - Weekly:
games/{year}/{season_type}/{week}/schedule— alsoweek.games. - Season:
games/{year}/{season_type}/schedule— games underweeks[].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.gamesvsweeks[].games; top-levelyear/type). - Listener subscribe path already included
official/before the refresh stack existed. - Period labels already use
extract_nhl_nfl_period. Live plays nestpayload.event.period{id, number, sequence}— prefer sequence (overtime issequence: 5withnumber: 1).payload.game.quarteris the integer scoreboard quarter. - Push
payload.event.typeisplayorevent(withevent_typesuch asperiod_end). There is nodrive_start/drive_endtype. Drive identity andend_reason/start_reason/first_downs/net_yardslive onpayload.event.drive. Situation isstart_situation/end_situation(down,yfd,possession,location.yardline) — notyards_to_opponent_goal. - Live score:
payload.game.summary.home|away.pointsandpayload.event.home_points/away_points. Official Match-question resolve uses Game Boxscore (games/{game_id}/boxscore). There is no Game Summary feed. Final scores aresummary.home.points/summary.away.points. Bothgame_summaryandgame_boxscoreresolution keys call this endpoint. - Review:
payload.event.official === falseand/or description “Play is under review.” Unit id is the playid(play_idin store_state). match_statepublishes only facts the feed lacks:drive_key,drive_rush_yards,drive_pass_yards,drive_pass_completions,home_pass_completions. Readdrive.first_downs/end_reasonfrom the event.AUTO_PERIOD_DISTRIBUTION_KEYSfor NFL is1–4.- Question groups:
drive({"dist": "even"}) andgame(max_per_match=1). Staging SQL is inquestion_definitions_league_event_state.md.
Maintenance¶
After each league slice:
- Append URL shape, payload nesting, season types, and Beat naming here.
- Update
scripts/db/README.md,../development/celery.md, and../guides/match_listener_startup.mdwhen operators or agents need the new job/CLI. - Keep this page longer than the Cursor skill. The skill only points here.