# NFL Game Model: formula spec

**Files** (all in the same folder):

- `nfl_model.json`: the data.
- `model.js`: the reference implementation, an ES module with `predict(data, input)`, `checkVectors(data, vectors)` and `loadModel()`.
- `test_vectors.json`: inputs with the outputs the Python model (`core.py`) produced for them.

**Correctness check.** An implementation is correct when it reproduces every vector to these tolerances:

- Probabilities and EVs: within 0.5 percentage points.
- Margins, totals and edges: within 0.05 points.
- Fair moneylines and QB names: exactly equal.

**Paper testing only.** In the untouched 2024–26 backtest, closing lines beat this model on every measure, and every betting rule lost money at closing prices.

## 1. Pick the feature row

Input fields: `home`, `away`, `season`, `week` (optional), `neutral`, and optional `home_qb`, `away_qb`, `spread`, `total`, `home_ml`, `away_ml`.

1. **Scheduled game.** If `season` equals `data.season` (2026), take the rows in `data.games` where `home` and `away` match (and `week` matches, if given). If any match, use the last one. Its vectors were built from ratings as of kickoff. For future weeks, that means ratings through `data_through`.
2. **Synthesized matchup.** Otherwise, use `data.synth["HOME|AWAY"]`. This row is built from each team's current ratings, with these defaults:
   - rest 7 days for both teams
   - no injury report
   - weather unknown (the median is used outdoors)
   - travel = great-circle distance from the away stadium to the home stadium
   - division flag from the 2024–26 schedules
   - roof = the home team's last home game
3. **Neutral site.** Use `xn`/`tn` instead of `x`/`t`. A neutral site switches off home field, travel, time-zone and west-coast-early terms.
4. **QB.** Each row carries `qb_h`, `qb_a` (QB ratings: shrunk EPA per dropback) and the QB names.
   - Future games with no listed starter already use the team's latest starter (`data.last_starter`), rated as of now.
   - A QB override replaces the rating with `data.qbs[name].r`.
   - Then recompute `x[qb_d] = (qb_h − qb_a) × 10` and `t[qb_s] = (qb_h + qb_a) × 10`.

## 2. Margin, total, win probability

- Ridge models with standardised inputs: `y = b[0] + Σ b[i+1] × (x[i] − mu[i]) / sd[i]`.
  - Margin: `data.margin_model` (columns in `cols`), which gives the home team's expected margin, `margin`.
  - Total: `data.total_model`, which gives `total`.
- Win probability: `p_home = 1 / (1 + exp(−wp_slope × margin))`.
- Fair spread (home) = −margin. Fair total = total.
- Fair moneyline: if p ≥ 0.5, `−100p/(1−p)`; otherwise `100(1−p)/p`. Round half to even.

**Margin columns:**

- `hfa`: 1 − neutral
- `elo_d`: Elo difference / 25
- `pts_d`: points-for − points-against EWMA difference
- `qb_d`
- `cont_d`: roster continuity
- `newcoach_d`
- `travel_a`: thousands of miles × hfa
- `tz_a`: time-zone gap × hfa
- `west_early_a`
- `div`

**Total columns:**

- `lg_tot`
- `qb_s`
- `wind`, `gust`: observed at kickoff for past games; median outdoors and 0 indoors otherwise
- `pts_s`
- `pts_drive_s`, `start_yl_s`, `three_out_s`
- `pace_s`, `plays_s`, `drives_s`
- `inj_off_s`, `inj_def_s`

## 3. Cover and over probabilities (push-aware)

`data.hist_margin = [model_margin[], actual_margin[]]` holds 2018–26 out-of-sample predictions; `data.hist_total` is the same for totals.

Given the model value m and a line L:

1. Take the 400 historical rows with the smallest |hist_pred − m|. Order ties by row index, which is a stable sort.
2. Shift each row's actual result by `round_half_even(m − hist_pred)` points.
3. Count shifted > L, = L and < L, then divide by 400. This gives P(home cover), P(push), P(away cover). For totals it gives P(over), P(push), P(under).

The spread is the home team's expected margin as posted. Home −3.5 means `spread = 3.5`; this is nflverse `spread_line`.

- Spread edge = margin − spread.
- Total edge = total − line.
- EV at −110: `P(win) × 100/110 − P(lose)`. A push returns the stake.
- Lines on file (nflverse closing/consensus) are used when the user leaves an input blank.

## 4. Moneyline

- `dec(a)` = 1 + a/100 if a > 0, otherwise 1 + 100/|a|.
- No-vig home probability = (1/dh) / (1/dh + 1/da).
- EV(home) = p × (dh − 1) − (1 − p).

## Data and caveats

- Ratings are current through `data_through`. Coefficients were fit on 2013–2025.
- Weather for future games is unknown.
- Injury reports are not available for future weeks.
- Closing lines are never a model input.
- Refresh with `refresh_data.sh`.
