Ask who the best player on a hockey team is and you get an argument about points. Ask who the most valuable asset is and you get a different answer, because value in a salary-capped league is production you didn't pay full price for. The NHL GM Playbook is built around that second question. This post is the model in full: how box-score columns become goals, why the replacement-level subtraction is the step that separates WAR from a counting stat, how market value is estimated without a labelled dataset of fair contracts, and the anchor-check that keeps an undocumented data feed from silently publishing nonsense.
The cap is what makes value a different question from talent
In an uncapped league, the best player is the most valuable player and there is nothing more to say. A hard cap turns roster construction into a constrained optimisation: you are buying wins with a fixed budget, so the currency is not wins but wins per dollar above what you paid. Two players producing identically at $2M and $9M are not comparable assets, and the $7M difference is another top-six forward.
Everything below exists to turn a box score into that one number.
WAR from a box score
Proper WAR models use play-by-play, shot quality, zone entries and on-ice teammate adjustment. Public box scores give you goals, assists, shots, hits, blocks, takeaways, giveaways, plus-minus, penalty minutes and ice time. The model builds the best estimate those columns support and is explicit that it is an estimate. Every countable contribution is converted to goals:
offense = 0.72·goals + 0.45·assists + 0.02·shots
two_way = 0.045·takeaways + 0.030·blocks + 0.008·hits
− 0.045·giveaways + 0.06·plusMinus
contrib = offense + two_way − 0.02·PIM
Then the step that separates WAR from a counting stat: subtract what a replacement-level player would have produced in the same ice time, and convert to wins.
gar = contrib − replacement_per_60 · (toi_min · gp / 60)
replacement: 0.85 per 60 for forwards, 0.62 for defencemen
war = gar / 6.0 # ~6 goals per win
The ice-time term is what stops the model rewarding volume. A forward playing 22 minutes a night has to clear a much higher bar than one playing 11, because the alternative to him is not nothing — it is a replacement player absorbing those minutes. Two players with identical point totals and very different ice time get very different WAR, which is the correct and frequently unpopular answer:
| Player A | Player B | |
|---|---|---|
| points, 82 GP | 45 | 45 |
| ice time | 21.5 min | 13.0 min |
| replacement baseline | 25.0 goals | 15.1 goals |
| goals above replacement | lower | higher |
| who the box score says is better | a tie | |
The "6 goals per win" divisor is the standard league-level conversion — roughly the goal differential associated with one additional win, derivable from a Pythagorean fit on team seasons. It is a scale factor, so it changes no rankings; it exists so the output is in units a hockey person can argue with.
Market value, and the number the app is organised around
WAR says how good a player is. It says nothing about whether you would want the contract. So a second model estimates what that production commands on the open market:
forwards: 0.11·points + 6.0·max(0, ppg − 0.30) + 1.0
defence: 0.06·points + 0.24·max(0, toi − 15) + 1.2
× age multiplier: 1.00 (≤27) · 0.92 (≤30) · 0.80 (≤33) · 0.65 (older)
The max(0, ppg − 0.30) term is deliberately non-linear, and it is the piece that makes
the model behave like a market instead of like a spreadsheet. Below roughly a third of a point per
game, production is close to fungible — there are dozens of such players available every July and
nobody bids. Above it, teams are competing for a genuinely scarce good and the price per point rises
steeply:
Defence uses ice time rather than the scoring kink, because the market for defencemen prices trusted minutes far more than it prices points — the fourth-highest-paid defenceman in most seasons is not the fourth-highest-scoring one.
And then the number the whole application is organised around:
surplus = market_value − cap_hit
Contract projections
For pending free agents, next-contract AAV is projected from production, ice time and age, and term from the age band — seven years for a young player who has earned real money, two for someone past 31. Where CapWages publishes its own projection, that is used in preference; the internal model is the fallback for players it doesn't cover.
The age curve discounts at both ends, for different reasons, and conflating them is a common mistake. Under-24s are discounted because they lack leverage — restricted free agency and the bridge-deal convention suppress the price of production the team already controls. Over-33s are discounted because teams have finally learned what the back half of a long deal costs them. The first is a market structure; the second is a belief about decline. They happen to point the same direction and would come apart immediately if the CBA changed.
Deployment: lines, pairs, needs
The Team Lab builds a plausible lineup rather than an optimal one, and the distinction is worth being honest about. Forwards are ranked by a production/role score and filled centre-first down four lines; defence pairs balance handedness, taking the best available left shot and pairing him with the best available right shot:
// greedy, handedness-aware pairing — not an optimisation
const L = pool.filter(p => p.shoots === "L").sort(byScore);
const R = pool.filter(p => p.shoots === "R").sort(byScore);
for (let i = 0; i < 3; i++) pairs.push([L[i] || pool.pop(), R[i] || pool.pop()]);
A real optimiser would solve for line chemistry, matchup deployment and special-teams overlap jointly, and would need data this model does not have. Greedy-with-handedness produces a lineup a hockey person recognises, which is the actual requirement for a tool whose job is to start an argument rather than to end one.
Roster needs come from ranking each area by relative weakness and always surfacing the top one or two — even for a strong team. A tool that tells a contender "no needs" is useless; a GM's question is never "do I have a hole", it is "where is my next upgrade".
Trusting an undocumented feed
Cap hits and contract status come live from CapWages, whose public site JSON is an undocumented endpoint — no versioning, no deprecation policy, no reason for it to stay the same shape. So the fetcher validates itself against anchor players whose answers are known:
ANCHORS = {"Connor McDavid": 12.50, "Auston Matthews": 13.25, "Cale Makar": 9.00}
def validate(rows):
for name, expect in ANCHORS.items():
got = rows.get(name, {}).get("cap_hit")
if got is None or abs(got - expect) > 0.01:
raise FeedChanged(f"{name}: expected {expect}, got {got}")
return rows
# on failure: fall back to the checked-in contracts.csv and say so on the page,
# rather than publishing numbers the pipeline can no longer vouch for.
That last mechanism is the part I would reuse anywhere, in any domain. Scraping an undocumented source is a reasonable thing to do; publishing whatever it returns without checking is not. A handful of known values, asserted at runtime, converts a silent data corruption into a loud, specific fallback — and the failure mode you want from a scraper is a stale number you know about, not a fresh number you don't.
Three properties make an anchor good: you know the value independently of the feed, the value is stable over the period you care about, and it is drawn from the same code path as everything else. Three superstar cap hits satisfy all three for a season at a time.
Using it yourself
python3 modules/nhl-gm/nhl.py # pull rosters + stats, compute WAR/value → targets.json
python3 modules/nhl-gm/capwages.py # refresh cap hits, with the anchor check
# contracts.csv is the checked-in fallback the anchor check falls back to
The coefficient block at the top of nhl.py is meant to be edited — that is the point
of keeping it as named constants rather than burying it in the arithmetic. Three edits worth trying:
- Raise the replacement level from 0.85 to see how thin the middle of the league is. Nearly every third-liner's surplus collapses, which is a fair description of the actual market for third-liners.
- Move the market kink from 0.30 ppg to 0.40 and watch the second-line forwards re-price. Where you put that kink is the most consequential single judgement in the model.
- Zero the plus-minus coefficient and diff the rankings. The players who move most are the ones whose valuation was resting on the noisiest input, which is a useful list to have.
What it doesn't know
No zone starts, no quality of competition, no special-teams split, no injuries, no no-trade clauses, no retained salary, no draft capital, no term-weighting in the surplus figure. The trade analyzer compares surplus totals on each side and calls the winner, which is a reasonable first-order model of a trade and a poor model of a negotiation — real deals turn on the clauses this model cannot see.
And the coefficients are calibrated by judgement, not fitted to historical trades, because there is no labelled dataset of "fair" deals to fit against. That is an honest constraint rather than a shortcut: a model whose parameters cannot be fitted has to be defensible line by line instead, which is why every one of them is printed above rather than hidden behind a training script.
Related: the same problem where labels do exist, and what changes when they do — a forecast that grades itself.