What we shipped, when.
Every public change to Line Gap. We update this on every deploy that touches the product. Want the deep-dive? See the Research posts.
- v1.3.0featureimprovementinfrastructure
Model Foundation: evidence track record + reference-market hierarchy
The plumbing release. Every Kalshi-priced or retail-priced row you've hovered over in Line Gap now leaves an audit trail — captured at write-time, settled against ESPN post-game, and rolled up into a per-bucket accuracy score that surfaces inline on the existing liquidity tier pill. Nothing about the surfaces you use every day changes shape; the trust score behind them does.
What's new
- Hover any liquidity tier pill to see the accuracy track record for that bucket — Brier score, retail-beat percentage, and sample size shown inline below the existing
Spread · Depth · 24h Volbreakdown. - Sample-size honesty stays first-class: buckets below
n=50show Insufficient history and no metrics; buckets between 50 and 200 show Provisional; buckets at 200 or above show Trustworthy. You always see the full sample count before any judgment. - Aggregation grain is
(source_type, liquidity_tier, prop_type)— retail and prediction-market accuracy are reported separately per bucket, never as a single global number. - Reference probabilities on retail prop rows now follow a strict, auditable hierarchy: liquid Kalshi (tier Moderate or higher, fresher than 30 minutes) → Pinnacle no-vig → retail no-vig consensus → none. The chosen reference is stamped onto the row at insert time, so the read path stays a single SELECT.
Behind the scenes
- A unified
evidencetable now tracks every rendered price across every source. ~50–100k rows a day; service-role only — no raw evidence ever leaves the server. - Settlement runs against ESPN
player_game_statson a 15-minute cron after the game flips to final, with a 24-hour stability window that absorbs ESPN's known stat-correction lifecycle (rebound miscounts, blocked-shot disputes — historically ~1 in 200 settles). - Retention is split: unsettled rows prune on a 90-day rolling window via a daily 04:00 UTC cron; settled rows are the calibration corpus and stay indefinitely.
- A reconciliation cron walks the trailing 7-day window for any retail/prediction-market row that slipped through without a paired evidence record and back-fills it — the two-write firehose isn't a single transaction, and the reconciler is the safety net.
- A
feature_storeschema and read/write contract landed alongside, ready for the v1.4 cycle when model inputs start populating. - A
data_sourceshealth registry tracks every ingest cron's last-seen timestamp, 24h success/fail counts, and last error, so a quietly broken source can't hide behind retried polls.
Sample-size honesty for the cold start
The
n=200Trustworthy threshold won't be hit on the most-active buckets for ~2 weeks of natural traffic. That lag is intentional — we don't backfill synthetic evidence from old calibration tables, because the calibration math has evolved since those rows were priced. Insufficient history is the honest read for the next two weeks, and the pill says so explicitly.What's next
v1.3 Phase 12: a market-blended EV column that combines the projection model with the hierarchy-chosen reference probability, shipped initially behind a shadow-mode flag for a 1–2 week calibration window before any badge fires in the UI.
- Hover any liquidity tier pill to see the accuracy track record for that bucket — Brier score, retail-beat percentage, and sample size shown inline below the existing
- fix
Liquidity tiers: two correctness fixes
Two follow-up fixes to yesterday's Kalshi liquidity tier rollout. Both surfaced in code review; neither was hit in production traffic before being patched.
Tier no longer drifts on sticky prices
When Kalshi's bid-ask midpoint held steady but depth or volume shifted underneath it, the dedup helper that protects
sportsbook_oddsfrom duplicate inserts treated the new row as "same" and dropped it — while the time-serieskalshi_market_quotestable kept writing fresh quotes. The result: the UI pill could read Liquid on a market that had quietly gone Untradeable.The dedup equality now includes
liquidity_tieralongside(line, over_odds, under_odds), so any tier transition forces a write. The two tables can't disagree again.Heavy favorites are no longer scored from the wrong side
The composite scorer was fed only the Yes-side top-of-book depth. On a heavy favorite (yes ≈ 0.85), Yes bids are structurally thin — a 6:1 risk/reward at that price — while the No side carries the real dollar volume. The favorite was being scored as Untradeable purely from the orientation of the orderbook, not its actual liquidity. The mirror failure: heavy dogs (yes ≈ 0.05) had artificially fat Yes-side depth in dollar terms and saturated as falsely Liquid.
The scorer now uses
min(yes_size, no_size)— the binding side. A fill on either side requires the opposite bid to absorb, so the shallower side is the one that actually governs tradeability.Behind the scenes
- Both fixes ship with regression tests covering the failure scenarios end-to-end. 375/375 tests pass; TypeScript clean.
- No schema changes; no flag changes (
KALSHI_LIQUIDITY_ENABLEDstays on). Tiers refresh on the next 5-minute poll.
- featureimprovement
Kalshi rows now carry a liquidity tier
Every Kalshi-sourced row in Line Gap now shows one of four trust tiers — Liquid, Moderate, Thin, or Untradeable — so you can tell at a glance whether the price is worth acting on.
The tier is a composite read of bid-ask spread, top-of-book depth, and 24-hour volume. Hovering the pill breaks the score down:
Spread: 4¢ | Depth: 80 | 24h Vol: $1.2k | Score: 0.62 → ModerateWhat you'll see
- A small color-coded pill next to the Kalshi book name on the OddsTable, MultiBookComparisonTable (simple + advanced), the per-game reference-lines section, and the Best Bets "Kalshi disagrees" hint.
- Liquid (emerald) and Moderate (amber) are the rows worth weighting in the comparison; Thin (orange) is quoted but not deeply traded; Untradeable (red) means Kalshi has a market open but the orderbook is thin or one-sided enough that a fill would move the price.
- Untradeable rows stay visible but are greyed out. Hiding them would be less honest — you stay in control of the call.
How the tier gets computed
- A new 5-minute fan-out hits Kalshi's public orderbook endpoint for every market that survives the both-sides-quoted gate, and snapshots top-of-book + cumulative depth-5 into a new
kalshi_market_quotestable. - The composite score is multiplicative —
min(1, depth/D) × min(1, S/spread) × min(1, vol/V)— so a single zero (no depth, or no volume) collapses the row to Untradeable. - A phantom guard rejects markets where the derived spread exceeds 15¢. Those rows are still persisted (with a
phantom_spread_rejectedflag) so operators can audit them, but they score 0 and render as Untradeable in the UI. - The denormalized
liquidity_tieris written ontosportsbook_oddsat insert time so the UI hot path never has to join.
Behind the scenes
- Public Kalshi orderbook endpoint, unauthenticated — no extra Kalshi cost or auth surface.
- Daily 4am UTC retention prune keeps
kalshi_market_quotesbounded at 30 days rolling. - Gated behind
KALSHI_LIQUIDITY_ENABLED. Until the flag flips, every Kalshi row's tier is null and the surfaces render unchanged. - 369/369 tests pass; full TypeScript clean; both apps build green. 7 plans, 25+ commits, no breaking changes to retail/sharp surfaces.
What's next
The same
liquidity_tier ≥ Moderategate becomes the qualifier in v1.3 for whether Kalshi anchors the reference-market hierarchy on a given row. Today's pill builds the operator-facing trust signal for that future contract. - fix
Fix: duplicate Kalshi rows and inflated alt-line EV
Two issues surfaced once Kalshi started flowing into the main OddsTable. Both shipped within the same day; the v1.2.0 launch entry is unchanged.
Stale snapshots stacking as duplicates
All three poll paths (retail / Kalshi / Pinnacle) inserted a new
sportsbook_oddsrow every time a price changed but never marked the previous snapshotis_active = false. The dedup-on-insert guard skipped same-price re-INSERTs but did nothing about price moves. Over a day of polling, the same(player, prop, line, sportsbook)accumulated 4–6 active rows — the Deandre Ayton 19.5-points line was the visible symptom (six near-identical rows ranking high on EV).Affected counts at the time of fix:
- 1,388 stale active Kalshi rows
- 1,233 stale active retail rows (from the fetch-data path)
- 390 stale active Pinnacle rows
The fix:
- One-time SQL cleanup deactivated all 3,011 stale rows.
- New
deactivate_superseded_odds()Postgres function keeps only the latestis_active = truerow per dedup group; idempotent and scoped bysportsbook_key. - Every poll route (
poll-odds,pollKalshiNba,pollKalshiNbaGames,pollPinnacleNba) now calls the function via.rpc()after its batch insert.
Alt-line junk EVs
Kalshi exposes the full strike ladder for every player prop (e.g. Ayton points at 5.5 / 9.5 / 14.5 / 19.5 / 24.5). Our projection model is well-calibrated near a player's seasonal mean; at tail strikes the StatProb stays in the 10–15% range even when the market's no-vig fair prob has collapsed to 2–4%. The math then displays "+1500% EV" — technically correct, structurally nonsense.
The OddsTable now drops any row whose projection-implied probability diverges from the market-implied probability by more than 25 percentage points. Retail books don't expose alt ladders, so in practice this only filters Kalshi alt-line rows; retail and Pinnacle main-line rows are unaffected.
- fiximprovement
Math foundation pass: uniform EV math, no-vig fair prob everywhere, devig backfill
Closes the second batch of the 2026-05-11 math-foundations audit — the five items that were queued behind the v1.2.0 launch and the duplicate-row fix. Net effect: every "EV", "Fair Prob", and "StatProb" number in the product now has one definition and one writer.
Aligned projection convention across both write paths
enrich-odds(hourly cron) andfetch-data(Refresh) both compute a "simple" and "full" projection for each player-prop. Until now they disagreed on which to stamp intocurrent_season_probability:- enrich-odds wrote the full projection to both probability columns (correct — its tails behave on Kalshi's alt-strike ladder).
- fetch-data wrote the simple projection to
current_season_probabilityand the full projection tostatistical_probability. The same row could end up with different numbers depending on which path enriched it last.
fetch-data now writes the full projection to both columns, matching enrich-odds. The simple-mode computation is retained for future diagnostics but no longer hits the DB.
EV column reads the stored value, not a client recompute
OddsTable.getDisplayEVwas recomputing EV on every render fromcurrent_season_probability × over_odds, ignoring theexpected_valuecolumn thatenrich-oddsandfetch-datahad already written. Subtle divergence when the writer applied an injury adjustment the renderer couldn't see. The display now readsexpected_valuedirectly and only falls back to a client recompute for legacy rows missing the column.One
americanToDecimal, onecalculateEVThe American-odds → decimal-odds conversion existed inline in six places (
OddsTablethree times,best-bets,PlayerStatsDialog,my-bets). All now import the canonicalamericanToDecimalfromlib/projection/core/devig.PlayerStatsDialog.calculateEVpreviously returned a fraction (0.05 = 5%) while every othercalculateEVin the codebase returned a percent (5 = 5%); harmonized to percent so a copy-paste between components can't be 100× off."vs Market" label, not "EV"
The Advanced expansion under each prop has a per-book column that shows how each book's no-vig fair probability compares to the consensus across books — a probability-point delta, not a return-on-stake EV. Both quantities were labeled "EV". The probability delta is now labeled vs Market so the same word doesn't mean two different things on the same page.
Backfill stops swallowing 0-stat games
backfill-stats.tswas using||in the upsert payload:points: game.stats.points || null. A player who legitimately scored 0 points hadnullwritten instead of0. Hit-rate readers defensively?? 0so the visible Wilson math was unaffected, but averages and projections couldn't tell a DNP apart from a 0-stat performance. Every||is now??.NBA gamelog parser is name-keyed, not index-keyed
The NHL branch already used a
byNamelookup over ESPN's parallelnamesarray so column reorders couldn't silently misalign the parser. The NBA branch used fixed positional indices. NBA now uses the samebyNamelookup with the existing positional indices retained as a safe fallback.Historical devig backfill
The at-write devig fix shipped earlier today applies only to rows written after the deploy. The ~3,000 retail and Pinnacle rows written before then still carried the vigged over-side implied probability in
implied_probability, while Kalshi rows in the same column were already no-vig. A new SQL migration (20260511_backfill_devig_retail_implied_probability.sql) sweeps the historical rows so the column has uniform no-vig semantics across every source. Idempotent — only updates rows whose stored value differs from the freshly-devigged value by more than 0.01pp.Behind the scenes
- 305/305 tests pass; full TS clean; both apps build green.
- The math-foundations audit is now closed: 12 of 12 items resolved across the two batches shipped today.
- v1.2.0featureimprovementinfrastructure
Kalshi prediction-market lines, end-to-end
The biggest release since we shipped CLV tracking. Kalshi's prediction-market prices are now a first-class part of the Line Gap surface — visible alongside DraftKings, FanDuel, BetMGM and the rest, with their no-vig math made explicit.
Game-level Kalshi NBA markets
- New polling cron at
:04past the hour pulls Kalshi'sKXNBAGAME(moneyline),KXNBASPREAD, andKXNBATOTALmarkets every 5 minutes. - Markets land in
sportsbook_oddsas Yes-canonical rows (player_id NULL,under_odds NULL) so they sit beside retail rows without duplicating storage. - A closest-to-retail strike selector picks one row per
(game, market_type, side)so the comparison surface stays clean even when Kalshi exposes 10+ alternate strikes per game. - A both-sides-quoted gate (D-06) rejects phantom one-sided quotes before they pollute the table.
Kalshi in the main book list
- Open any NBA player prop on
/oddsand Kalshi appears as a regular book row alongside DraftKings, FanDuel and the rest — with a small PM badge to mark its no-vig pricing. - Kalshi's filter chip is now in the Sportsbooks dropdown.
- The per-prop expansion table ranks Kalshi by EV against retail consensus, so when Kalshi prices a prop dramatically differently from the books, you see the divergence inline.
Best Bets gets a "Kalshi edge" hint
- Best Bets still anchors on the highest-EV retail line — Kalshi's unvigged prices would otherwise hijack every callout — but when Kalshi's fair probability diverges from retail consensus by ≥2 percentage points, the Best Bet callout now shows whether Kalshi agrees or disagrees with the retail pick, and by how much.
- This is the "sharp money sees something the books don't" signal you used to have to compute yourself.
Daily Games card expand
- Click "Show prediction-market lines" on any NBA game card on the dashboard and you'll see Kalshi's moneyline / spread / total for that game, snapshot-timestamped to the latest poll.
- Honest scope: retail moneyline/spread/total ingest isn't built yet, so this is currently a Kalshi-only view — full multi-book game-line comparison ships in a future phase.
Projection enrichment for Kalshi rows
- The hourly enrich-odds cron now stamps StatProb, Confidence, and EV onto Kalshi player-prop rows using the same projection math retail rows get. 77% of active Kalshi rows now carry full projection columns; the rest have no projection coverage (retired players, future games not yet projected).
Schema & security
- Forward-only migration widens the
sportsbook_oddsCHECK constraints to cover the new game-linemarket_typeandprop_typevalues across NBA + NFL + NHL. sportsbook_odds.player_idis now nullable (Phase 7 game-line rows persist withplayer_id NULLper the Yes-canonical convention).- Resolved every active finding in the Supabase Security Advisor: RLS enabled on
daily_odds_summary, mutablesearch_pathpinned on three SQL functions, and theline_movement_summarymatview removed from the anonymous PostgREST surface.
EV calculation hardened on the alt-strike ladder
- Kalshi exposes every strike (e.g. 5.5 / 9.5 / 14.5 / 19.5 / 24.5 points for the same player), and retail books only post lines near a player's seasonal mean. That asymmetry surfaced a long-standing tail-bias in the "simple" projection model — at extreme strikes it would say "35% over 19.5" for a 10-ppg guard while the market priced the same event at 2%, producing +1690% EV displays that looked like impossible edges.
- StatProb + EV now read from the full projection (game-context-aware variance), which is well-calibrated on tails. Retail rows are unchanged in the meat of the distribution; Kalshi extreme-strike rows now show realistic single-digit probabilities and small EVs.
- Existing enriched rows in
sportsbook_oddswere updated in-place; future rows enrich correctly via the cron.
Under the hood
- 17 commits, 305 tests passing, no breaking changes to any retail-only surface (Best Bets, cross-book detection, sharp signals).
- Documented two open follow-ups: pair
spread_home/spread_awayto a single strike, and backfill retail game-line ingest so the DailyGames expand can become a true multi-book comparison.
- New polling cron at