A pre-game NFL prediction system that produces calibrated Win Probability (WP), Against the Spread (ATS), and Over/Under (O/U) forecasts for every weekly matchup. Models are trained strictly walk-forward, blended with the market in the mathematically appropriate space (log-odds for probabilities, point space for spreads and totals), and served from a read-only DuckDB cache by a FastAPI + HTMX + Tailwind v4 web app.
The project is both a personal decision-support tool and a portfolio piece. v1.0 MVP shipped on 2026-03-28, v2.0 (accuracy, automation, and polish) shipped on 2026-05-26, and v2.1 "Trust & Reproducibility" (documentation, audit, and diagnosis -- not new features) shipped on 2026-06-01. The current milestone, v3.0 "Accuracy & Profitability" (gate hardening, gated re-fits, O/U monetization, new signals, productization), is in progress.
What it is. A Friday-snapshot prediction engine for NFL regular-season and playoff games. Every Friday at 18:00 ET, the system ingests the latest games, odds, and weather; rebuilds the feature matrices; runs three target-specific models; blends those outputs with the closing-ish market snapshot; and refreshes a denormalized web cache that the dashboard reads from.
Who it serves. A single analyst (me). Predictions are informational only, not wagering automation; sizing and bet selection are surfaced as suggestions, not actions.
What problem it solves. NFL markets are efficient but not perfect, and the signal lives in carefully constructed, temporally-safe features combined with disciplined market blending. The system exists to make that edge-hunting reproducible, auditable, and honest about where it fails.
Why it is technically interesting.
- An end-to-end data lakehouse (Bronze / Silver / Gold Parquet + DuckDB) with Pydantic v2 quality gates at each layer transition.
- Temporal-safety is enforced at three levels: a runtime-checkable
FeatureBuilderProtocol with a mandatoryas_of_datetimetime-fence, aLeakageGatethat scans the combined feature matrix for post-game keywords, and a walk-forward splitter that hard-fails if train / validation / holdout seasons overlap. - The API layer and the ML layer are architecturally quarantined. A pytest
AST-walking import guard (
tests/api/test_import_guard.py) fails CI ifapi/ever imports frommodels/,features/, orratings/. - Market blend weights are tuned strictly on pre-2018 seasons, so the 2021-2024 backtest window is never seen during weight selection (weights are tuned on pre-2018 seasons; even the 2018-2020 training window post-dates them) — a harder constraint than the backtest itself.
Shipped (v1.0 MVP, 2026-03-28). Data pipeline, feature engineering, model training, walk-forward backtest, market blending, and the web dashboard are all in production.
Shipped (v2.0 accuracy / automation / polish, 2026-05-26). Phases
11-18 are complete: Elo rebuild with snapshot-then-update, Optuna-driven
retraining infrastructure, DynamicBlendWeights (week-of-season sigmoid
schedule, adopted per-target via gating), a unified Friday orchestrator
with retry and health gates, data-quality monitoring, the model-insights
page, the betting dashboard, and the season-tracking page.
Shipped (v2.1 Trust & Reproducibility, 2026-06-01). A trust/audit
milestone with NO new product features: converge on one canonical,
verified, documented pipeline; forensically audit the data and feature
engineering for correctness and leakage; honestly diagnose real model
accuracy; and verify, then explain, the automation. Phases 19-23 are
complete. See STATE-OF-SYSTEM.md for the trustworthy/fixed/deferred
summary and MODEL-DIAGNOSIS.md for the per-target accuracy verdict.
In progress (v3.0 Accuracy & Profitability). Phases 24-31: a significance-tested per-target deploy gate as a hard block (24), the first gated re-fit activation (25), the O/U CLV-to-ROI divergence diagnosis (26), the O/U monetization chain (27), new injury / snap / situational signals screened into gold (28), a budget-gated line-movement signal (29), and a second gated re-fit on the widened gold that measured each feature group's contribution before deploying anything (30). Productization (31) remains.
What is explicitly not in scope. Automated bet placement (legal complexity -- informational only), in-game / real-time predictions, player props, DFS optimization, multi-user access, native mobile apps. None of these exist in the codebase.
What happened in Phase 25 (the first gated activation). WP and ATS were
re-fit on the canonical Elo gold and passed the per-target non-regression
deploy gate (ATS via one documented fix-cycle), so they were promoted
through the gate (D25-18). O/U RETAINED the v1.0 pre-Elo model: its re-fit
failed the gate and was honestly refused (D25-14) because the v1.0 O/U
carries a stronger line-CLV edge the non-regression floor protects. The
v2.0 retrain that originally failed gating (D-17) is the history that
motivated the hardened gate; the Phase-25 activation is recorded in
ACTIVATION-READOUT.md (per-target CLV before/after + the deployed/retained
2x2).
What serves production now (post Phase-30 gated re-fit). Phase 30 rebuilt gold in four separately-attributed rungs, measured each new feature group's contribution against a rule frozen before any number existed, then ran the same per-target gate again:
| Target | Serving | Phase-30 gate outcome |
|---|---|---|
| WP | wp_20260824_113325 |
PASSED -- promoted (the phase's one production change) |
| ATS | ats_20260605_220128 |
REFUSED -- the Phase-25 re-fit is RETAINED |
| O/U | ou_20260326_163930 |
REFUSED -- the v1.0 pre-Elo model is RETAINED |
| blend | blend_dynamic_20260606_020635 |
not gated here -- unchanged |
The dynamic blend (D-19) is live for all three targets. Two refusals out of
three is the gate working as designed, in the same D25-14 lineage: both
candidates measured worse than what was already serving, and the frozen gate
said so before either could ship. A refusal is a RESULT, and the numbers
behind both refusals are published in the same shape as the promotion's. On
feature groups: snap and situational were KEPT and injury was DROPPED
after measurably hurting a target -- these were screened in Phase 28 and only
BINDINGLY ruled on in Phase 30, so a group reaching gold is not a group that
was deployed. The full record, including what the phase deliberately left
open, is GATED-REFIT-READOUT.md. See also "Current Limitations" and
MODEL-DIAGNOSIS.md, the frozen v2.1 diagnosis that recommended the first
gated re-fit.
- Win Probability (WP) — calibrated home-team win probability from a
LogisticRegression+StandardScaler+PlattCalibratorpipeline. Calibration is fit on the HP-validation fold, never on training data. - Against the Spread (ATS) — predicted home margin from an
XGBRegressor, converted to cover probabilities via an empiricalResidualDistributionConverter. - Over/Under (O/U) — predicted total points from an
XGBRegressor, converted to over/under probabilities via aTotalDistributionConverter.
- Bronze — append-only timestamped Parquet snapshots from every ingestion.
- Silver — cleaned, schema-validated, latest-wins tables, Hive-partitioned
by
season=YYYY/andsnapshot_ts=…(URL-encoded ET ISO-8601), so the Friday 18:00 ET snapshot is a first-class storage attribute. - Gold — one feature matrix per target:
features_wp.parquet,features_ats.parquet,features_ou.parquet. - Pydantic v2 quality gates at every layer boundary. One bad row fails the whole batch (intentional — silent row-skipping causes downstream corruption that is harder to debug than a hard failure).
Elo, rolling team form (EPA / success / pace / red zone / third-down),
market anchors (opening + Friday-snapshot lines; closing lines never read
into features), weather (indoor games zeroed), contextual (rest, travel,
divisional, surface mismatch), composite QB quality
(0.7·z(rolling_qb_epa) + 0.3·z(rolling_cpoe)), and opponent-adjusted
EPA. Normalization is expanding-window grouped by season with
min_periods=4 and prior-season bootstrap, explicitly replacing the
leakier within-season Z-score approach.
Three independent layers: features/protocol.py::FeatureBuilder
(@runtime_checkable Protocol with mandatory as_of_datetime);
features/validation.py::LeakageGate (per-builder time-fence,
combined-matrix keyword scan, Elo chronological ordering — violations
raise LeakageViolation and emit a JSON diagnostic); and
models/temporal.py::TemporalSplitConfig.validate() which raises if
train / HP-validation / holdout seasons overlap or violate ordering.
WP blended in log-odds space via scipy.special.logit / expit;
ATS / O/U blended linearly in point space. Weights tuned strictly on
pre-2018 seasons (TUNING_SEASONS in models/blending_data.py) —
temporally disjoint from the 2021-2024 backtest window.
DynamicBlendWeights (Phase 13) adds a per-target sigmoid schedule in
week-of-season with per-target gating; it is live for all three targets
(blend_dynamic_20260606_020635). Phase 30 re-ran the blend comparison
against the newly serving models, in a throwaway copy of the artifacts
tree, and recorded that the gating outcome would now prefer static for WP
and ATS -- by margins of 1.3e-4 and 2.8e-5, which reads as
indistinguishable rather than harmful. That finding was recorded and NOT
acted on; changing the blend is a separate gated decision.
Expanding-window walk-forward over 2021-2024, per-season retraining.
backtest/metrics.py::brier_decomposition implements Murphy (1973)
reliability / resolution / uncertainty by hand.
backtest/simulation.py::BettingSimulator runs flat-stake and
quarter-Kelly side by side, with SLIPPAGE_POINTS = 0.5 and -110 vig on
ATS / O/U. CLV (models/clv.py) — probability and line — is the
primary quality metric.
FastAPI monolith with a shared read-only DuckDB connection. Jinja2
templates rendered through jinja2-fragments's Jinja2Blocks so
full-page requests and HTMX fragment swaps share the same templates.
Six pages (This Week, Performance, Backtest, Insights,
Betting, Season), game detail drill-down (/games/{id}), HTMX
partials, CSV + JSON exports, and a /health endpoint.
+-----------------+ +------------------+ +-------------------+ +----------------+
| nflreadpy | | The Odds API | | Open-Meteo | | venues.json |
| (games, PBP) | | (spread/ML/OU) | | (hist. weather) | | (static) |
+--------+--------+ +---------+--------+ +---------+---------+ +-------+--------+
| | | |
v v v v
+---------------------------------------------------------------------------------------+
| data/bronze/ (append-only, timestamped parquet — raw source snapshots) |
+---------------------------------------------------------------------------------------+
| validate_bronze_to_silver (Pydantic v2; 1 bad row fails the batch)
v
+---------------------------------------------------------------------------------------+
| data/silver/ (Hive-partitioned: season=YYYY/, snapshot_ts=YYYY-MM-DDT18%3A00…) |
| tables: games, odds_snapshot, weather, contextual, elo_game_snapshots |
+---------------------------------------------------------------------------------------+
| FeatureBuilder Protocol + LeakageGate + expanding-window normalization
| + validate_silver_to_gold (numeric range guards)
v
+---------------------------------------------------------------------------------------+
| data/gold/ (features_wp.parquet, features_ats.parquet, features_ou.parquet) |
+---------------------------------------------------------------------------------------+
|
v
+---------------------------------------------------------------------------------------+
| Model training (walk-forward; Optuna TPE + Hyperband; SQLite studies) |
| WP : LogReg + StandardScaler + Platt scaling (calibrated on HP-val fold) |
| ATS : XGBRegressor(margin) -> ResidualDistributionConverter -> cover prob. |
| O/U : XGBRegressor(total) -> TotalDistributionConverter -> over/under prob. |
| Artifacts: artifacts/{target}_{UTCtimestamp}/ + artifacts/latest.json manifest |
+---------------------------------------------------------------------------------------+
|
v
+---------------------------------------------------------------------------------------+
| Market blending (models/blending.py) |
| WP : log-odds interpolation | ATS/O/U : linear in point space |
| Weights tuned strictly on pre-2018 seasons (TUNING_SEASONS) |
+---------------------------------------------------------------------------------------+
|
v [---- UIAP-01 ARCHITECTURAL BOUNDARY ----]
+---------------------------------------------------------------------------------------+
| scripts/populate_cache.py -> data/web_cache.duckdb (read-only; ~6 MB) |
| Pre-rendered Plotly HTML, prediction rows, backtest rows, game context. |
+---------------------------------------------------------------------------------------+
| api/ MUST NOT import models/, features/, ratings/ (enforced by test)
v
+---------------------------------------------------------------------------------------+
| FastAPI (api/main.py lifespan: shared read-only DuckDB; single-worker envelope) |
| Pages: /, /performance, /backtest, /insights, /betting, /season, /games/{id} |
| HTMX: /fragments/games, /fragments/performance |
| Exports: /api/export/csv, /api/export/json |
| Health: /health |
+---------------------------------------------------------------------------------------+
|
v
+---------------------------------------------------------------------------------------+
| Web UI (Jinja2Blocks + HTMX 2.0.4 + Tailwind v4 + Plotly) |
+---------------------------------------------------------------------------------------+
The Friday 18:00 ET snapshot is both computed (utils/date_utils.py::get_snapshot_time)
and stored as a directory partition under data/silver/, which is why
any prediction is reproducible given the same input snapshot.
+-------------------------------------------------------------+
| TemporalSplitConfig |
| train seasons < HP-validation season < holdout |
| (validate() raises on overlap or ordering violation) |
+------+----------------------+-------------------------------+
| |
v v
+--------------+ +------------------+
| train set | | HP-val set |
+--------------+ +------------------+
| |
+----------+-----------+
|
v
+---------------------------------------+
| OptunaTuner (TPE + Hyperband) |
| resumable SQLite studies: |
| data/optuna/wp_tuning_v1.db |
| data/optuna/ats_tuning_v1.db |
| data/optuna/ou_tuning_v1.db |
+-------------------+-------------------+
|
v
+-------------+-------------+
| WalkForwardSplitter |
| expanding-window over |
| the holdout horizon |
+---+--------+--------+-----+
| | |
v v v
+---------+ +---------+ +---------+
| WP | | ATS | | O/U |
| LogReg | | XGB | | XGB |
| + Iso | | +Resid | | +Total |
+----+----+ +----+----+ +----+----+
| | |
v v v
+-----------------------------------+
| Market blending |
| WP : log-odds interpolation |
| ATS : linear, point space |
| O/U : linear, point space |
| Weights tuned on pre-2018 only |
+-----------------+-----------------+
|
v
+-----------------------------------+
| artifacts/{target}_{ts}/ |
| model.pkl |
| metadata.json |
| feature_list.json |
| calibrator.pkl (WP only) |
| {target}_params.json |
| artifacts/latest.json (manifest) |
+-----------------------------------+
models/prediction_pipeline.py imports only load_model_artifact — no
trainer classes on the prediction path. The manifest at artifacts/latest.json
points at the production versions. After the Phase-25 gated activation those were
wp_20260605_215552 (re-fit, activated), ats_20260605_220128 (re-fit, activated
via fix-cycle), ou_20260326_163930 (v1.0, retained -- gate refused the re-fit),
and blend_dynamic_20260606_020635; that record is ACTIVATION-READOUT.md.
After the Phase-30 gated re-fit exactly one key moved: WP is now
wp_20260824_113325. ATS, O/U and the blend pointer are unchanged, because the
Phase-30 ATS and O/U candidates were REFUSED by the gate and their incumbents
retained. The per-target before/after and both pre-swap mappings are in
ACTIVATION-READOUT.md and GATED-REFIT-READOUT.md.
| Layer | Choice | Version |
|---|---|---|
| Language | Python | >=3.12 (runtime 3.13) |
| Package / env | uv | uv.lock |
| Lint / format | Ruff | >=0.9 |
| Type check | Pyright | >=1.1.390, typeCheckingMode = "standard" |
| Dataframes | pandas | >=2.2, <3 |
| Analytics DB | DuckDB | >=1.5, <2 |
| Columnar IO | pyarrow | >=18.0 |
| WP model | scikit-learn LogisticRegression + Platt-scaled calibration |
>=1.8, <2 |
| ATS / O/U model | XGBoost XGBRegressor |
>=3.2, <4 |
| Hyperparameter tuning | Optuna (TPE + Hyperband, SQLite studies) | ==4.8.0 (pinned exact) |
| Web framework | FastAPI | >=0.115, <1 |
| ASGI server | uvicorn (single-worker envelope) | uvicorn >=0.34, <1 |
| Templating | Jinja2 + jinja2-fragments (Jinja2Blocks) |
jinja2-fragments>=1.11 |
| Frontend interactivity | HTMX 2.0.4 (CDN) | — |
| Charts | Plotly (Python pre-render + JS CDN) | plotly>=6.0, <7 |
| Styling | Tailwind CSS v4 (vendored CLI at tools/tailwindcss.exe) |
— |
| Data sources | nflreadpy, The Odds API, Open-Meteo | nflreadpy>=0.1.5 |
| HTTP client / retries | httpx + tenacity | httpx>=0.28, tenacity>=9.0 |
| Config | Pydantic v2 + pydantic-settings + YAML | pydantic>=2.10 |
| Structured logging | structlog | >=24.4, <25 |
| In-process cache | cachetools.TTLCache |
>=7.0.5 |
| Tests | pytest + pytest-asyncio + pytest-cov + hypothesis + beautifulsoup4 | dev deps |
Pinning Optuna to an exact version (==4.8.0) is deliberate: study state
persists to SQLite across runs and minor Optuna changes have historically
altered search behaviour enough to drift results.
| Layer | Location | Shape | Semantics |
|---|---|---|---|
| Bronze | data/bronze/ |
Parquet | Append-only timestamped raw snapshots |
| Silver | data/silver/ |
Parquet (Hive-partitioned) | Schema-validated, latest-wins upsert |
| Gold | data/gold/ |
Parquet (one file per target) | Feature matrices ready for training -- 194 / 195 / 194 columns (wp / ats / ou) over 6,499 rows spanning 2002-2025 after the Phase-30 rebuild |
| Web cache | data/web_cache.duckdb |
DuckDB (~6 MB) | Read-only, API-facing |
| Analytics DB | data/nfl_predictions.duckdb |
DuckDB (~37 MB) | Ad-hoc SQL and notebooks |
The two DuckDB files are deliberately separate. The web cache contains
only what the API needs and is rebuilt by scripts/populate_cache.py;
the analytics DB is for exploration and never touches the request path.
| Schema | Role | Notable fields |
|---|---|---|
GameSchema |
Silver-layer games | game_id (e.g. 2024_W06_KC@BUF), season (2000-2030), week (1-22), kickoff_et, venue_roof: VenueRoof, result: GameResult |
OddsSchema |
Silver-layer odds snapshot | per-book spread / total / moneylines + snapshot_ts |
WeatherSchema |
Silver-layer weather | Open-Meteo shape + wind_direction float + NaN→None coercion |
VenueRoof (StrEnum) |
indoor / outdoor / retractable |
nflverse "dome"/"closed" → indoor, "outdoors" → outdoor, "open" → retractable |
GameResult (IntEnum) |
-1 away win, 0 tie, 1 home win |
Full metadata for all 32 teams plus normalize_team_abbreviation(abbr),
which hard-fails on unknown abbreviations with a closest-match suggestion
via difflib.get_close_matches. LA is the Rams; LAC is the Chargers —
matching nflreadpy. No silent pass-through anywhere.
predictions, feature_importances, backtest_metrics,
backtest_predictions, game_context, chart_cache (pre-rendered
Plotly HTML), and cache_meta (with last_updated, prediction_count).
Temporal correctness is the whole project. Three independent systems cooperate:
FeatureBuilderProtocol. Every builder infeatures/satisfies a@runtime_checkableProtocol that requires anas_of_datetimeparameter. A builder that forgets the parameter fails conformance checks; one that reads beyond it fails the next layer.LeakageGate. Three guards: per-buildercheck_time_fence, combined-matrixvalidate_combined_matrix(keyword scan for post-game features),check_elo_ordering(chronological within each season). Violations raiseLeakageViolationand write a JSON diagnostic tooutputs/diagnostics/leakage_<timestamp>.json.- Walk-forward splits.
models/temporal.py::TemporalSplitConfigvalidates thattrain_seasons < hp_val_season < holdout_seasonswith no overlap;WalkForwardSplitterdoes expanding-window splits within the holdout. No random CV anywhere.
models/blending.py blends WP in log-odds space (scipy.special.logit /
expit) and ATS / O/U linearly in point space. The non-obvious decision is
where weights are tuned: strictly pre-2018 seasons, stored as
TUNING_SEASONS in models/blending_data.py. The 2021-2024 backtest
window is therefore temporally disjoint from the weight-tuning window —
a stricter guarantee than the backtest alone. DynamicBlendWeights
extends this with a per-target sigmoid schedule in week-of-season and
per-target gating that falls back to static weights where dynamic does
not empirically beat static.
The api/ package must never import from models/, features/, or
ratings/. Enforced by tests/api/test_import_guard.py, which walks
every Python file under api/ as an AST and flags imports —
including function-scoped lazy imports — from any forbidden package.
The test fails CI the moment the boundary is violated, preserving the
API's clean startup envelope (milliseconds, no heavy imports) as a hard
invariant.
pipeline/orchestrator.py::FridayPipeline runs 18 sequential steps
(8 data, 10 predictions) each wired through pipeline/steps.py with a
deferred-import adapter pattern — every step_*() imports its
script inside the function body, which avoids argparse collisions at
module load. Three gate concepts are deliberately kept apart:
StalenessGate (season + data freshness, bypassed by --force),
PipelineHealthChecker (environment integrity, preflight + postrun,
never bypassed, advisory under --force), and PipelineAlertManager
(exactly one alert per outcome). Retry is scoped via tenacity to
transient exception types only (ConnectionError, TimeoutError,
OSError, httpx.HTTPStatusError); the execution log at
logs/friday_pipeline.json is written atomically.
api/main.py::lifespan opens exactly one shared read-only DuckDB
connection in app.state.db_conn. api/dependencies.py::get_db runs a
SELECT 1 health check per request and atomically reconnects under a
threading.RLock on failure. api/services.py wraps hot-path queries
in a module-level cachetools.TTLCache(maxsize=128, ttl=300) with
deep-copy on read and write plus an explicit RLock. This all assumes
single-worker uvicorn (--workers 1). The module docstring says so
plainly; running multiple workers would invalidate the shared-connection
and shared-cache invariants -- a deliberate concurrency envelope, not a
bug.
nfl-predict/
api/ # FastAPI serving HTML + JSON from the DuckDB web cache
main.py # Lifespan: shared read-only DuckDB connection
dependencies.py # get_db() DI, RLock reconnect, Jinja2Blocks loader
services.py # DataService: TTLCache(maxsize=128, ttl=300) + RLock
cache.py # CACHE_SCHEMA + populate_cache() — builds web_cache.duckdb
charts/ # Plotly chart generators (pure render layer)
routes/{pages,fragments,api_export,health}.py # HTML, HTMX, CSV/JSON, /health
backtest/ # Walk-forward backtest + reporting + CLV
engine.py, walkforward.py # Orchestrator + SeasonSplit + ordering guards
metrics.py, simulation.py # Brier decomposition; flat + quarter-Kelly
clv_tracking.py, report.py, tune.py
conf/
settings.py, config.yaml # Pydantic BaseSettings + YAML config
data/
storage.py # DuckDB + Parquet IO; tz normalization at write boundaries
schemas.py # Pydantic v2 — GameSchema, OddsSchema, WeatherSchema
quality_gates.py # validate_bronze_to_silver / validate_silver_to_gold
bronze/, silver/, gold/ # Layered lakehouse
optuna/ # SQLite stores for resumable Optuna studies
baselines/{v1.0,v2.0}/ # Pinned backtests for A/B regression
venues.json # Static venue metadata (483 lines)
nfl_predictions.duckdb # Analytics DB (~37 MB)
web_cache.duckdb # Read-only API cache (~6 MB)
features/ # All builders satisfy FeatureBuilder Protocol
protocol.py # @runtime_checkable Protocol with as_of_datetime
validation.py # LeakageGate (hard-fail) + FeatureValidator (report-only)
normalization.py # Expanding-window Z-score + prior-season bootstrap
elo_features.py, team_form.py, weather.py, contextual.py,
market_anchors.py, opponent_adj.py, qb_tracking.py
models/
trainers/ # v2.0 Optuna-driven trainers (base, wp, ats, ou)
temporal.py, tuning.py # WalkForwardSplitter + OptunaTuner (TPE + Hyperband)
calibrate.py # Platt + isotonic calibrators, ECE (WP trainer selects Platt)
blending.py # BlendWeights + DynamicBlendWeights + MarketBlender
blending_data.py # TUNING_SEASONS (pre-2018) — strict temporal isolation
clv.py # Probability CLV + line CLV (primary quality metric)
prediction_pipeline.py # Artifact-based loading; no trainer imports
artifacts.py, train.py
pipeline/ # v2.0 Friday orchestrator (Phase 14)
orchestrator.py # FridayPipeline: 18 steps with retry + logging
steps.py # StepDefinitions with deferred imports
staleness.py, health.py, execution_log.py, alert.py
ratings/elo.py # FiveThirtyEight-style Elo: MoV K-factor, per-season HFA,
# divisional factor 0.54, snapshot-then-update
scripts/ # CLI entry points for pipeline steps + operations
friday_pipeline.py, ingest_{games,odds,weather}.py,
build_*.py, populate_cache.py, validate_*.py
tests/
unit/ # trainers, builders, leakage, Elo, quality gates, doc guards
integration/ # end-to-end pipeline, idempotency, promote/rollback, smoke tests
api/ # pages, fragments, exports, health, UIAP-01 import guard
phase30_state.py # the tracked home for Phase-30 constants (see the readout)
utils/ # Cross-cutting
team_data.py # 32 teams; normalize_team_abbreviation (hard-fail)
date_utils.py # get_snapshot_time("Friday 18:00") + tz helpers
logging_config.py # structlog with sensitive-data filter + request IDs
probability_utils.py, kelly_criterion.py
web/ # Frontend: Jinja2 + Tailwind v4 + HTMX
templates/ # base.html + pages/ + components/
static/input.css # Tailwind v4 source with @theme { NFL palette }
deployment/ # Windows Task Scheduler setup (scheduling only)
setup_scheduling.py # Registers the Friday task from the committed XML
windows_scheduler.xml # Task definition (Fri 18:00 local = 6 PM ET)
README.md # Scheduling notes (Phase 19 trimmed)
tools/tailwindcss.exe # Vendored Tailwind v4 CLI binary
Makefile # Thin 9-target wrapper over PIPELINE.md (train, backtest,
# backtest-blend, predict, build-cache, serve,
# friday-production, test, lint)
pyproject.toml # Dependencies, Ruff, Pyright, pytest, coverage
File counts below are a point-in-time reading taken 2026-08-24, at the close of Phase 30; they are not pinned by a test, because a guard on a growing count turns red on every new test file for no correctness reason.
| Suite | File count | What it covers |
|---|---|---|
tests/unit/ |
95 | Trainers, feature builders, leakage gate, Elo correctness + no-leakage, quality gates, temporal splits, CLV, static + dynamic blending, Optuna tuning, timezone handling, pipeline health + orchestrator + staleness + alerts + execution log, betting simulation, QB tracking, opponent adjustment, backtest engine + metrics + report, the feature-group gate, and the committed content guards for every repo-root document |
tests/integration/ |
37 | End-to-end pipeline, backtest comparison + report, data completeness, Elo convergence, idempotency, lift validation, nflreadpy + Open-Meteo smoke tests, prediction pipeline, scheduling setup, training pipeline, the promotion/rollback paths, and the Phase-30 rebuild controls |
tests/api/ |
14 | Pages, fragments, exports, cache headers, caching, connection management, error responses, health endpoint, UIAP-01 import guard |
The full suite runs green with 0 failed. The passing count is not quoted
here, because it moves every time a guard is added and a stale count in the
front door is exactly the drift these guards exist to prevent. The number that
IS worth reading is the 7 xfails: those are deliberate quarantines --
published anchors awaiting a re-ratification Phase 30 did not perform -- and
that count is the mechanical proof those seven items are still open, not a
count of broken tests. See GATED-REFIT-READOUT.md for what each one is
waiting on.
Property-based testing with Hypothesis is used in one file today
(tests/unit/test_market_blending.py). An earlier version of this README
claimed seven; that count did not survive the suite's growth and is
corrected here rather than carried. Run the full suite with make test
(= uv run pytest tests/unit tests/integration tests/api -q); for a faster
loop, uv run pytest tests/unit -q.
What actually serves production today.
- FastAPI app under a single-worker uvicorn run locally
(
uv run uvicorn api.main:app --host 0.0.0.0 --port 8000). The shared read-only DuckDB connection and module-levelTTLCacherequire--workers 1(see the API concurrency envelope above). - DuckDB on local disk -- one read-only connection shared by the app
(
data/web_cache.duckdb). - The only automation is the Windows Task Scheduler Friday run that
triggers the orchestrator (
scripts/friday_pipeline.py); seeAUTOMATION.mdfor what it does anddeployment/for the task setup.
Hosted deployment is deliberately out of scope for now. An earlier
exploratory Docker / Nginx / Gunicorn / docker-compose stack (with
optional Redis, Postgres, Prometheus, Grafana, and ELK extras) was
deleted in Phase 19 -- it overstated operational maturity and was
not part of the real runtime. Packaging the app for a hosted server is
a planned future milestone (see .planning/PROJECT.md Out-of-Scope);
the architecture is kept deployment-friendly so that work stays small.
- O/U still serves the v1.0 model, refused by the gate TWICE.
In Phase 25, WP and ATS were re-fit on the canonical Elo gold and
promoted through the per-target non-regression deploy gate (ATS via one
documented fix-cycle); O/U's re-fit FAILED the gate and was retained on
the v1.0 pre-Elo model -- a more-accurate O/U regressor predicts totals
closer to the market, shrinking its line-CLV below the v1.0 edge the
non-regression floor protects (honest refusal, D25-14). Phase 30 re-ran
the gate on the rebuilt, widened gold and reached the same answer in the
same shape: the O/U candidate's point error IMPROVED (MAE -1.23) while
its line-CLV got substantially WORSE (-0.487 paired, p = 9.2e-12), so it
was refused again. The Phase-30 ATS candidate was refused too (-0.213
paired, p = 0.0375), leaving the Phase-25 ATS artifact serving. Only WP
moved. The single pre-registered fix-cycle lever went UNSPENT for both
failing targets, and not by oversight -- both candidates were already
trained on their incumbent's exact selection window, which is precisely
what the lever would have done. The dynamic blend (D-19) is live for all
three targets. The earlier v2.0 retrain that failed gating on every
target (D-17) is the history that motivated the hardened gate. See
GATED-REFIT-READOUT.mdfor the Phase-30 record (including the numbers behind both refusals),ACTIVATION-READOUT.mdfor the Phase-25 activation, andMODEL-DIAGNOSIS.md(DIAG-05) /STATE-OF-SYSTEM.mdfor the frozen v2.1 diagnosis that recommended the first re-fit. - Single-worker concurrency envelope. The shared DuckDB connection
and module-level
TTLCacherequire--workers 1. Running multiple workers would invalidate those invariants. This is documented in theapi/main.pymodule docstring and is a deliberate envelope, not an accidental limit. - WP ships with a negative absolute CLV, and that is not a typo.
The deploy gate runs in
non_regressionmode: it asks whether a candidate is not WORSE than the incumbent, not whether it is positive in absolute terms. WP's pooled probability CLV after the Phase-30 promotion is -0.0380 -- an improvement of +0.0061 on the incumbent (p = 0.0142), and still negative. Removing closing-line-value leakage is not the same thing as having a market edge, and this project keeps those two bars apart deliberately rather than quoting the flattering one. The honest per-target profitability verdict is Phase 31 work; the O/U clean out-of-sample verdict in particular could NOT be discharged in Phase 30, because its holdout is still the partially burned 2023-2024 split. - Legacy trainer modules coexist with new ones.
models/train_wp.py,models/train_ats.py, andmodels/train_ou.pystay becauseprediction_pipeline.pystill importsResidualDistributionConverterandTotalDistributionConverterfrom them. A future refactor can move the converters out, but today both paths are present.
The operating shell is PowerShell on Windows 11; every command runs
through uv run, so nothing depends on an activated virtualenv. The
canonical run sequence lives in PIPELINE.md, and RUNBOOK.md is the
operator runbook (setup, each operation, how to tell whether a run
succeeded, troubleshooting, recovery).
uv sync # install everything (no pip, no venv dance)
Copy-Item .env.example .env # template; fill ODDS_API_KEY and anything else required
uv run python scripts/train_models.py --target all # train WP, ATS, O/U (Optuna)
uv run python scripts/run_backtest.py # walk-forward backtest 2021-2024
uv run python scripts/populate_cache.py # (re)build data/web_cache.duckdb
uv run uvicorn api.main:app --host 0.0.0.0 --port 8000 # FastAPI at http://localhost:8000The same steps are available as the thin Makefile targets make train,
make backtest, make build-cache, and make serve (see PIPELINE.md
for the full 8-stage sequence). Run the tests with make test (=
uv run pytest tests/unit tests/integration tests/api -q) or, for a
faster loop, uv run pytest tests/unit -q.
This project exists to answer a narrow question honestly: given a Friday 18:00 ET snapshot of games, odds, and weather, what are my best-guess WP / ATS / O/U forecasts, how much should I trust them, and how much of that trust is borrowed from the market? The answer is delivered by a lakehouse with hard-fail quality gates, a feature pipeline with three independent leakage checks, walk-forward-only model training with Optuna, a market blend tuned on temporally disjoint seasons, and a read-only web cache served behind an architecturally quarantined API. The product is intentionally modest in scope (one user, one tab set, one Friday snapshot per week) and correspondingly opinionated about correctness.
MIT