# SNAPSHOT RETENTION + BACKTEST HARNESS — Phase 2 **(a) is a REPORT of what exists. (b) is a DESIGN PROPOSAL — nothing built yet.** Data pulled live from Supabase 2026-07-20 ~02:40 UTC. --- # (a) WHAT REPLAYABLE HISTORY ACTUALLY EXISTS ## The one-line answer **`ledger_entries` is the only store of model history that exists. It holds 6 days of graded props, and it does NOT contain the model's inputs — so today we can score the grades we emitted, but we CANNOT test whether a different model would have done better.** That second thing is what a backtest harness is for. ## Every other candidate store is empty Row counts across the entire `public` schema: | Table | Rows | |---|---| | `player_id_map` (lookup, not history) | 9,926 | | **`ledger_entries`** | **643** | | `grade_history` | **0** | | `line_snapshots` | **0** | | `historical_props` | **0** | | `closing_lines` | **0** | | `resolution_results` | **0** | | `accuracy_tracking` | **0** | | `model_predictions_extended` | **0** | | `engine1_weights` | **0** | | `prediction_registry`, `outcomes`, `line_history`, `joint_outcomes`, `prop_correlations`, +25 more | **0** | **A data warehouse was designed and never filled.** 38 empty tables. `grade_history` even has the right shape (projection, modeled_prob, implied_prob, factors) but its only writer is `gradingOrchestrator.persistGrade`, reachable solely via the n8n `POST /api/grading/pipeline` route, which has never run in production. Redis holds no history either: `snapshot:{sport}:latest|previous` is **two generations at 24h TTL**, the intraday line `history[]` rides inside that same expiring blob, and `outcomes:{sport}:log` (30d, cap 1000) carries **no odds, no confidence, no projection**. ## What `ledger_entries` gives us (public model rows, `user_id IS NULL`) **640 rows · 6 distinct game days · 2 sports · 215 players** | | | |---|---| | Date span | 2026-07-11 → 2026-07-19 | | Distinct game days | **6** (Jul 11, 12, 16, 17, 18, 19) — **Jul 13/14/15 are missing entirely** | | Settled | 470 | | Settled **with odds** (the usable backtest set) | **465** | | With `locked_odds` | 635 (99.2 %) | | With `closing_odds` | 636 — but see CLV below | | With `confidence` | 640 | | With usable projection (`model_value > 0`) | 585 | Per day (graded / settled): | Date | MLB | WNBA | |---|---|---| | 07-11 | 74 / 74 | — | | 07-12 | 54 / 52 | — | | 07-16 | 48 / 48 | 32 / 32 | | 07-17 | **86 / 57** | 70 / 70 | | 07-18 | **103 / 75** | 62 / 62 | | 07-19 | 22 / 0 | 89 / 0 (tonight, unsettled) | ### Fields available per row `sport, player_key, player_name, stat, line, side, locked_odds, book, grade, edge, confidence, model_value (projection), graded_at, game_id, game_date, closing_line, closing_odds, clv, clv_result, outcome, actual_value, settled_at, revised_from_grade, team, opponent` ### 🔴 What is MISSING — and why it blocks real backtesting 1. **No feature vectors.** Nothing stores `l5_avg`, `l20_avg`, `opp_rank_stat`, `rest_days`, consistency, trap composite — the *inputs* the grade was computed from. **Without inputs you cannot replay a counterfactual model.** You can ask "did our B grades hit?" but not "would this new metric have graded it better?", which is exactly the question the metrics-engine north star requires. 2. **No `p_win` / `ev_pct` / `fair_odds`.** They only started existing in production tonight and are on no ledger column, so EV calibration has a sample of zero. 3. **No `grade_11`.** Only the 4-letter collapse is stored, so the C−/C/C+/B− distinction — the entire live range — is unrecoverable from history. 4. **No model version.** Nothing records which engine produced a row. Tonight's changes mean rows before/after are from different models and are silently mixed in the same table. 5. **CLV is unusable** — C4: `closing_line == locked_line` on ~95 % of rows, so `clv` is structurally ~0. 6. **Settlement gaps:** Jul 17 MLB 86 graded / 57 settled, Jul 18 103/75. ~28-29 rows/day never settle. Cause not yet diagnosed → feeds the Phase 2 settlement-correctness audit. **0 pushes across all 470 settled rows** is also suspicious for a stat like hits and needs that audit. ## VERDICT **We cannot meaningfully backtest yet, and this was the expected answer.** - 465 settled-with-odds rows over **6 days**, 2 sports, one model era, no inputs. - That is enough for a *descriptive* record (hit rate, ROI by band — already produced in `gate-simulation.md`) and **not remotely enough to validate a model change**, which needs input replay, multiple model versions, and enough independent games to clear noise. - Under the n≥20 discipline, per-bucket claims are already thin; per-bucket *and* per-model-version would be vapour. **Therefore SNAPSHOT RETENTION IS PRIORITY ZERO.** Every night without it is a night of history we can never get back. The harness is worth little until the store beneath it has been compounding for weeks. --- # (b) DESIGN — the snapshot retention store (PROPOSAL, not built) ## Principle **Capture the model's INPUTS and OUTPUTS at lock time, immutably, so any future model can be replayed against the exact conditions the live model faced.** The ledger records what we *claimed*; retention records *why we claimed it*. ## Where it lives: Postgres (Supabase), not Redis Redis is a cache with TTLs — the thing that already lost us history twice. This is permanent, queryable, joinable, and backed up by the job we just closed. ## Schema (proposed): `model_snapshots` One row per (graded prop × snapshot cycle). **Append-only.** A re-grade at the next cycle writes a NEW row — that is the point: it lets us ask whether the 14:00 read or the 22:00 read was better. ```sql create table model_snapshots ( id bigserial primary key, -- provenance: which run produced this, and with which model snapshot_id uuid not null, -- one per runSnapshot() call captured_at timestamptz not null, -- grade LOCK time cycle_hour_utc smallint, -- 14/19/22/1/3, or null for intraday model_version text not null, -- e.g. 'engine1@2026-07-20' code_sha text, -- git sha at runtime -- identity (matches the ledger's natural key so outcomes can be stamped) sport text not null, game_id text not null, game_date date not null, player_key text not null, player_name text not null, team text, opponent text, stat text not null, line numeric not null, side text not null, -- MARKET (real book numbers, never model output) book text, book_odds integer, over_odds integer, under_odds integer, fair_odds integer, -- de-vigged fair_prob numeric, overround numeric, devig_method text, -- MODEL OUTPUT grade text, -- 4-letter grade_11 text, -- pre-collapse (C-, C, C+, B- ...) confidence numeric, confidence_basis text, p_win numeric, -- the real probability ev_pct numeric, projection numeric, edge_pct numeric, takeable boolean, value boolean, refused boolean default false, refusal_reason text, -- juiced_no_edge / insufficient_data / ... -- THE COUNTERFACTUAL ENABLER features jsonb not null, -- the full feature vector at lock time archetype text, -- OUTCOME (stamped later by the settle pass; null until then) outcome text, -- hit / miss / push actual_value numeric, settled_at timestamptz ); create index on model_snapshots (game_date, sport); create index on model_snapshots (player_key, stat, game_date); create index on model_snapshots (model_version, game_date); create unique index on model_snapshots (snapshot_id, player_key, stat, line, side); ``` ### Why each unusual choice - **`features jsonb`** — the single most important column. Schema-free so adding a feature never needs a migration, and a future model can be replayed on the exact inputs. Without this the harness can only grade our own homework. - **`model_version` + `code_sha`** — a backtest that mixes model eras is worthless. Tonight proved the risk: pre/post grade-fix rows are already mixed in the ledger with nothing to tell them apart. - **`grade_11`** — the 4-letter collapse throws away the live range (C−…B−). Store both or lose the resolution that matters. - **`refused` + `refusal_reason`** — **refusals are training data.** The ledger drops them entirely, so we currently cannot ask "was the gate right to refuse these?" That question is unanswerable today and shouldn't stay that way. - **Row per cycle, not per prop** — enables intraday calibration (is the early read or the late read sharper?) and directly serves the grade-lock/CLV arc. - **Outcome stamped onto EVERY cycle row** for the same natural key, so each cycle's prediction is scored against the same truth. ## Where it's written `snapshotService.runSnapshot` — the existing normalization chokepoint, the same place `recordPipelineGrades` already writes the ledger. Best-effort and non-blocking: **a retention failure must never break a snapshot** (same contract as the ledger write). Also written on refusals, which the ledger path skips. Settlement stamps outcomes in the existing settle pass, keyed on `(player_key, stat, line, side, game_id, game_date)`. ## Volume + cost (the constraint that shapes retention) ~160 graded props/day × ~5 cycles ≈ **800 rows/day ≈ 292k rows/year**. At ~1-2 KB of features per row that is **roughly 300-600 MB/year — which would exceed the Supabase free tier (500 MB)** on its own. Proposal: - Keep **full `features`** for **90 days** (well past any calibration window). - After 90 days, null the `features` blob but keep every scalar column forever — the descriptive record stays permanent; only counterfactual replay ages out. - Revisit before the 90-day boundary with real measured row sizes rather than this estimate. **Zero out-of-pocket still governs.** ## What it unlocks 1. **Backtest harness** — replay any model over stored inputs; hit rate, ROI, Brier, CLV by sport/tier/odds band. 2. **Calibration by odds band** (Phase 4) — needs `p_win` + outcome, both here. 3. **Metrics-engine validation** — the north star's "does this metric predict better?" gate is only answerable with stored inputs. 4. **Intraday sharpness** — which cycle's read is best. 5. **Refusal audit** — were the gates right? ## Sequencing 1. Ship retention **first** (priority zero — history compounds from tonight). 2. Let it accumulate while Phase 3 work proceeds. 3. Build the harness against it; its honest first output — *"6 days, one model version, cannot validate"* — is expected and correct. 4. Only after weeks of accumulation do calibration and metric validation mean anything. ## Open questions for Kev before building 1. **90-day feature retention** — right call, or keep everything and pay? 2. **Backfill?** We could seed `model_snapshots` from the 640 existing ledger rows (scalars only, `features` null, `model_version: 'pre-retention'`). Honest as long as the null features are explicit. Worth it, or start clean tonight? 3. **Refusals** — confirm we store them. It doubles-ish the row count but makes the gate auditable, and I think it's the difference between a record and a dataset.