Skip to content
Back to guides
Model checks

Backtest a Sports Betting Model in Your Browser (Cornerstone)

Shark Snip Editorial 14 min read

Read the price, role, and market first

How to backtest a sports betting model in the browser with TensorFlow.js. Train, evaluate, and ship a model on your own device with no upload.
15 sections
Backtest a Sports Betting Model in Your Browser (Cornerstone) cover art

Every "no-code sports betting model" product I tested in 2025 and 2026 — Rithmm, BALLDONTLIE Lab, the half-dozen Patreon-gated GPT wrappers — does the actual training on a server farm somewhere. You hand them a CSV or a prompt, they hand you back a number. The weights are theirs. The features are theirs. If they go down, your model goes down. If they decide to start charging more, you have no leverage. This post is the opposite: backtest a real NFL spread model in TensorFlow.js, end to end, in your browser tab. No upload. No account. The trained weights live on your machine, exportable as a 40KB JSON file you can email to yourself.

I am going to walk through the exact loop we use to ship every model on Tinker: load a feature pack, define an architecture, train with a time-respecting split, evaluate against the closing line, then convert the model into a brick that scores live games on picks. Numbers and code paths are real — pulled from the model card we published when we shipped Mahomes-era spread brick v3.

Why a browser is a serious place to train a model

"Real" data scientists snicker at the idea of training in JavaScript. They are usually picturing a 2019 TF.js demo that classified cat photos at 4 FPS. The actual numbers for an NFL spread model in 2026 look different. On a 2020 MacBook Air (M1, no external GPU), a 24-input dense MLP with two hidden layers of 64 units trains 50 epochs over 8,200 rows in 38 seconds. The Brier score converges to within 0.003 of the same architecture in PyTorch on a Colab T4. The difference is rounding, not modeling.

The privacy argument is real, not theoretical

Server-trained model SaaS products read every feature you upload. Some of those features are public (Vegas lines, ELO ratings) but a lot of them are not — your sportsbook's true closing price, your own bankroll history, the timestamps that reveal which states you bet from. A browser model never sees a server. The TF.js runtime executes inside the tab. When you save weights, they go to IndexedDB or your filesystem. Nothing crosses the network unless you choose to publish a brick to the open-weights workshop.

Reproducibility lives or dies on the seed

A backtest you cannot rerun is a story, not a test. Browser training has one advantage Python notebooks rarely match: the entire input data is a static, content-addressed parquet pack the user downloaded once. The split logic is in client code. The seed is set explicitly. If two people in two different countries train the same architecture against the same pack with the same seed, they get bit-identical weights. Try getting that out of a Colab notebook six months later.

The feature pack: what loads into the tab

Before we touch a model, look at what we are training on. The default NFL spread pack ships 24 features per game-team row through 2024 Week 22. Names are stable and documented inline in the /tinker dataset inspector:

  • schedules-v1 — kickoff_iso, home_team, away_team, spread_line (positive = home favored), total_line, roof, surface, temperature_f, wind_mph
  • elo-v3 — pregame_elo_home, pregame_elo_away, qb_elo_home, qb_elo_away
  • epa-rolling-v2 — off_epa_pp_last8_home, def_epa_pp_last8_home, off_epa_pp_last8_away, def_epa_pp_last8_away, pass_rate_over_expectation_home, pass_rate_over_expectation_away
  • rest-and-travel-v1 — days_rest_home, days_rest_away, miles_traveled_away, timezone_delta
  • injuries-v1 — starter_outages_home, starter_outages_away, qb_status_home (active/questionable/out), qb_status_away

The label is binary: did the home team cover the closing spread? With the positive=home convention, that is cover = (home_score - away_score) - spread_line > 0. We handle pushes as a soft 0.5 label.

Forward-walking time splits, not random shuffles

The single most common way amateurs blow up an NFL backtest is calling shuffle() before splitting. Games from 2024 leak into the training set, features that depend on rolling averages get fed by future weeks, and you produce a Brier of 0.21 that evaporates the moment you bet it. The /tinker split is locked: 2018-2022 train, 2023 val, 2024 test, no shuffles, no mixing. The notebook will refuse to evaluate if you try to override.

