Changelog

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.

  1. 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 Vol breakdown.
    • Sample-size honesty stays first-class: buckets below n=50 show 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 evidence table 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_stats on 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_store schema and read/write contract landed alongside, ready for the v1.4 cycle when model inputs start populating.
    • A data_sources health 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=200 Trustworthy 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.

  2. 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_odds from duplicate inserts treated the new row as "same" and dropped it — while the time-series kalshi_market_quotes table 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_tier alongside (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_ENABLED stays on). Tiers refresh on the next 5-minute poll.
  3. 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 → Moderate

    What 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_quotes table.
    • 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_rejected flag) so operators can audit them, but they score 0 and render as Untradeable in the UI.
    • The denormalized liquidity_tier is written onto sportsbook_odds at 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_quotes bounded 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 ≥ Moderate gate 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.

  4. 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_odds row every time a price changed but never marked the previous snapshot is_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 latest is_active = true row per dedup group; idempotent and scoped by sportsbook_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.

  5. 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) and fetch-data (Refresh) both compute a "simple" and "full" projection for each player-prop. Until now they disagreed on which to stamp into current_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_probability and the full projection to statistical_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.getDisplayEV was recomputing EV on every render from current_season_probability × over_odds, ignoring the expected_value column that enrich-odds and fetch-data had already written. Subtle divergence when the writer applied an injury adjustment the renderer couldn't see. The display now reads expected_value directly and only falls back to a client recompute for legacy rows missing the column.

    One americanToDecimal, one calculateEV

    The American-odds → decimal-odds conversion existed inline in six places (OddsTable three times, best-bets, PlayerStatsDialog, my-bets). All now import the canonical americanToDecimal from lib/projection/core/devig. PlayerStatsDialog.calculateEV previously returned a fraction (0.05 = 5%) while every other calculateEV in 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.ts was using || in the upsert payload: points: game.stats.points || null. A player who legitimately scored 0 points had null written instead of 0. Hit-rate readers defensively ?? 0 so 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 byName lookup over ESPN's parallel names array so column reorders couldn't silently misalign the parser. The NBA branch used fixed positional indices. NBA now uses the same byName lookup 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.
  6. 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 :04 past the hour pulls Kalshi's KXNBAGAME (moneyline), KXNBASPREAD, and KXNBATOTAL markets every 5 minutes.
    • Markets land in sportsbook_odds as 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 /odds and 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_odds CHECK constraints to cover the new game-line market_type and prop_type values across NBA + NFL + NHL.
    • sportsbook_odds.player_id is now nullable (Phase 7 game-line rows persist with player_id NULL per the Yes-canonical convention).
    • Resolved every active finding in the Supabase Security Advisor: RLS enabled on daily_odds_summary, mutable search_path pinned on three SQL functions, and the line_movement_summary matview 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_odds were 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_away to a single strike, and backfill retail game-line ingest so the DailyGames expand can become a true multi-book comparison.