Skip to content

Repository files navigation

NBA Contract Value Dashboard

Every contracted NBA player's production plotted against what he is paid. A league-average price line is fit through the scatter; the largest residuals are the best and worst contracts in the league. Rebuilt nightly.

Every assumption is visible and toggleable. Valuation turns on a few arbitrary constants — what a win costs, what replacement level means, which contracts set market price — so they live in the UI, not in one confident number. Full derivation and worked example: METHODOLOGY.md.

The problem

No public tool answers "which NBA contracts are good value?" transparently. EPM and LEBRON are paywalled, RAPTOR is frozen (discontinued 2023), the rest is editorial. This is the free, reproducible alternative.

Reading the chart

2025-26 production against 2026-27 salary, both settled, cap $164,961,000. Scope is a 2026-27 contract plus 500 minutes and 20 games334 in, 59 excluded (live counts in meta.json); all percentiles use those 334.

AxisQuantityRange
XComposite score: blended WAR 40%, DARKO DPM 25%, WS/48 15%, TS% residual 10%, availability 10%. Toggles to raw blended WAR.0–100
Y2026-27 cap hit as a share of the $164,961,000 cap (default) or of the player's own team payroll.0–~40%

Y is a share, not dollars, so height reads as "how much of a roster this player consumes" across cap seasons. Sources: Basketball-Reference (stats, /contracts/), DARKO (DPM), Spotrac (structure), NBA CDN (portraits).

Two lines

LineFitnAnswersMoves with $/win?
MarketOLS cap_pct ~ composite, market-priced deals only — slope 0.00204, r² 0.241190what teams do payno, it is empirical
Model(blended WAR × $/win) / cap ~ composite — r² 0.795334what production should earnyes

Rookie-scale, minimum, and two-way deals are plotted but get no vote in the market fit — a schedule set their salary, not a negotiation. Residuals and leaderboard surplus measure against the market line; the gap between the lines is how far revealed pricing sits from the model.

Toggles

ToggleOptionsEffect
$/win$4.02M naive, $5.03M replacement-adjusted (default)surplus = blended WAR × $/win − cap hit. Moves the model line only.
Y denominatorleague cap (default), team payrollThe cap keeps one ruler under a league-wide regression. Team payroll inverts real comparisons: Curry ($62.6M) is paid $4.1M more than Davis ($58.5M) yet plots cheaper, purely because Golden State spent more on everyone else.

Team mode invalidates the ETL's cap-relative numbers, so the client refits (web/src/model/denominator.js): market line by OLS on the active axis, residuals recomputed, Y-tick dollar sub-labels dropped, model reference widened to a band — one dollar figure is thirty different shares. Player detail also shows team payroll and its rung on the CBA's four spending lines, derived as multiples of the live cap in web/src/model/payroll.js.

Views

ViewFor
Scatterthe whole league at once — shape, clusters, outliers
Leaderboardthe same data sorted. Rookie deals structurally dominate the top; see Known limitations
Team rollupsurplus by franchise. Player filters apply to surplus and WAR; payroll and apron state come from teams.json, unfiltered
Compareup to three players, with the WAR uncertainty band and league percentile bars

Requirements

Twelve questions from requirements refinement, answered verbatim. The ADRs are downstream of these.

#QuestionAnswerDrives
Q1EPM is paywalled. Plan for the headline metric?"ideally use both 1 and 2, focus on obtain raw data. we can always implement our own formula to calculate those scores/metrics."ADR-001
Q2Which production year against which salary year?"2025-26 stats vs 2026-27 salary"ADR-002
Q3How should salary data be sourced?"i just want it to be live data or at least be scraped periodically with a cron job… would rather take a hybrid approach"ADR-003
Q4What does "live" mean here?"Nightly scheduled refresh"ADR-004
Q5What stack?"Doesn't really what tech stack it is, I want the data points to be the headshot of each player."ADR-007
Q6How are composite weights decided?"You propose a defensible default"ADR-005
Q7$/win denominator and regression population?"Compute both, toggle in UI"ADR-006
Q8How deep should trade eligibility go?"Simple flags only"ADR-011
Q9~500 headshots will overlap badly. Handling?"Binned overview, faces on zoom"ADR-007
Q10Which players make the chart?"Under contract + minutes threshold"ADR-008
Q11Where does this run?"GitHub Actions ETL → static hosted site"ADR-009, ADR-010
Q12What else besides the scatter?"Ranked surplus-value leaderboard, Team-level rollup, Player detail / comparison view"ADR-012

Q1 makes this an ingestion pipeline with a swappable metric layer. Q5 made the stack a consequence of a rendering requirement. Q7 set the principle: contested assumptions become features.

