sporacle
in developmentNFL decision service for the 2026 season. Calibrated win likelihood and fantasy projections served as static artifacts. The claim is calibration, not beating the market.
Python · DuckDB · LightGBM · Astro · Cloudflare Workers
Sporacle is an NFL decision service for the 2026 season. It answers two kinds of question. For fantasy players: weekly rankings for QB, RB, WR and TE, each with a projected stat line, a p20 to p80 floor and ceiling band, opponent context, injury designation, and a cached paragraph on why the rank is what it is. All three scoring formats come off one projected stat line, with Half-PPR as the default. For anyone: a calibrated win likelihood and projected score for every game, framed as research rather than as a pick.
Kickers and DST are absent on purpose. DST was built, tested and rejected, and the experiment is logged as E-7. An empty slot is more honest than a placeholder.
The distinguishing claim is calibration, not sharpness. The project does not try to beat the betting market, and market data is banned both as a product surface and as a model input. No spreads, no totals, no odds display, no picks language, permanently out of scope. Closing lines exist in the repo only as an internal yardstick, in their own market_lines table, and a name-token scan called assert_no_market_features runs in the publish path so one cannot arrive through a join. A public track record page scores every published call against the outcome, misses included and uncorrected. It ships at v1 rather than later, because it is the trust mechanism the rest of the product rests on.
There are two layers. The pipeline layer is files and Python: nflverse ships parquet, DuckDB reads it in process for feature engineering and training, and everything is versioned as files on R2. No server, no database.
Engine 1 scores teams. It is a LightGBM quantile ladder over the game margin, nine quantiles, blended roughly 0.64 boosted, 0.16 linear and 0.20 bagged. Win probability is read off that single fitted distribution at zero rather than modelled as a separate classifier, which measured decisively better: Brier 0.2378 against 0.2701. Total is modelled separately and projected scores are recovered from margin and total. It trains from 2020 forward.
Engine 2 projects players, designed as usage and then efficiency with a quantile band on the output. The deployed version violates its own prohibition P3: it fits a points ladder directly, while the usage heads exist but do not feed the projection call. That gap is written down in the engine reference rather than papered over. Engine 1’s unit strengths are Engine 2’s matchup features, and a frozen calibration and band layer sits on top of both.
The serving layer is static first. Cadence runs publish versioned JSON artifact sets to R2, validated against JSON Schema before anything is written. Astro builds the whole site against those artifacts as static output on Cloudflare Pages. Rankings, game cards and the track record are prerendered. Live win probability is the one dynamic surface.
A Cloudflare Worker wakes a container on cron. The full run goes Wednesday at 03:30 UTC: ingest the completed week, refresh the engines, generate everything, run the narrative batch, publish, then trigger the Pages build. Refresh runs fill a six hour grid through the rest of the week. Runs are idempotent per season, week and kind, artifact sets are versioned by run id, and rollback is a repoint.
The live path ticks every minute, gated so the actual pull happens every minute inside a game window and every ten minutes otherwise. On a confirmed final it calls cadence over a service binding to start a reprice: the score patches into the schedules frame immediately, team ratings move, and every game that has not kicked off reprices within about a minute. Play by play features still lag on the ordinary ingest cadence, and I state that boundary rather than blur it. A game’s own number freezes at its own kickoff. Inside a game window a second container polls at 45 seconds, and the Worker fans out over WebSocket from a Durable Object that pulls rather than being pushed to, so no public request ever has a write path.
Every number that reaches a published surface carries a source from a closed enum: model, prior, imputed, mock or stale. The external accessor raises rather than returning a value. The rule is stronger than labelling your fallbacks: no fallback may substitute an external source’s number under Sporacle’s label, ever.
Gate 1 runs lint and format, pytest, lock and requirements checks, astro check, site unit tests, a real build followed by assertions against the built output, worker tests, JSON contract validation, and three consistency scans, one of which fails the build on any em or en dash. Gate 2 is pull request only and runs Playwright, axe, Lighthouse, a link check and an asset budget against a real build.
Some things are deliberately absent, each with a documented trigger if reality argues otherwise: Neo4j, Qdrant, Postgres, Supabase, Redis, Vercel, AWS, accounts and auth, on demand LLM calls, and any always on backend. XGBoost and CatBoost are installed for research and never enter the deploy image. One smaller detail worth keeping: ESPN’s main API started returning 403 to Cloudflare egress, so the live layer moved to their CDN endpoint instead.