You can find more information about this program on the website!
The current method for calculating the probability takes into account a variety of individual statistics:
- Player's goals per game (GPG)
- Player's goals per game in the last 5 games (5GPG)
- Player's goals per game over the last 3 NHL seasons (HGPG)
- Team's goals per game (TGPG)
- Other team's goals against per game (OTGA)
- Home or away (Home)
- Player's power play goals per game over the last 3 NHL seasons (HPPG)
- Other team's short handed goals per game (OTSHGA)
- Injuries:
https://www.rotowire.com/hockey/tables/injury-report.php?team=ALL&pos=ALL(JSON, no auth) - Starting goalies:
https://www.rotowire.com/hockey/tables/projected-goalies.php?date=YYYY-MM-DD(JSON, no auth,Confirmed / Expected / Unknown) - Goalie + skater stats: official NHL API
api-web.nhle.com(goalie-stats-leaders/current,player/{id}/landing,player/{id}/game-log/now,club-stats/{team}/now,gamecenter/{gameId}/boxscorewithstarter=truefor backfill).club-stats/{team}/nowis current season only and returns no goalies until the team plays a regular-season game, soopp_goalie_*stats are legitimately null in preseason — a goalie with no games this season has no current-season stats, and prior-season numbers are deliberately not backfilled - Recorded per skater as
opp_goalie_*fields (name, team, status, GAA, save %, record) viahandle_get_goaliesbetweenGetInjuriesandGetTims - DB: migrations in
supabase/migrations/*.sqlare applied automatically by CI before the Lambda deploy. Each file is scoped with an__ENV__table-name placeholder that CI substitutes — PRs with thedeploylabel apply to thedevtables, merges tomainapply to theprodtables. psql keeps no migration history, so every file is re-applied on each run and must be idempotent. - The long-run per-player history lives in
Player-Snapshots-{ENV}, written bysmartscore/player_archive.py. It replaced MongoDB and the Cloudflaresmartscore-apiworker that fronted it (#113); see Using these for model training.
If RotoWire tables endpoints ever change/break (undocumented, embedded via
loadTableRWinstarting-goalies.php/injury-report.php), use DailyFaceoff as fallback for both starting goalies (Confirmed / Likely+ timestamp + source, server-rendered HTML, scrapable with BeautifulSoup) and injuries (line combos / injury list). Note RotoWire team codes differ from NHL API (MONvsMTL,LASvsVGK) so keep the map in sync. Its/_next/data/.../line-combinations.jsonendpoint referenced in older notes now returns 404.
smartscore/nhl_lineups.py retrieves line combinations from two sources, and
handle_get_lineups merges them into the player list. Both sources are parsed
deterministically — no LLM extraction.
- Forward lines, defence pairs, goalies, scratches, injuries: the NHL.com daily
projections article (
https://www.nhl.com/news/nhl-lineup-projections-2026-27-season). The lineups ship inside a JSON-LDNewsArticleblock, so this reads thearticleBodyJSON field rather than scraping HTML. Units are identified by group size and labelled positionally:F1-F4(trios),D1-D3(pairs),G1/G2.articleBodyis one flat markdown blob where each game header is appended to the end of the preceding paragraph, so it must be segmented by regex match position — splitting on newlines silently corrupts the result. - Power play units, goalie designation, injuries: the lineup section of
https://www.rotowire.com/hockey/nhl-lineups.php(BeautifulSoup over the stablelineup__*class names). RotoWire does not publish forward lines publicly, so this is complementary to the NHL.com article, which omits PP units.
handle_get_lineups writes four columns, added by
supabase/migrations/20261001_add_lineup_columns.sql:
| Field | Meaning |
|---|---|
lineup_unit |
Forward line only (F1-F4) |
lineup_position_group |
F / D / G, whichever unit the player sits in |
pp_unit |
POWER PLAY #1 / POWER PLAY #2, or null |
lineup_status |
PROJECTED when a forward line matched, else UNKNOWN |
A forward can carry both lineup_unit and pp_unit (typically ~117 players per slate).
lineup_status is deliberately UNKNOWN rather than absent on a miss, so an empty or
failed fetch is distinguishable from a genuine non-match.
Defence pairings and goalie designations are parsed and available on the source payload but not stored per skater, since the picks table is skater-scoped.
Training reads the Supabase Player-Snapshots-{ENV} archive, written by the
SaveToDb step (handle_save_players → save_player_snapshots in
smartscore/player_archive.py). That table replaced MongoDB (#113) and the
Cloudflare smartscore-api worker that fronted it. The other two Supabase tables
are downstream reporting, not the training set: Picks-{ENV} is the current day's
full ranked roster (wiped and rewritten on every PublishToDb), and
Historic-Picks-{ENV} is a ~7-day visual of who we've been picking (only 3 picks
per date, via choose_picks/NUM_EXPECTED_PLAYERS).
Table shape, added by
supabase/migrations/20261002_add_player_snapshots_table.sql:
| Field | Meaning |
|---|---|
date, player_id |
Composite primary key. player_id is the NHL id; it is deliberately not the positional id the Picks writers assign (player["id"] = i + 1), which is a per-request ordinal |
scored |
Nullable INTEGER: null = game not graded yet, 0 = no goal, 1 = goal. This is the training label, and the same triple as Historic-Picks."Scored" |
the four lineup_* / pp_unit columns |
As above, from 20261001_add_lineup_columns.sql |
home/hppg/otshga |
Dropped by save_to_db and update_historical_data because the frontend does not show them, but they are training features (FEATURES in scripts/shared.py) — Mongo kept them, so this table keeps them |
The primary key is the point of the port. The old Mongo write used insertMany
with no unique constraint, so re-uploading a date appended a second full copy of
the roster. Here every write is an upsert on (date, player_id), so re-running
the pipeline for a date refreshes that date's rows in place and
get_players_for_date returns exactly one row per player.
first_run (04:00 EDT / 03:00 EST), a
mid-morning normal_run at 14:30 UTC (10:30 EDT / 09:30 EST), a confirmation
normal_run at 15:15 UTC (11:15 EDT / 10:15 EST), and later refreshes at 17:00
UTC (13:00 EDT / 12:00 EST) and 20:00 UTC (16:00 EDT / 15:00 EST). The first_run
and every normal_run re-fetch injuries, goalies and lineups, then archive to
Player-Snapshots and publish to Picks-{ENV}; a sixth rule sends email at
15:45 UTC, after the morning refreshes. The later refreshes correct the
archive/site but are not used for the email.
first_run -> Backfill -> GetPlayers -> EnsureFirstRunStatus -> GetInjuries -> GetGoalies -> GetLineups -> MakePredictions -> GetTims -> SaveToDb -> UpdateHistory -> PublishToDb
normal_run -> LoadWorkingSet -> GetInjuries -> GetGoalies -> GetLineups -> GetTims -> SaveToDb -> PublishToDb # skips UpdateHistory
last_run -> LoadWorkingSet -> PublishToDb # every game has started; tims cleared, no enrichment reads
EnsureFirstRunStatus restores the status the GetPlayers sub-machine does not
carry, so CheckSaveHistory can tell the three branches apart. SaveToDb upserts
on (date, player_id), so the mid-morning run refreshes the same archive rows in
place with the projections that were not available at 03:00 ET. A re-run for the
same date is idempotent; PublishToDb rewrites the day's Picks rows (delete
scoped to that date) and write_historic_db is a no-op once the date exists. A
failed lineup/injury/goalie fetch keeps the previous values in both Picks and
Player-Snapshots instead of overwriting them with UNKNOWN/HEALTHY/nulls.
last_run goes straight to PublishToDb after LoadWorkingSet, skipping the
enrichment chain: every game has started by then, the picks API is locked, and a
GetTims read can only fail (the 30s Lambda timeout hits before its locked-picks
fallback can run), so the publish clears the tims groups (all 0) itself -- the
frontend renders that as its "All games have started today" state. Skipping
GetGoalies is safe for the published Picks rows: save_to_db carries the
prior opp_goalie_* values forward when the payload has no goalie data.
Two more caveats that came out of the port:
save_player_snapshotsdeliberately omitsscored. PostgREST's merge-duplicates resolution buildsDO UPDATE SETfrom the union of keys in the batch, so including it would reset the column to null on every re-upload and throw away the result ofbackfill_scored. A brand-new row starts null andbackfill_scoredfills it in once the game is final.- The postponed-game delete matches
team_name, not the abbreviation.team_abbris stripped from the pipeline payload (TEAM_MERGE_EXCLUDED_FIELDS), so the retired worker'sDELETE /game— which filtered onteam_abbr— never matched anything and postponed players sat in the archive ungraded forever. The NHL score feed reports only abbreviations, sobackfill_datestranslates them through that date's schedule (resolve_team_names) before deleting.
Other constraints worth knowing:
- The Rust predictor does not read these fields.
make_predictions_teamsbuildsmake_predictions_rust.PlayerInfofrom an explicit field list (gpg/hgpg/tgpg/otga/ otshga/hppg/home), so lineup data is recorded but not yet a model input.MakePredictionsnow runs afterGetLineupson thefirst_runpath, so a future lineup feature has the merged data available without another re-order. lineup_statusdistinguishes outcomes.PROJECTEDvsUNKNOWNlets a training job separate a real negative (not on a forward line) from a failed/empty fetch. Filter onPROJECTEDrather than treating null as "not a top-9 forward".- Access is service-role only.
Player-Snapshotshas RLS enabled with no policies, andplayer_archive.pyusesSUPABASE_ADMIN_CLIENT. The anon key the frontend holds reads nothing from it. ThePicks/Historic-Pickstables predate this and still have no RLS at all.
The MongoDB archive was copied into Player-Snapshots-{ENV} by
smartscore/scripts/port_mongo_snapshots.py, a one-off script that is not run
by CI or the deploy — an operator invokes it, and it is safe to re-run because it
upserts on the (date, player_id) primary key.
Mongo's insertMany never deleted, so the collection almost certainly holds
several copies of the same roster per date. The script collapses them with a
deterministic rule: prefer the copy whose scored is set (only a graded copy
carries the label), then the most recently written (the creation timestamp in the
leading bytes of the Mongo ObjectId), then the raw _id as a final tie-break.
It reports how many documents were scanned and how many duplicates were collapsed.
Always dry-run first — this writes nothing and prints the counts and a sample:
ENV=prod MONGODB_URI=... uv run python smartscore/scripts/port_mongo_snapshots.py --dry-run
ENV=prod MONGODB_URI=... uv run python smartscore/scripts/port_mongo_snapshots.pyCredentials come from the environment only: MONGODB_URI (the same secret the
worker read), SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY (read by config.py,
which also picks up a local gitignored .env), and optionally ENV (dev/
prod, which selects the Mongo collection SmartScoreDev/SmartScore and the
matching Supabase table) and MONGODB_DATABASE (defaults to players, which is
what the worker hardcoded). --date YYYY-MM-DD re-runs a single date.
pymongo is a dev-only dependency, so it stays out of the Lambda zip.
_log_structurelogs how many teams matched the expected 4F/6D/2G shape on every fetch and warns per-team on deviations, so a partial parse is visible instead of silent. Expect occasional "0 goalies" warnings: the article genuinely omits goalies for some teams.merge_lineup_datalogs the match rate and warns below full coverage. It also warns when the article lists one player on two lines at once (seen live with Elias Pettersson on both F1 and F2) — the name-keyed lookup can only keep one, so this says so rather than looking like a clean parse.- The article double-encodes some characters.
Ryan O’Reillyarrives as UTF-8 bytes read back as latin-1, which transliterates toOaReillyand can never match._repair_mojibakeundoes this before transliteration; keep it ahead ofunidecodein any name handling. - These are projected lineups, published in the morning; they are not confirmed.
opp_goalie_*continues to come from the RotoWire goalies table, which carriesConfirmed / Expected. - Joins use
normalize_player_name(folds hyphens, apostrophes, accents and mojibake) because the existing joins match on name, not id. - Neither source publishes an archive; only the current slate is available.
First install all necessary packages:
make local-setup
Deploy to AWS:
- On Linux/macOS:
Or make it executable (
bash build_scripts/deploy.sh
chmod +x build_scripts/deploy.sh) and run:./build_scripts/deploy.sh
- On Windows (using Git Bash or similar):
sh build_scripts/deploy.sh
If you are on windows, ensure Docker is running with the image: "public.ecr.aws/amazonlinux/amazonlinux:2".
The deployment pipeline expects the following GitHub repository secrets to be configured:
AWS_ACCESS_KEY_ID: AWS IAM access key ID used by CI/CD for authenticated AWS API calls.AWS_ACCOUNT_ID: Target AWS account ID used for deployment targeting and resource naming.AWS_SECRET_ACCESS_KEY: AWS IAM secret key paired with the access key for CI/CD authentication.BREVO_FROM_EMAIL: Sender address used for outbound SmartScore notification emails.BREVO_SMTP_KEY: Brevo SMTP API key/password used to authenticate with the SMTP relay.BREVO_SMTP_LOGIN: Brevo SMTP login/username used with the SMTP key.FEATURE_SEND_EMAILS: Feature flag that enables or disables sending emails at runtime.SUPABASE_API_KEY: Supabase anon/public API key used by the default client.SUPABASE_DB_URL: Postgres connection string for the Supabase project, used by CI to applysupabase/migrations/*.sql.SUPABASE_SERVICE_ROLE_KEY: Supabase service-role key used for privileged server-side operations. Also required locally to runsmartscore/scripts/port_mongo_snapshots.py, alongsideMONGODB_URI.SUPABASE_URL: Base URL for the Supabase project used by application clients.
Feature flags are stored as environment variables and read at runtime.
FEATURE_SEND_EMAILS: Controls whether SmartScore sends user notification emails.true,1,yes,on=> enabledfalse,0,no,off(or unset if changed in code defaults) => disabled
Example:
FEATURE_SEND_EMAILS=falseNote: This program is intended for informational purposes only and does not facilitate actual betting. Users should exercise their own judgment and discretion when using the provided suggestions for betting purposes.
- Goals Per Game (GPG): The most telling stat when determining if a player will score a goal. It implicitly considers factors like time on ice, shots per game, shot-to-goal ratio, etc.
- Goals Per Game in Last 5 Games (5GPG): Captures hot streaks (and cold streaks).
- Historic Goals Per Game (HGPG): A player's GPG over the last three seasons. Especially useful at the beginning of a new season when current GPG may be skewed.
- Team's Goals Per Game (TGPG): Useful if a player is traded to a new team.
- Other Team's Goals Against (OTGA): Captures the opposing team's defensive strength, factoring in defense quality, goalie performance, etc. This is independent of a player's GPG, which reflects their scoring average, whereas OTGA varies with each game.
- Home or Away: Helps identify patterns in a player's goal-scoring performance based on location.
- Historic Power Play Goals (HPPG): A player's power play goals over the last three seasons (since these are relatively rare, one season alone might be misleading). Can be combined with Other Team's Shorthanded Goals Against Per Game for a more comprehensive stat.
- Other Team's Shorthanded Goals Against Per Game (OTSHGA): Measures how many shorthanded goals a team allows per game. Can be combined with HPPG for a more meaningful composite stat. Also implicitly considers other team's penalty minutes, penalty kill percentage, etc.
- Time on Ice → Covered by GPG.
- Shots Per Game → Covered by GPG.
- Shot Percentage → Covered by GPG.
- Other Team's Penalty Minutes → Covered by OTSHGA.