Architecture decisions

DecisionWhy
001Ingest raw BBRef and DARKO data; compute every derived metric ourselves. No EPM dependency.EPM is paywalled and therefore non-reproducible; DARKO is free, nightly, and genuinely projective. A provider interface keeps EPM addable behind a flag.
002Score completed 2025-26 production against active 2026-27 salary.Answers "who is overpaid right now", and both inputs are settled. Players with a contract but no 2025-26 production become an explicit no_prior_season class, excluded from the fit rather than plotted at zero.
003Bulk salary from BBRef /contracts/; contract structure from Spotrac.BBRef covers the league in ~30 polite requests; Spotrac adds options, guarantees, and kickers. Spotrac is enrichment, so its outage degrades the build but never breaks it.
004A nightly GitHub Actions cron rebuilds and commits the dataset.Contracts move on trade timescales, stats on daily ones. Real-time is impossible under source rate limits and meaningless for season-long metrics.
005Percentile-weighted composite with every weight documented, plus a raw-WAR axis toggle.The blend is readable; the WAR axis makes the regression slope literally dollars-per-win. Readable or rigorous without forking the code.
006Expose both $/win denominators; default to replacement-adjusted ($5.03M, not $4.02M).The naive cap / 41 assumes a replacement roster wins zero games. It wins roughly 8–15, so the naive figure inflates apparent overpay by about 20% league-wide.
007Dots when zoomed out, headshot icons on zoom-in. Superseded the original hex-bin overview.334 headshots collide into illegibility in one viewport, but hex bins read as a heatmap of nobody. Dots keep one mark per player at every zoom, bleaching to white as the portrait fades up inside them so surplus colour survives as a ring. deck.gl ScatterplotLayer + IconLayer on a data-derived zoom threshold.
008Require a 2026-27 contract plus 500 MP and 20 GP.Per-minute metrics are unstable at low sample; a 40-minute call-up produces a wild surplus that would own the outlier view and discredit the chart.
009Nightly Actions ETL commits data; a static frontend consumes it.Free, no server, reproducible from a clean checkout — viable only because ADR-010 removes the stats.nba.com dependency.
010nba_api only for stats.static.players, which is bundled local data and issues zero HTTP. All statistics from BBRef.stats.nba.com blacklists datacenter IPs and fingerprints TLS handshakes, which breaks any cloud-run ETL. The offline module still yields the NBA player IDs that headshot URLs need.
011Trade eligibility as simple flags: dates, no-trade clauses, recent-signing locks. No apron-aware salary matching.Full 2023-CBA matching means modeling every team's live payroll against apron rules, base year compensation, and the poison pill — a project the size of the dashboard. The team rollup already produces the payroll state a future engine would need.
012Three views beyond the scatter: leaderboard, team rollup, compare.The scatter shows shape; a sortable table answers "who exactly is #1"; the rollup shows which front offices extract the most value per dollar.
013Draw both an empirical market line and a model line.An empirical fit of observed salaries cannot depend on our $/win choice, because that constant appears nowhere in the data being fit. Residuals rank against the market line, so "underpaid" means underpaid versus what teams pay comparable players.
014Join DARKO on its published NBA ID column; normalized-name matching only as a fallback.An exact integer key beats fuzzy matching and sidesteps accents: 35 of 530 DARKO names differ from BBRef by diacritics alone (Nikola Jokic / Nikola Jokić). The fallback means a dropped ID column degrades rather than fails.
015Keep share-of-cap as the default Y denominator; add a team-payroll toggle that refits client-side.The cap is a limit, not a ceiling — Cleveland's books run to $226.0M, Brooklyn's to $150.8M. But a league-wide market line needs one ruler, and team payroll inverts real comparisons. Both fits and cap_pct_residual are cap-relative in the ETL, so team mode must refit rather than redraw.

Design patterns

PatternSubstance
Canonical identity spineAll sources join through one crosswalk (BBRef slug ↔ NBA player ID ↔ Spotrac slug), built before any other stage, so name-join failures on suffixes and accents exist in exactly one asserted-against place.
Source adapterEach source implements the same fetch() → DataFrame contract in etl/sources/, ranked essential vs enrichment: essential failure fails the build loudly, enrichment logs and degrades. Adding EPM is one adapter.
Calibrate before blendingWS, VORP, and DPM live on different scales; each is converted to WAR and calibrated to a common league total before averaging. Averaging uncalibrated metrics is the most common error in public contract-value work.
Precompute heavy, recompute lightThe ETL emits static artifacts; the client recomputes only what a toggle invalidates — a multiply for $/win, a full refit for the Y denominator.
Derive anything pegged to the capThe CBA defines minimums and exceptions as percentages of the cap, so a dollar literal is silently wrong the next season. This bit us: a hardcoded ROOKIE_MINIMUM_SALARY put an $85k error into every floored expected salary and pushed real veteran-minimum deals into the market regression. It is now SALARY_CAP × 0.8231%.
Orthogonal facts need orthogonal fieldsA contract's acquisition route and its price tier are independent — an extension can also be a max. Collapsing both into one enum let BBRef's "Extension" note overwrite every max deal and empty the Max category on live data. contract_type and salary_tier are now separate axes.