Define the model in 30 lines of TF.js

Here is the architecture for the spread brick we shipped on April 8, 2026. Copy-paste it into the /tinker code cell and it runs.

import * as tf from '@tensorflow/tfjs';

function buildSpreadModel(numFeatures) {
  const model = tf.sequential();
  model.add(tf.layers.dense({
    inputShape: [numFeatures],
    units: 64,
    activation: 'relu',
    kernelInitializer: 'glorotUniform'
  }));
  model.add(tf.layers.dropout({ rate: 0.2 }));
  model.add(tf.layers.dense({ units: 64, activation: 'relu' }));
  model.add(tf.layers.dropout({ rate: 0.2 }));
  model.add(tf.layers.dense({ units: 1, activation: 'sigmoid' }));

  model.compile({
    optimizer: tf.train.adam(0.001),
    loss: 'binaryCrossentropy',
    metrics: ['accuracy']
  });
  return model;
}

That is the whole model. Two hidden layers, two dropout layers, one sigmoid head outputting cover probability. People over-engineer this — five-layer transformers on 8K rows of tabular NFL data overfit garbage. Keep it small.

Why ReLU and not tanh

Tabular NFL features have heavy-tailed inputs (a Bengals offense ranking 1st in EPA last week, then 27th this week is normal). ReLU's piecewise linearity handles those tails without saturating the way tanh does. We A/B-tested both architectures on the same Mahomes-Saints week-4 backtest set: ReLU edged tanh by 0.002 Brier consistently.

The training loop you actually run

In /tinker the loop is one click. Under the hood:

const { xTrain, yTrain, xVal, yVal } = await loadPack('nfl-spread-v3');
const model = buildSpreadModel(xTrain.shape[1]);

await model.fit(xTrain, yTrain, {
  epochs: 50,
  batchSize: 64,
  validationData: [xVal, yVal],
  shuffle: true,  // within batches only, not across the time split
  callbacks: tf.callbacks.earlyStopping({
    monitor: 'val_loss',
    patience: 5,
    restoreBestWeight: true
  })
});

Three things to notice. Early stopping with patience 5 — without it the model overfits by epoch 30. shuffle: true is safe because we already pre-split by year. restoreBestWeight: true sends back the weights at the lowest val loss, not the final epoch.

What to expect while it trains

On the Air, the loss chart drops fast: epoch 1 val Brier 0.249 (a hair worse than Vegas), epoch 8 down to 0.241, plateaus around epoch 22 at 0.236, early stopping kicks in epoch 27. Accuracy hovers around 53-54%, which sounds underwhelming until you remember the goal is not to beat 50% — it is to beat the implied probability from the closing spread, which already prices in 60-70% of available information.

Backtest, not just evaluate

"Backtest" in this codebase means more than computing Brier on holdout. It means simulating the betting decisions you would have actually made and seeing how the bankroll evolved. The /tinker backtest harness takes your trained model and replays 2024 week by week:

  • Score every game with the model's cover probability
  • Compute edge vs the closing spread's implied cover probability (Normal CDF of margin gap over standard error)
  • Apply a Kelly-fraction stake (clipped at 5% of bankroll, with a loss-floor guardrail)
  • Settle against the actual result and the actual closing line

On the spread brick v3 backtest, 2024 regular season: 272 bets placed at edge ≥ 1.5 points, 148-119-5, ROI +6.8%, average CLV +0.42 points. That CLV number is the single most important diagnostic. If you cannot beat the close on average, the ATS record is variance. If you beat the close by half a point on 250+ bets, you are pricing the market correctly even on weeks the score doesn't cooperate. We hammer this point in our CLV explainer and the bet-tracking guide.

Decompose ATS into edge buckets, not just season totals

One number per season hides everything. The brick's edge histogram tells you: bets in the 1.5-2.5 point edge bucket went 38-32 (54%), bets at 2.5-4.0 edge went 51-39 (56%), bets above 4.0 edge went 59-48 (55%). Translation: the model is calibrated. Higher edges produce higher win rates roughly proportional to what their EV math predicts. If your highest-confidence bucket underperforms your lowest, you have a calibration problem — see the Brier explainer for the diagnostic plot.

