← All findingssource · memory/project_market_anchored_simulator.md
The plan's 6-step loop: (1) draw game state anchored to market total/spread, (2) draw minutes conserved to 200, (3) draw usage conserved to ~100%, (4) draw per-component rates, (5) recombine, (6) enforce conservation.
Phase 1 SHIPPED (projections-engine commit 5f0c6e6, 2026-07-09): steps 1-2 only.
projections/simulate/game_state.py— market-anchored (home_points, away_points, margin, total) sampler.implied_team_totals()derives team totals fromtotal_over/spread_home. Sign convention verified against a real live odds-service game (2026-07-09 Mercury@Fever):spread_home > 0means HOME is the underdog (standard convention). Formula:home_total = (total - spread_home)/2.projections/simulate/minutes_conservation.py— wrapsminutes_v2.predict_minutes_band's existing per-player independent quantile bands (confirmed zero conservation) with an actual conservation guarantee (every MC sample sums to exactly 200) + margin-conditioned blowout shift (starters→bench). Role inferred from q50 rank (no separate starter-flag input available).- 13 tests in
tests/test_simulate/, all passing, including a real-data smoke test against a real 12-player WNBA roster's starter/bench ground truth from.audit-cache-wnba/features.parquet+ the cached trained model at.audit-cache/models/minutes_band_v1.
Confirmed NOT reusable: slate_correlation.py's shared-latent-factor mechanism (sample_slate_correlated) — one shared scalar g mixed into every player with the same sign, structurally cannot produce negative cross-player correlation (no conserved pool/exchange). Left untouched, not reused, not deleted (still used by the existing correlated-single-leg parlay path).
Confirmed reusable for later phases: projections/simulate/copula.py's sample_player_game(quantiles_per_stat, correlation, ...) — clean, stateless, per-player draw; marginals.inv_cdf_batch is genuinely stat-agnostic (already reused for minutes in Phase 1).
Explicitly deferred (not built): step 3 (usage/shot-volume conservation), step 4 (per-component rate draws — blocked on §4's full decomposition, which doesn't exist for any league; WNBA's EB shrinkage project_wnba_eb_shrinkage only touched one blended number, not decomposed FGA/FG%/etc.), step 5-6 recombination, and all of §6 (allocation-compare — strictly gated on §5 finishing per the plan itself).
Next session should start with: Phase 2 = usage/shot-volume conservation across the on-court five (the piece that produces the plan's key negative-correlation mechanism), likely still stubbed against un-decomposed rates until §4 decomposition exists or a post-hoc renormalization approach is designed for the existing copula outputs (FGA is already one of copula.py's 9 STATS_ORDER stats — a renormalize-to-team-total approach on the copula's own FGA draws may be viable without waiting for full decomposition; worth exploring as a scoped Phase 2 approach in the next planning pass).
Shipped to a real UI (2026-07-09): user asked to see Phase 1 "in action" — ran it live against all 3 real 2026-07-09 WNBA games (game_state + minutes_conservation), published as an Artifact. That run surfaced a real bug: feeding every "active" roster player (14-21 per team) into the conserved pool split it far thinner than a real ~9-man rotation (A'ja Wilson simulated at ~26 min vs her real ~33+). Fixed with filter_to_rotation() (minutes_conservation.py) — filters to top players by real recent-game-average minutes (floor 4min, cap 10) before conserving; confirmed fix (Wilson → ~29). New live endpoint GET /minutes/{league}/{date}/{game_id} in projections/serve/minutes_projection.py (projections-engine commit 3432915) computes on-demand (a few seconds/game), no persistence. Frontend: nightly-picks' GameReadView.tsx "Projected Minutes" section (commit 93ccf04), WNBA-only, reuses existing .recent-strip UI, verified live in browser against real data.
Also worked around (not patched) a pre-existing bug in slate_scorer._summarize_vegas: /odds/today mixes every league/sport in one response; a NaN moneyline on an unrelated game's row crashes the whole groupby (mh is not None is True for NaN, then int(NaN) throws). minutes_projection.py scopes the odds frame to one game_id before calling it, sidestepping the bug without touching the shared function used by the live daily pipeline.
Step 3 SHIPPED (commit 2191912, 2026-07-10): conserved-pool shot volume (FGA/FTA).
projections/simulate/usage_conservation.py— same conservation mechanism as Step 2, applied to each on-court player's FGA/FTA. Anchor is deliberately the rotation's own summed q50 (median) projection, NOT a market-derived target — investigated live and confirmed no team-level shot-volume/pace anchor exists anywhere in this codebase at serve time (pace is an input FEATURE to each player's FGA model, never a materialized team total); deriving one fromgame_state.py's market points would need a new, unvalidated points-per-shot conversion — documented scope-narrowing, a real future refinement, not the plan's fullest market-anchored vision.- Refactored
minutes_conservation.py's rescale math into a sharedconserve_to_target()— same mechanism, different target semantics; Phase 1 tests pass unchanged (regression-verified). - Verified against the REAL trained WNBA FGA quantile model (
.audit-cache-wnba/models/field_goals_attempted_quantile_v1) + real feature parquet, not just synthetic data: team_target computed to ~76 FGA/game, basketball-plausible. - 7 new tests in
tests/test_simulate/test_usage_conservation.py, including one that directly demonstrates the correlation-fixing property (no sample has all 5 players simultaneously near their own independent q90).
Step 4 (minimal slice) SHIPPED (commit 8af4b0a, 2026-07-10): FG%/FT% component models + points recombination — the audit's own recommended minimal validation slice ("one prop family first, get the calibration check working before touching shrinkage").
- Trained real FG%/FT% quantile models on NBA's walk-forward split (
scripts/train_shooting_pct.py) — genuinely new label/feature plumbing (field_goal_percentage/free_throw_percentagederived from already-ingested made/attempted counts, NaN not 0 on zero-attempt games, shared between bulkassemble()and per-rowrolling_features()via one helper inrolling.py— the existing bulk/per-row parity property test caught real drift on the first attempt, now passes). projections/simulate/rate_draws.py: recombinesPoints = 2×FGA×FG% + 3PM + FTA×FT%, consuming Step 3's already-conserved FGA/FTA directly.- HONEST RESULT, not a win: recombined points MAE 5.270 vs the existing direct model's 5.159 on n=30,445 real held-out NBA test rows — recombination is 2.1% WORSE. Within this team's own 5% regression tolerance but not validated as an improvement. Plausible cause: still-deferred Beta-Binomial EB shrinkage (FG%/FT% are noisier per-game estimates than a points model trained on more signal — exactly the survival mechanism shrinkage exists for). Matches project_feature_experiments's established discipline (USG%/DvP measured and killed the same way) — infra shipped because it's real/correct/tested, NOT because it already beats baseline.
Step 5 SHIPPED (commit 5a424c2, 2026-07-10): joint per-player recombination via conserved FGA/FTA substitution into copula.py's existing 9-stat correlated draw.
projections/simulate/joint_recombination.py:sample_player_joint()wrapscopula.sample_player_gameunchanged, optionally overriding FGA/FTA columns with Step 3's conserved arrays. Omitting both = byte-identical to calling copula directly.- Real mid-implementation design finding, deviated from the originally-approved plan: the plan called for a rank/percentile splice (via
marginals.cdf_row) to preserve within-player correlation between FGA and the other 8 stats. Investigated and REJECTED:slate_scorer.py's live call site reuses ONE rng instance sequentially across the per-player loop, so each player's copula draw is an independent stream — sample index i is NOT the same simulated game across players. A per-player rank-splice would silently break Step 3's cross-player conservation (its most consistently tested property all session). Switched to straight index-aligned substitution instead — exact conservation preserved, FGA/FTA decoupled from the other 7 stats' within-sample correlation as a documented tradeoff, not a silent gap. - Verified against real data: real NBA FGA quantile model, real Step 3 conservation (~106 FGA/game team target), real correlation matrices (
.audit-cache/models/correlations.npz) — team-level FGA sum survives the full joint pipeline for all 10 real rotation players down to float precision. - 147/147 tests pass.
Step 6 (literal rebounds-tie-to-misses / assists-tie-to-makes) explicitly NOT built — confirmed genuinely blocked on infrastructure that doesn't exist (same shape as Step 3's original pace finding): no team-level made/missed-FG total anywhere (team_features.py's efg_pct derives from FGA/FGM/3PM but no separate total is exposed, and it's training-only, not wired to slate_scorer.py); offensive_rebounds/defensive_rebounds exist in raw boxscore data but are used ONLY as a pace-formula input — no ORB/DRB split model exists, only the combined rebounds STATS_ORDER target. Would need two new split models + a team make/miss pool — a separate infrastructure project comparable in scope to Step 4, not attempted.
§6 allocation-compare: unblocked by Step 5 landing (per the plan's own gating), but NOT started — a new, substantial piece in its own right.
§6 SHIPPED — TASK COMPLETE (commit 0bc42f8, 2026-07-10): allocation comparison & candidate generation, the plan's final piece. Task #9 closed.
projections/simulate/allocation.py:model_implied_share/market_implied_share(each player's q50 ormedian_lineas a fraction of the roster/priced-players total) +generate_allocation_candidates(ranked by|gap|, reason string, separateno_market_linebucket for the plan's own explicit "pure model territory" case).- Two scope-simplifying findings vs. what was feared: (1) market-side input needed ZERO new infra —
ingest/odds.py'sfetch_oddsalready serves live cross-book-consensusmedian_lineper player-prop, confirmed against a real 2026-07-09 pull (69 WNBA players with real posted points lines); (2) model-side input needed ZERO Monte Carlo sampling — a share is a deterministic ratio of existing trained-model q50s, same self-referential anchor as Step 3's FGA target, applied to points (deepest market coverage of any stat). - Verified against real, live, MATCHING data: a real WNBA roster's real trained points q50s cross-referenced against the SAME date's real live posted points lines (5 of 12 players overlapped) — both sides' shares summed to exactly 1.0, ranked gaps and reason strings rendered correctly, no-market-line bucket worked as designed.
- Honest interpretive caveat surfaced by the live check (not a bug): when only a subset of a roster has posted lines, the market-side denominator is smaller than the model's full-roster denominator, inflating market share for priced players — gap numbers are most trustworthy when line coverage is broad. Documented in commit message, not silently discovered-and-ignored.
Final state of the full 6-step + §6 build: steps 1/2/3/5/6 shipped and real-data-verified; step 4 shipped as tested infrastructure but MEASURED WORSE than the existing direct model (2.1% regression) — not wired into anything live, honestly flagged. Nothing from this simulator build is wired into slate_scorer.py's live production call site yet — that's a deliberate, separate future decision, consistent with every step's "build + validate the function, don't force a serving decision" posture this session.
Allocation injury-awareness SHIPPED (projections-engine 0ca57ea, 2026-07-10): allocation.py gains drop_out_players() (filter Out/Doubtful from BOTH sides before share normalization — a non-playing player must not deflate teammates' shares) and injury_status_by_pid on generate_allocation_candidates (every entry annotated; Questionable/Day-to-Day leans get an automatic CAUTION in the reason — their line already prices injury risk, so the gap may be discount, not mispricing; motivating example: Wilson Q, 23.5 line vs 29.0 median = fake +7pp lean). Status sets duplicated from ingest/injuries.py with a sync test (simulate/ stays boto3-free). Verified on the real 2026-07-09 slate + report.
If a future session picks this back up: (1) Beta-Binomial EB shrinkage on FG%/FT% is the most promising lever to fix Step 4's regression (genuinely absent from this repo, confirmed 3x); (2) extending §6 to rebounds/assists is cheap (same functions, stat-agnostic); (3) literal Step 6 (rebounds-tie-to-misses/assists-tie-to-makes) needs new ORB/DRB split models + a team make/miss pool — a separate infra project; (4) §6's CLV backtest validation gate is blocked on fetch_odds's ~2026-01-15+ coverage and would need grading-service's CLV harness (cross-repo work).