Known limitations

  1. Rookie-scale deals structurally dominate the best-value list. The CBA caps what those players earn, so an All-NBA rookie contract always shows huge surplus. Fitting on market-priced deals mitigates, not removes.
  2. Box-score metrics undervalue defense and off-ball gravity. DARKO's on-off component helps, not solves.
  3. One season is a noisy talent estimate — role, health, and teammate quality are all in there.
  4. Cap hit ≠ cash paid. Options, guarantees, incentives, kickers, and stretched dead money all diverge.
  5. The cap is not what teams spend. Tax payers spend well above it, so a cap-derived $/win understates the true market price of a win.
  6. The TS%-vs-usage residual is inert. The league fit of TS% ~ USG% is flat (slope −0.00032, r² 0.0013), so the residual tracks raw TS% at r = 0.999 — that 10% component is a recentred TS%. Detail.
  7. DARKO is counted twice. It carries 25% alone and is one of three estimates inside war_blended at 40%, so it drives ~38% of the score. Win Shares double-counts the same way; the weights overstate independence.

Reference data

Checked 2026-08-01.

FactValueSource
2026-27 salary cap$164,961,000pr.nba.com
2026-27 luxury tax$200,428,000pr.nba.com
2026-27 first / second apron$209,015,000 / $221,686,000pr.nba.com
2025-26 salary cap (prior-year context)$154,647,000pr.nba.com
DARKOfree public Google Sheet, nightlydarko.app
Sports-Reference rate limit20 req/min; up to 24h banbot policy
stats.nba.com from cloudblocked — IP blacklist + Akamai TLS fingerprintingnba_api#633
nba_api.stats.static.playersbundled local data, zero network callsdocs
Headshot CDNcdn.nba.com/headshots/nba/latest/1040x760/{id}.pngpublic, unauthenticated

Repository layout

etl/ sources/ adapters · transform/ (war, composite, valuation, contract_type)
crosswalk.py identity spine · ratelimit.py shared token bucket · build.py orchestrator
data/processed/ committed JSON artifacts the frontend reads (data/raw/ is cached scrapes, gitignored)
web/ Vite + React + deck.gl SPA — chart/ views/ model/ styles/ scripts/
tests/ pytest suite · .github/workflows/ ci.yml, nightly.yml

Deployment

Static site on Vercel, data rebuilt nightly by GitHub Actions. Vercel never runs Python. Committing data/processed/ is what makes the site reproducible from a clean checkout and buildable without credentials.

WorkflowTriggerNetworkDoes
ci.ymlpush to main, any PRnoneADR-010 guard, pytest, frontend build, asserts the site shipped real (non-mock) data
nightly.ymlcron 0 11 * * *, manualBBRef, DARKO, Spotrac, NBA CDNre-scrape, rebuild, commit data/processed, build, deploy

ci.yml never runs the scrapers — hitting BBRef on every push is slow and hostile under a 20 req/min limit. 11:00 UTC is ~06:00 ET, after BBRef ingests the previous night's box scores. vercel.json sits at the repo root so data/processed/ is in the build context; cache headers go per asset class (bundles a year, headshots a week, dataset JSON s-maxage=3600). The nightly deploy step is gated on secrets.VERCEL_TOKEN: with it, Actions runs vercel build + vercel deploy --prebuilt; without it the step no-ops, correct if you use Vercel's Git integration. Enable one, not both.

python -m etl.build --refresh --verify # rebuild data/processed/*.json
npm ci --prefix web
npm run link-data --prefix web # symlink data/processed into web/public/data
npm run dev --prefix web

npm run build runs web/scripts/stage-data.mjs, copying JSON and mirrored headshots into the gitignored web/public/data/ and leaving an existing valid symlink alone. Headshots must be same-origin — cdn.nba.com sends no CORS header and WebGL will not texture a cross-origin image — so a build without them uses placeholders.

About

Analyzing the best and worst contract in the NBA

Resources

Stars

1 star

Watchers

0 watching

Forks

Packages

Contributors

Languages