Export weights and turn the model into a brick

The piece nobody else lets you do: take the trained model out of the tab and into a reusable artifact. Click "Export" in /tinker and you get two files. spread-brick-v3.json contains the architecture, the input feature schema, the training metadata (seed, epochs, val Brier, CLV). spread-brick-v3.weights.bin is the raw float32 weight tensor — 38KB total for our architecture.

Drop both files into the builder and they become a reusable block in the Lego-style brick catalog. Wire that block into a new model: it scores incoming /picks games the same way it scored the backtest. The brick is portable — you can publish it to the open-weights workshop, share the JSON in Discord, or keep it private on your machine. The point is that the weights are yours.

Versioning matters more than people admit

Brick filenames include a semver tag for a reason. When you change feature inputs (we added wind_mph in v3.2), or retrain on more data (v3.3 after 2024 Week 22), the brick name bumps. Old bricks keep working with old data, new bricks live alongside. This is how we publish monthly model release notes: every change is auditable, every rollback is a single click.

A walked-through example: Mahomes Chiefs at Saints, Week 4 2024

To make this concrete, take a single bet and trace the model's reasoning through it. October 7, 2024. Chiefs (road) at Saints (home). Closing line: Chiefs -3.5, total 41.5. Public money: roughly 65% on Chiefs ML, 60% on Chiefs cover. The brick v3 score for that game:

  • Pregame ELO: Chiefs 1638, Saints 1494. QB ELO: Mahomes 1801, Carr 1532.
  • Rolling 8-game offensive EPA/play: Chiefs 0.11, Saints -0.02.
  • Rolling 8-game defensive EPA/play allowed: Chiefs -0.04 (good), Saints 0.06 (poor).
  • Rest: both teams on standard 7-day rest. Travel: Chiefs traveling 691 miles east.
  • Model predicted home margin: -8.4 (Saints lose by 8.4). Closing spread implied: -3.5.
  • Edge: 4.9 points toward Chiefs cover. Cover probability: 0.621.
  • Kelly fraction at -110 vig: 0.084 of bankroll. Capped at 0.05.

Bet placed: Chiefs -3.5 at -110, 5% of bankroll. Actual outcome: Chiefs 26, Saints 13. Margin 13, cover by 9.5 points. Bet won. Backtest harness logs the CLV: bet placed at Chiefs -3.5, closing line stayed -3.5, CLV 0.0 points on this single bet. The win was margin variance, not edge — which is exactly what a calibrated 0.62 probability picks tells you in advance.

Now contrast that with a model that hits the same Chiefs -3.5 cover but at a different reason: maybe an under-priced Mahomes ELO. The win rate is the same, but the CLV diagnostic differs. That kind of inspection is what backtest tooling has to surface — and what tab-based browser tooling makes trivially auditable.

Comparing your brick against published benchmarks

Once you have a model, the next question is whether it actually beats anything that matters. Three benchmarks I always run against.

Vegas closing Brier (the only one that pays)

For NFL spread cover 2018-2024, Vegas closing Brier is 0.244. If your model's holdout Brier is above 0.244, you are losing to the line. Below 0.244 by 0.002-0.006 is a plausibly-beatable margin. Below by 0.008 or more, sanity-check for label leakage. The exact numbers shift by season — Vegas Brier was 0.246 in 2020 (Covid-influenced sample), 0.241 in 2022 (a particularly information-rich season).

Public consensus (Pythagorean ELO)

The 538-style Pythagorean ELO model is the strongest "free public" benchmark. Open Source Football publishes weekly. Brier around 0.247 — slightly worse than Vegas. If your model beats public ELO but not Vegas, you have a publicly-tradable thesis with no market edge. If you beat both, you have something.

Last year's brick

The strongest signal that your new architecture is better than your old one is a direct A/B over the same backtest window. /tinker's "compare brick" panel runs both models on the same 2024 holdout and shows Brier delta, ATS record delta, ROI delta, and CLV delta. If the new brick is better only on the bottom three but not on Brier, you got lucky — keep iterating.

Common backtest mistakes and how /tinker prevents them

