How the model actually works.
Here's how the model works, step by step. There are four parts: it predicts a probability for each pick, logs every prediction, learns from how those predictions actually did, then checks itself against the closing line — the sharpest test of whether a pick was really good. The logging part is what most paid services skip.
Current state, live: Avg CLV+0.4pt Beat close43% Picks graded738
01. The probability model
For every game, the model puts a number on each side — "this team has a 57% chance to win." Here's where that number comes from.
Every market gets a model probability for each side. The recipe varies a bit per sport, but the spine is the same: a sport-specific Elo rating, blended with recent-form signal, then nudged by a handful of structural features.
Elo plus form
The base team rating is an Elo number maintained per sport, with K factors and home-field constants tuned per league. On top of that, a last-10-games form score adjusts the rating for hot or cold streaks. The blend weights are league-specific: MLB leans more on Elo because last-10 is only 6% of a season, NFL will lean more on last-10 because last-10 is closer to 60% of the season (rolling out later this year).
Sport-specific features
Layered on top of the team rating, the model pulls in features that matter for the specific sport:
- MLB: starting-pitcher ERA, bullpen ERA (cumulative usage), ballpark factor, weather (wind, temperature, dome status), travel fatigue. Optional: home-plate umpire strike-zone profile, env-gated (
UMPIRE_FACTOR_SCALEdefaults to 0; wiring is in place for when the umpire ratings file is curated). - NBA: injury impact (per-player VORP-style drag, refreshed every 10 minutes so late-breaking inactives land before the model misprices the line), rest days, schedule density. Optional: referee crew tendencies, env-gated (
NBA_REF_FACTOR_SCALEdefaults to 0). - MMA / Tennis: the prediction-market implied probability from Polymarket (Kalshi as fallback) replaces the rating step entirely. The base model probability IS the PM number — no Elo, no form blend. PM and Kalshi are sharper than my model on niche fights, so deferring is the right call.
- MMA favorite-longshot recalibration: on top of that market price, MMA gets one data-driven correction. I fit 7,265 historical UFC fights (2010-2026) and found the market systematically under-prices heavy favorites: fighters priced 0.85-0.90 actually win 91%, and 0.90+ win 98%. So for UFC I nudge the favorite side up toward its empirically observed win rate (scaled by
MMA_RECAL_SCALE), and the verdict ceiling that demotes lopsided picks is raised to 0.92 for MMA (it stays tight for NBA/MLB, which are overconfident the other way). When a fight has a FanDuel line but no Polymarket/Kalshi listing (common on undercards), a market-anchored logistic model fit on the same dataset fills the gap — it matches the market rather than trying to beat it, purely for coverage. - All sports: a sport-level calibration offset and a per-(sport, market) residual nudge from the journal (see section 3). For MLB and NBA, Polymarket and Kalshi prices are blended in at ~5% weight as a sanity anchor — not the full-replacement behavior used on MMA / Tennis.
Sharp-money signals (all sports)
The model watches the market itself, not just the game. Five separate signals look at how the line is moving and where the smart money is sitting. They feed the verdict tier and surface inline on the picks:
- Multi-book consensus. A Pinnacle-weighted (2x) mean of implied probabilities across FanDuel, DraftKings, BetMGM, and Pinnacle. When FanDuel's price diverges from consensus by ≥1.5pp, the row is flagged "FD stale" (consensus thinks the side is more likely than FD's price implies — you're getting +EV) or "FD juiced" (consensus thinks it's less likely — be careful).
- Line movement. Opener-vs-current implied-prob delta across the best book at each tick, capped at a ±1.5% nudge to model_p. Quadratic weighting so small moves barely register and large moves hit the cap.
- Steam detection. When two or more books move ≥0.5pp implied prob in the same direction within five minutes, the line is "running steam" — the classic sharp-money tell. The in-play chip swaps from
+XpptoSTEAM ⚡ +Xppwhen it fires. Tunable viaSTEAM_WINDOW_MIN/STEAM_MIN_BOOKS/STEAM_MIN_PP. - Reverse line movement. When FanDuel moves against Pinnacle (FD shortens a side while Pinnacle drifts it, or vice-versa), the public is pushing FD while sharps stay on the other side. The model flags
rlm_flag+rlm_sharp_on_this_sideso the verdict can confirm or contra. Pinnacle is the sharp benchmark; using book divergence avoids the paywalled / scrape-fragile public-bet % feeds tipsters quote. - CLV prediction. A forward-looking forecast of where the FD line will be at close, computed from sharp divergence + recent trend + time-to-commence. Exposed as
predicted_clv_pp. The bigger the positive forecast, the more you lose by waiting to bet.
From probability to edge
Sportsbook prices are converted to implied probabilities then
de-vigged (the overround is stripped so the two sides sum to 1.0).
The edge for a pick is model_p - market_p. Anything
above the threshold becomes a recommendation, tiered by magnitude
— internally HERO / STRONG BET /
BOSS PICK / GOOD BET for positive picks
and MARGINAL / PASS for the rejected
ones. User-facing surfaces collapse those six into three labels:
Top Pick, Small Play, and
Pass. The full granularity stays visible on the
/model audit page.
Two distinct mechanisms stop a runaway feature from blowing up stake size. First, a hard clamp on the raw model probability: 80% globally (NBA, NFL), tightened to 72% for MLB, since no pre-game baseball matchup realistically clears that given the sport's variance. MMA is the exception and runs the other way: heavy UFC favorites genuinely win at a high clip (priced 0.90+ win 98% historically), so MMA is exempt from the 80% clamp and capped high at 93% instead. The clamped number is what you see. Second, a verdict-demote ceiling: when a pick's probability exceeds a per-sport threshold (NBA 78%, MLB 75%, MMA 92%, and 85% by default), the verdict drops one rung (HERO → STRONG BET, STRONG BET → GOOD BET) so the stake recommendation shrinks — the probability is left as-is, only the sizing is pulled back. For NBA / MLB these thresholds sit where picks historically underperformed their own predicted probability; MMA's is raised to 92% because the opposite is true there (the market, and so the model, has historically under-rated favorites, not over-rated them).
02. From probability to pick
Every game, every side, every market gets a model probability. Almost none of them become a pick we publish. Here's the filter.
Knowing a team has, say, a 54% chance to win isn't the same as saying "bet on them." A pick has to clear several gates first. The model considers every game on the slate four ways (home moneyline, away moneyline, home spread, away spread), so a twelve-game MLB night produces roughly 48 candidate rows. Most of them never make it to Track Record.
Edge, not probability, is the headline
For each side we compute edge = model_p − market_p,
where market_p is FanDuel's implied probability after
we strip the vig. A 60% model number on a side the market also
prices at 60% means we agree with the book — no value, no
pick. The same 60% on a side the market prices at 53% is a
+7pp edge — that's the kind of disagreement
we publish.
Verdict tiers
Every surviving row gets one of these labels:
- STRONG BET — edge ≥ 8pp. The model thinks the market is materially off.
- GOOD BET — edge 2–8pp. A meaningful disagreement, worth a smaller stake.
- MARGINAL — edge 0.5–2pp. Inside the noise of de-vigging error. Shown for transparency, never staked.
- PASS — edge below 0.5pp. The model and the market basically agree.
- AVOID — edge negative. FanDuel is overpriced; surfaced on /no-bet.
Only STRONG BET and GOOD BET get written to the journal. Everything below that tier is informational — you'll see it on the dashboard but it never becomes a tracked pick or contributes to P/L. The reason is honesty: a 1.5pp edge isn't meaningfully different from "the model and market agree," and treating it as a recommendation would dilute the track record with picks the model itself doesn't really believe in.
Safety filters
A surviving row can still get rejected before it lands as a pick. Each of these has a story behind it — usually a specific failure the model walked into early on:
- Stale-line guard — if
|edge|exceeds 25pp, FanDuel's posted price is almost certainly frozen (postponed game, side-swapped feed) rather than a real mispricing. We refuse to publish phantom edges. Same drift-check logic we use on the closing line. - Calibration kill switch — if the journal shows a sport & market combination running a catastrophic gap (currently NBA spreads at −31pt on n=24), every pick in that bucket is demoted to PASS until the gap recovers. The model is allowed to say "I'm not trusted in this market right now."
- Heavy-underdog artifact — if the line is a 4-to-1 dog or worse and the model says less than 30% with no real Elo backing, that's a model artifact, not real value. Demoted.
- Overconfidence demote — the model claims more than 85% on a row where the Elo gap doesn't justify it — demoted one tier (STRONG BET → GOOD BET) so the stake size shrinks.
- Thin-edge sub-coinflip dog — a pick where the model still gives its side under a coin flip (model_p < 49.5%) AND the edge over FanDuel is under 4pp. An edge-bucketed backtest (n=604) found a clean cliff at ~4pp: sub-coinflip dogs with 2-4pp edge lose money, while dogs with 4pp+ edge are clearly +EV (and only get stronger as the edge grows). So these thin-edge sub-coinflip dogs are demoted to PASS and skipped. Sub-coinflip dogs that DO clear the 4pp edge floor are kept and staked at full Kelly, no shrink: an earlier blanket 30%-Kelly cut on all sub-coinflip dogs backtested worse, because it shrank the profitable high-edge dogs too.
- Far-future cutoff — commence more than 14 days out (3 for ball sports). Prevents conditional pre-series lines from getting stamped as real picks.
- In-play cutoff — commence already started by more than 15 minutes. The live price isn't the pregame price the model was reasoning against.
What this means for Track Record
Every row on /track-record is a pick that:
- Cleared the 2pp edge floor at FanDuel,
- Survived every safety filter above,
- Was published to the journal as STRONG BET or GOOD BET,
- Belonged to a game ESPN later returned a final score for.
We don't show every game the model considered — that'd be the universe, not the track record. We show every pick the model actually recommended. The math is honest because the filter is automatic: a 2pp threshold and a verdict label are code, not editorial judgment.
03. The journal
Every pick gets saved to disk the moment it's published — not just the pick, but every input that produced it. So later we can ask: was the pick good?
Every recommendation gets snapshotted at publish time. Not just the pick and the price, but every input that fed it. When the game settles, the entry auto-grades and gets a closing-line snapshot. That's the loop.
What's captured
- Model probability, market price, computed edge, verdict tier.
- Every feature value: Elo of each side, form score, pitcher and bullpen ERAs (where applicable), weather, park factor, injuries, line movement, prediction-market implied probability.
- The full multi-book pricing grid (FanDuel, DraftKings, BetMGM, Pinnacle), the consensus probability we computed at publish time, the sharp-divergence flag, the steam / RLM signals, and the predicted-CLV forecast — every sharp-money tell we used to land on the verdict.
- The opening line, the line at recommendation, the closing line, and a 30-minute-before-tip snapshot.
- A structured
model_inputssnapshot (added 2026-05-21) holding the raw signals the model saw — Elo, form, ratings, injury drag, weather, line state, real-money anchors. This is what backtests run against, so "what if I'd weighted X half as much" can be answered against the inputs the model actually saw, not today's stale state. - Final outcome, score, and the grade (WIN / LOSS / PUSH / VOID).
Why this matters
Without saved inputs, you can't fairly test "what if I'd weighted X half as much" — by the time you'd want to retest, the inputs (today's pitcher ERAs, etc.) have moved on. Saving the snapshot at publish time fixes that, so the model can be backtested against the exact numbers it actually saw when it made the call.
On top of that backtest substrate, an ablation framework
(added 2026-05-24) re-runs the same live games through the model
with specific feature buckets disabled — full, no-injuries, no-lines,
no-consensus, elo-only — and writes a parallel journal for each
variant. Once each variant has accumulated ≥50 graded picks, we can
attribute the model's hit-rate and CLV to specific features instead
of arguing on vibes. The runner is tools/run_variants.py;
the comparison panel on /model follows once the
data is in.
Auto-grading runs against ESPN scoreboards and the spread is
computed from the leg's stored point at publish time. Pushes
void. Anything the auto-grader can't match (ESPN doesn't list
the game, team-name fuzz miss, ambiguous side) stays in the
journal as open rather than silently dropping. The
tools/diagnose_stale.py diagnostic classifies each
stuck row with its likely cause for manual review.
04. The calibration loop
Every night the model grades itself. If it's been calling 60% too often when reality says 50%, tomorrow's predictions get nudged down.
A nightly job replays the journal and computes calibration statistics per sport, per market type, and per verdict tier. The output is a set of offsets that get applied to tomorrow's predictions.
Sport-by-sport correction
If MLB moneyline favorites have hit at 45% but the model predicted 56% over a 200-pick sample, the calibration gap is -11 points. The next morning, MLB moneyline predictions get -11 points subtracted before they become edges. The bias is reactive, not assumed.
Offsets are capped, and the cap is sample-size dependent. A noisy 20-pick gap of -33 points shouldn't propagate the full -33 into tomorrow's predictions; it should propagate a fraction.
| Sample size | Max offset | Effect |
|---|---|---|
| n ≥ 50 | ±25 pp | Large sample, full structural bias allowed |
| n ≥ 20 | ±18 pp | Solid sample, moderate bias allowed |
| n ≥ 8 | ±10 pp | Minimum sample, conservative |
| n < 8 | 0 pp | No correction, ride at neutral |
"pp" = percentage points on the probability scale; ±25 pp means a calibration offset of up to ±0.25 in probability space (e.g. 60% nudged to 35% or 85%). The caps above govern per-(sport, market) offsets — the fine-grained correction. A separate sport-only offset (broader bucket, no market split) runs alongside it and caps at 6pp under n=50 and 12pp above; that's what the "Sport-offset cap is currently 12pt" hint on the /model NBA panel refers to.
Per-verdict shrink
If a verdict tier (internally STRONG BET, surfaced
to users as Top Pick) has gone 4-and-10 on a
recent sample, that tier is overpromising. Next day's predictions
in that tier get their probability pulled toward 0.5 proportional
to the calibration gap. Shrink only applies when the tier is
underperforming (gap < 0), not when it's outperforming.
shrink = min(0.5, abs(calibration_gap) * 4.0)
calibration_gap is on the decimal scale (0.10 = 10pp),
so a -12pt gap evaluates to min(0.5, 0.12 × 4) = 0.48,
just under the 0.5 ceiling. Any gap past ~12.5pt clamps to the full
0.5 cap, so a single bad tier can never be shrunk more than halfway
to a coin flip.
Per-confidence-band shrink
Per-verdict shrink corrects whole tiers. But a tier can be well-calibrated on average while a specific confidence band inside it leaks. The 65-74% band is the classic offender: a May 2026 audit found picks the model called ~69% were hitting ~52% on n=92, a -17pt gap that survived the sport offset, the verdict shrink, and the Platt fit. So a final, narrower correction runs last: bucket every resolved single into probability bands (0.25-0.35, 0.35-0.45, ... , 0.75-1.00) and shrink each band on its own measured gap.
shrink = min(0.40, abs(band_gap) * 3.0)
A band only gets a non-zero shrink when it has n ≥ 20 resolved picks AND is overconfident by more than 3pt (positive gaps and near-perfect bands are left alone, so the loop never disturbs what's working). The cap is 0.40, tighter than the per-verdict 0.5, because band gaps sit on smaller samples. This is the last calibration step before the verdict ceiling.
Verdict ceiling
We cap the model at 85% confidence on any single pick — empirically, anything above that turns out overconfident more often than sharp, and the higher the recommended stake, the worse the asymmetry. When raw model probability exceeds 0.85, the verdict demotes one tier (HERO becomes STRONG BET, STRONG BET becomes GOOD BET).
The calibration loop doesn't change the model's signal. It scales and shifts the model's outputs based on how reliably those outputs have predicted reality. If the model picks well but claims too large an edge, the loop tightens the claim without throwing out the pick.
How well-calibrated is the model? Brier and the gap
Two scalars surfaced on the Model page measure calibration directly:
- Calibration gap:
hit_rate − avg_predicted_p. A model that says 60% and hits 60% has gap 0. Negative gap means overconfidence (the most common failure mode); positive means underconfidence. Inside ±2pt is well-calibrated; ±10pt is the signal-to-fix-the-model band. - Brier score: mean squared error between predicted
probability and the binary outcome,
mean((predicted_p − outcome)²)where outcome is 1 for a win and 0 for a loss. Lower is better. 0.25 is the coin-flip baseline (random 0.5 predictions on a 50/50 outcome). Anything below 0.21 is sharp; below 0.18 is professional-grade. The Brier score punishes overconfident misses worse than an underconfident hit, so it's the right scalar for "is the model honest about how confident it should be."
The two metrics catch different failure modes. A model can have perfect calibration gap (predictions average out to the hit rate) but a terrible Brier score (every individual prediction is wrong in opposite directions). The Model page reports both side-by-side so neither can hide.
05. Closing-line value
The sharpest measure of a real edge: did we get a better price than the market settled on?
The only sports-betting metric that actually predicts long-run profitability. CLV is the difference between the price you got and the price the market closed at. If you bought a team at +150 and the line closed at +130, that's positive CLV: the market moved toward your side, which means you got a sharper price than the consensus.
Hit rate is noisy in small samples. CLV is not. A bettor with consistent +CLV will, mathematically, win over a large sample because they're consistently buying mispriced assets. A bettor with -CLV can hit 60% short-term and still be on a path to ruin.
How it's measured here
- Line snapshots are pulled every five minutes from Pinnacle (the sharp-book benchmark, ~2% vig) and from FanDuel / DraftKings / BetMGM (the recreational US books where bets actually execute).
- At recommendation publish, the active book price gets stamped on the journal entry.
- At game commence, the closing snapshot is stamped from each book independently.
- CLV is computed against Pinnacle's close as the primary benchmark — Pinnacle runs ~2% vig, so its close is the cleanest "fair price" the market settled on. The same number is also computed against FanDuel's close as a secondary view; FD is the execution book but its close still has ~4-5% vig so CLV vs FD reads slightly higher than CLV vs Pinnacle.
- "Beat the close" is a binary: did the sharp line move past us in our direction.
Pinnacle is the global low-margin sportsbook used by professional bettors. They run on roughly half the vig of FanDuel, never limit winning players, and post some of the sharpest lines in the industry. Sharp shops measure CLV against Pinnacle's close because anything else is just measuring against another softer line. We can't bet at Pinnacle from the US, but that's not the point: we use Pinnacle's close as the fair-line reference and bet at FanDuel, DraftKings, or BetMGM (whichever has the best execution price).
Pinnacle CLV is forward-only — Pinnacle snapshots started landing in the journal on 2026-05-23, so picks settled before then have only a FanDuel-close CLV stamped, not a Pinnacle one. The Pinnacle sample grows each day and promotes to the headline on /model once n ≥ 30. Until then, the FanDuel-close number is shown first (less sharp but it's all we have history on).
Separate from the broader closing-line snapshot fix on 2026-05-18: that one patched the loop that was evicting pre-game ticks before the grader could read them, which retroactively un-stamped a handful of bad CLV outliers but didn't add Pinnacle as a benchmark. The /model provenance note tracks that fix separately.
06. Honest limits
Where the model is still wrong, in plain English.
Overconfidence on magnitude
Hit rate sits around 42.9%, calibration gap around -5.5pt. The model picks sharp (positive CLV), but it claims a bigger edge than the close ratifies. Calibration loop is the fix; it's working but not done.
NBA hit rate dip
NBA is currently running 60.0% on n=5. Open question whether it's longshot bias (model loves dogs that don't cover) or coinflip bias (a wash of 50/50 calls that lose on price). There's a diagnostic tool for that; haven't run it through to a fix yet.
Football is offseason
NFL and NCAAF aren't calibrated yet. There's no journal data to fit against. They're queued for late August 2026 when training camps wrap and Week 1 lines post. Until then, those sports are not surfaced as picks.
Variant ablation hasn't shipped yet
Long-term, I want to run five model variants in parallel (full, no-Polymarket, no-injuries, no-line-movement, Elo-only) and compare them on the same slate. That's the cleanest way to attribute edge to features. It's in the plan, not in production.
07. Stack and operations
What's running this thing, for the curious.
- App: Flask, Postgres with Alembic migrations, Gunicorn, deployed on Render Starter ($7.25/mo).
- Data: The Odds API (sportsbook lines), ESPN public scoreboards (scores, injuries), Polymarket and Kalshi (prediction-market anchors), public stats sites for ratings refresh.
- Auth: Magic link, Google, or email + password. No card. Public pages (forecasts teaser, model audit, methodology) need no account.
- Background loops: Polymarket and Kalshi anchors every 5 minutes, line snapshots every 5, edges recomputed every 10, journal reflect overnight, ratings and feature refreshers on individual cadences (weather every 2h, pitchers and bullpens every 3h, ratings every 12h, WAR / VORP / travel daily).
- Observability: Plausible for analytics, Render logs for the last 7 days, durable journal as the source of truth for everything model-related.
The numbers cited throughout this page are live as of today, pulled from the same journal that grades every pick. The full audit, with calibration plots, per-pick waterfalls, and CLV by sport, is on /model.