Five years of watching bettors try to roll their own backtests has surfaced a short list of repeat offenses. /tinker has guardrails for each.

  1. Post-game features in pregame rows. Joining a Bills offense's EPA-per-play that includes this game onto the pregame row leaks the label. /tinker timestamps every feature and refuses to join future-timestamped features into past rows.
  2. Survivorship bias on injuries. If you only have injury reports for games where injuries actually happened, you are systematically underestimating starter availability. /tinker's injuries-v1 pack covers all games, including the boring ones.
  3. Using closing line as a feature. The closing line is your benchmark, not your input. Train on the opening line or the midweek line if you need a price feature.
  4. Vig forgotten in the backtest. A naive Brier-good model can be a money-loser at -110. The /tinker backtest applies the actual closing vig (positive=home convention) to every settled bet.
  5. Sample-size triumphalism. 60% ATS on 40 bets is noise. The harness shows the 95% confidence band on yield as you bet count grows — see the chart pattern in how to track your bets.

Where to go after the first backtest

Once you have one working brick, the deeper plays open up. Stack two models (a Brier-tuned MLP and an XGBoost-style gradient brick) in workshop and watch the ensemble outperform either alone. Add presnap formation features from the Big Data Bowl pack — covered in the from-scratch walkthrough. Compare your numbers honestly against a Python pipeline in the latency/privacy/reproducibility comparison. Build a calibration plot on /tinker's calibration tab so you know whether your 60% picks actually hit 60%.

The point of putting all of this in a browser is not novelty. It is that the model, the data, the seed, the result, and the deploy target are all on the same machine, in the same tab, in code you can read. That is what a backtest is supposed to be.

Why people who can already do this in Python still come back to the browser

Most of /tinker's heavy users are people who spent years building betting models in Python first. They show up because the cold-start cost of "I have an idea, let me see if it improves Brier" is 30 seconds in the browser vs 30 minutes in Python (warm up venv, re-pull data, set seeds, write loader). The browser does not replace Python for the cases Python is good at — it replaces Python for the 80% of iterations that are quick variant tests.

The other repeat-use pattern: ensembling. The browser lets you load a Python-trained ONNX model, a TF.js MLP, and a custom feature transformer side by side, blend their predictions, and watch ensemble Brier in real time. The blend coefficient is a slider. In Python that is a Jupyter cell with a re-run on every tweak; in /tinker it is a slider that updates the plot continuously.

If you have done the Python version and never been satisfied with how slow the iteration loop felt, the browser is worth a Friday afternoon to try. Start with the same model you trust in Python, see if you can reproduce the Brier within 0.001 in a fresh /tinker session, then start iterating on variants. Most people who do this stop opening Jupyter for spread-style models within two weeks.

Named modeling examples

A model page is more useful when the feature examples are concrete. Josh Allen rushing attempts, Ja'Marr Chase target share, Nikola Jokic assist rate, Tarik Skubal strikeout projection, Igor Shesterkin starter confirmation, and Islam Makhachev control time are all different prediction problems. A single “player form” feature cannot explain them all, so the model needs sport-specific inputs and review notes.

  • NFL: separate route participation, pressure rate, and red-zone role from box-score volume.
  • NBA: separate usage, minute projection, pace, and back-to-back fatigue.
  • MLB: separate starter skill, handedness, park, weather, and lineup confirmation.
  • NHL and UFC: late confirmations and fight-week news can matter more than a season average.

Model inputs worth naming

Use names as evidence, not decoration. The useful SEO win is that Josh Allen, Ja'Marr Chase, Bijan Robinson and Puka Nacua and Chiefs, Bills, Bengals, Eagles and Lions appear inside decisions, thresholds, and internal links instead of being dumped into a keyword list.

  • NFL model: route participation for Ja'Marr Chase, rushing attempts for Josh Allen, pressure rate allowed by the Bengals, and red-zone carry share for Jonathan Taylor should be separate features.
  • NBA model: usage, projected minutes, rest, and pace should move Nikola Jokic or Shai Gilgeous-Alexander props differently than a one-number power rating.
  • MLB model: Tarik Skubal strikeout projection, Coors Field park factor, lineup confirmation, and bullpen rest need their own columns.
  • Review loop: grade entry price, closing price, bet result, and model error separately so lucky results do not hide bad forecasts.

Build or audit the workflow in Tinker and review it with CLV.

Research note board

Use this model-audit board to keep features, validation, and bet sizing from collapsing into one confidence score.

Model layerWhat to inspectExample inputDowngrade when
FeatureWhether the variable maps to the sport and marketJosh Allen role data or ADP price movementThe feature is a proxy for something you can measure directly
ValidationOut-of-sample error, CLV, calibration, missing dataChiefs market movement after injury newsWins come without beating the close or improving calibration
SizingBankroll, confidence interval, correlation, market limitCLV exposure compared with related ticketsMultiple bets repeat the same thesis at full stake

Bet responsibly — set limits, never chase losses.

Model calibration: predicted vs observed

Predicted win probability bucket vs the empirical win rate inside that bucket on the test set. Points on the y=x reference line are perfectly calibrated; points below mean the model is overconfident in that bucket.

EV per $100 across win rate × odds grid

Expected value of a $100 stake at each combination of true win rate and market odds. Anywhere the cell is positive you have a long-run profitable bet; the magnitude shows how aggressive Kelly will size it.

Frequently asked questions

Can a TensorFlow.js model trained in the browser actually compete with a Python model trained on a GPU server?
For tabular NFL spread features (24-48 inputs, dense layers, 8-10k training rows), yes. A two-hidden-layer TF.js MLP we train in /tinker on a 2020 MacBook Air converges to a Brier score of 0.236 in about 40 seconds — within 0.003 of the same architecture trained in PyTorch on a Colab T4. The bottleneck for spread models is data quality and feature engineering, not flops. Where Python still wins decisively: image/video models (BDB tracking frames), 10M+ row prop datasets, and anything with tree-based ensembles >500 estimators.
How do I know my browser backtest is not look-ahead leaking via shuffle?
Two things to check. First, sort rows by `kickoff_iso` before any split. Second, use a forward-walking time split (train 2018-2022, validate 2023, test 2024), never `tf.data.shuffle().split(0.8)` on the whole frame. /tinker enforces this by default: the train/val/test toggle is locked to time-ordered splits and shows a red warning if a feature column has the same value within the same game (the classic leak: post-game stats joined onto pregame rows).
What's a realistic baseline Brier score for an NFL spread model so I know if mine is any good?
Vegas closing spreads have a Brier of roughly 0.244 on the binary cover label across 2018-2024 NFL regular season. A model that beats 0.244 by 0.004-0.008 (Brier 0.236-0.240) and shows positive CLV vs close is plausibly profitable after vig. Below 0.244 you are losing to the line. Anything claiming 0.20 or lower on out-of-sample NFL spreads is almost certainly leaking — sanity-check by training on shuffled labels and confirming the score collapses to 0.25.
If everything runs on my device, where does the historical training data come from?
/tinker streams a compressed parquet snapshot of nflverse-derived schedules, drives, and EPA features (about 18MB gzipped through 2024 Week 22) on first model load. The data lands in IndexedDB and re-uses the browser cache on subsequent visits — you can train ten different architectures without re-downloading. For player props we layer a second 24MB pack with route-tree and NGS speed features. Everything is read-only; your edits to the architecture, hyperparams, and saved weights never leave the tab.

Build a free model in 60 seconds →

Go →
14m read time
29 players/teams
8 key angles
Angles in this read 6 angles

NFL 2026 market context

NFL betting examples work best when quarterback, team, and market context stay attached: Chiefs/Bills/Ravens/Eagles/Lions angles should connect to price, schedule, injuries, and game environment.
Patrick MahomesJosh AllenLamar JacksonJoe BurrowJalen HurtsJustin HerbertC.J. StroudTua TagovailoaChiefsBillsRavensEaglesLionsBengalsclosing line valuetarget shareair yardsred-zone roleroute participation

Get picks in your inbox

One email, every slate — ranked edges, no touts. Unsubscribe any time.

Start free — pick a sport

Go →

We use cookies for essential site functionality. With your consent, we also use cookies for analytics and performance monitoring. See our Privacy Policy.