Book Comparison Phase 1-3(backend): fenced per-book store + honest gated crown
Per-book prices existed only transiently (odds cache, ~1h, raw names, grade-path
input); every grade-path persistence point collapses to one book. The
/api/books feature was built+mounted but non-functional (fed FLAT rows to a
GROUPED comparator -> always empty).
Phase 1: bookPriceStore captures per-book prices from `props` BEFORE dedupeProps,
keyed nameKey|stat, into bookprices:{sport} (SNAP_TTL) in snapshotService. Fenced:
reads props, writes its own key, read by nothing on the grade path. Grade proven
byte-identical (test + no-grade-path-reference grep test).
Phase 2: scripts/measure-book-spread.js reports same-line best-vs-worst spread
(cents + implied-prob pts), per sport, never pooled. Pre-registered crown
threshold: median >=8c OR >=2pp. Runs post-deploy on real data.
Phase 3 (backend): compareProp is honest-absent (single-book/flat -> no crown)
and the crown is gated (BOOK_CROWN_ENABLED, default OFF until Phase 2 clears).
/api/books repointed to the snapshot-locked store (fallback odds cache),
nameKey-matched; `source` field is the deploy fingerprint.
HELD unchanged: dedupeProps, snapshot dedup, selector, grade, champion,
challengers, ranking, edge_pct/ev_pct. UI routing of BookComparison + crown
treatment deferred to post-measurement (gated on Phase 2). Full suite 3834 green,
web build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsztNChZ7vEvSR61AuMhD1
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* measure-book-spread — Book Comparison order, Phase 2 (GATES THE CROWN).
|
||||
*
|
||||
* Reads the snapshot-locked `bookprices:{sport}` store (Phase 1) — falling back
|
||||
* to the transient `odds:{sport}:{utcDate}` cache — and reports, PER SPORT, the
|
||||
* best-vs-worst PRICE spread among books posting the SAME line for the same
|
||||
* side:
|
||||
* - median + distribution + tail, in American cents AND implied-prob points
|
||||
* - how often the spread is exactly zero
|
||||
* - book-count histogram per prop
|
||||
* - pinnacle presence (captured, not built on — this order)
|
||||
*
|
||||
* PRE-REGISTERED CROWN THRESHOLD (do NOT lower it to make the crown appear):
|
||||
* the crown ships for a sport ONLY if median same-line spread
|
||||
* >= 8 American cents OR >= 2.0 implied-probability points.
|
||||
*
|
||||
* Never pools sports. Reports n + effective sample on every figure.
|
||||
*
|
||||
* Redis runs degraded locally (no live data) → this exits 0 cleanly rather than
|
||||
* hanging on a reconnect timer (the verify-grade-range.js precedent). Run it
|
||||
* post-deploy against prod Redis, after inducing a snapshot.
|
||||
*
|
||||
* node scripts/measure-book-spread.js [sport ...] (default: mlb wnba nba)
|
||||
*/
|
||||
|
||||
const SPORTS = process.argv.slice(2).filter(Boolean);
|
||||
const DEFAULT_SPORTS = ['mlb', 'wnba', 'nba', 'soccer'];
|
||||
const CROWN_CENTS = 8;
|
||||
const CROWN_PROB_PTS = 2.0;
|
||||
|
||||
/** American → implied probability (0..1). Includes the vig. */
|
||||
function impliedProb(a) {
|
||||
if (a == null || !Number.isFinite(Number(a)) || Number(a) === 0) return null;
|
||||
const n = Number(a);
|
||||
return n > 0 ? 100 / (n + 100) : Math.abs(n) / (Math.abs(n) + 100);
|
||||
}
|
||||
|
||||
function median(xs) {
|
||||
if (!xs.length) return null;
|
||||
const s = [...xs].sort((a, b) => a - b);
|
||||
const m = Math.floor(s.length / 2);
|
||||
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
|
||||
}
|
||||
function pct(xs, p) {
|
||||
if (!xs.length) return null;
|
||||
const s = [...xs].sort((a, b) => a - b);
|
||||
return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))];
|
||||
}
|
||||
|
||||
function entriesFrom(store, oddsCache) {
|
||||
// Phase-1 store shape: { props: [{ player, stat_type, books:[{book,line,over_odds,under_odds}] }] }
|
||||
if (store && Array.isArray(store.props)) return store.props;
|
||||
// Fallback: group the flat odds cache the same way.
|
||||
const flat = oddsCache && Array.isArray(oddsCache.props) ? oddsCache.props : [];
|
||||
const by = new Map();
|
||||
for (const p of flat) {
|
||||
if (!p || !p.player || !p.stat_type || p.line == null || !p.book) continue;
|
||||
const k = `${p.player}|${p.stat_type}`;
|
||||
if (!by.has(k)) by.set(k, { player: p.player, stat_type: p.stat_type, books: [] });
|
||||
by.get(k).books.push({ book: p.book, line: p.line, over_odds: p.over_odds, under_odds: p.under_odds });
|
||||
}
|
||||
return [...by.values()];
|
||||
}
|
||||
|
||||
function measureSport(entries) {
|
||||
const centsSpreads = [];
|
||||
const probSpreads = [];
|
||||
const bookCountHist = {};
|
||||
let sharedLineProps = 0;
|
||||
let zeroSpread = 0;
|
||||
let pinnacleRows = 0;
|
||||
let totalProps = 0;
|
||||
|
||||
for (const e of entries) {
|
||||
totalProps += 1;
|
||||
const books = e.books || [];
|
||||
if (books.some((b) => b.book === 'pinnacle')) pinnacleRows += 1;
|
||||
const nBooks = new Set(books.map((b) => b.book)).size;
|
||||
bookCountHist[nBooks] = (bookCountHist[nBooks] || 0) + 1;
|
||||
|
||||
// Group this prop's book rows by line; a shared line = ≥2 books at one line.
|
||||
const byLine = {};
|
||||
for (const b of books) {
|
||||
const L = String(b.line);
|
||||
(byLine[L] = byLine[L] || []).push(b);
|
||||
}
|
||||
let contributed = false;
|
||||
for (const rows of Object.values(byLine)) {
|
||||
const distinctBooks = new Set(rows.map((r) => r.book));
|
||||
if (distinctBooks.size < 2) continue;
|
||||
for (const side of ['over_odds', 'under_odds']) {
|
||||
const prices = rows.map((r) => r[side]).filter((v) => v != null && Number.isFinite(Number(v))).map(Number);
|
||||
if (prices.length < 2) continue;
|
||||
// Best price for a bettor = highest implied payout = LOWEST implied prob.
|
||||
const probs = prices.map(impliedProb).filter((v) => v != null);
|
||||
if (probs.length < 2) continue;
|
||||
const probSpread = (Math.max(...probs) - Math.min(...probs)) * 100; // points
|
||||
probSpreads.push(+probSpread.toFixed(3));
|
||||
// American cents: meaningful when same-sign; use nominal max-min.
|
||||
const centSpread = Math.max(...prices) - Math.min(...prices);
|
||||
centsSpreads.push(Math.abs(centSpread));
|
||||
if (probSpread < 1e-9) zeroSpread += 1;
|
||||
contributed = true;
|
||||
}
|
||||
}
|
||||
if (contributed) sharedLineProps += 1;
|
||||
}
|
||||
|
||||
const nEff = probSpreads.length; // side-level shared-line comparisons
|
||||
const verdictCents = median(centsSpreads);
|
||||
const verdictProb = median(probSpreads);
|
||||
const crownShips = nEff > 0 && ((verdictCents != null && verdictCents >= CROWN_CENTS) || (verdictProb != null && verdictProb >= CROWN_PROB_PTS));
|
||||
|
||||
return {
|
||||
totalProps,
|
||||
sharedLineProps,
|
||||
nEff,
|
||||
zeroSpread,
|
||||
pctZero: nEff ? +(100 * zeroSpread / nEff).toFixed(1) : null,
|
||||
bookCountHist,
|
||||
pinnacleRows,
|
||||
cents: { median: verdictCents, p75: pct(centsSpreads, 75), p90: pct(centsSpreads, 90), max: centsSpreads.length ? Math.max(...centsSpreads) : null },
|
||||
prob: { median: verdictProb, p75: pct(probSpreads, 75), p90: pct(probSpreads, 90), max: probSpreads.length ? Math.max(...probSpreads) : null },
|
||||
crownShips,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let cacheGet;
|
||||
try {
|
||||
({ cacheGet } = require('../src/utils/redis'));
|
||||
} catch (e) {
|
||||
console.log('[measure] redis util unavailable — nothing to measure.');
|
||||
process.exit(0);
|
||||
}
|
||||
const sports = SPORTS.length ? SPORTS : DEFAULT_SPORTS;
|
||||
const utcDate = new Date().toISOString().split('T')[0];
|
||||
const report = {};
|
||||
for (const sp of sports) {
|
||||
let store = null; let oddsCache = null;
|
||||
try { store = await cacheGet(`bookprices:${sp}`); } catch { /* degraded */ }
|
||||
if (!store) { try { oddsCache = (await cacheGet(`odds:${sp}:${utcDate}`)) || (await cacheGet(`odds:${sp}`)); } catch { /* degraded */ } }
|
||||
const entries = entriesFrom(store, oddsCache);
|
||||
report[sp] = { source: store ? 'bookprices' : (oddsCache ? 'odds-cache' : 'none'), ...measureSport(entries) };
|
||||
}
|
||||
|
||||
console.log('\n=== BOOK-PRICE SPREAD (Phase 2) — never pooled ===');
|
||||
for (const sp of sports) {
|
||||
const r = report[sp];
|
||||
console.log(`\n--- ${sp.toUpperCase()} (source: ${r.source}) ---`);
|
||||
if (r.source === 'none' || r.totalProps === 0) { console.log(' no captured data (run post-deploy after a snapshot)'); continue; }
|
||||
console.log(` props: ${r.totalProps} | with ≥2 books at a shared line: ${r.sharedLineProps} | side-level comparisons n=${r.nEff}`);
|
||||
console.log(` book-count histogram: ${JSON.stringify(r.bookCountHist)}`);
|
||||
console.log(` pinnacle present on: ${r.pinnacleRows} props`);
|
||||
console.log(` spread exactly zero: ${r.zeroSpread}/${r.nEff} (${r.pctZero}%)`);
|
||||
console.log(` American cents — median ${r.cents.median} | p75 ${r.cents.p75} | p90 ${r.cents.p90} | max ${r.cents.max}`);
|
||||
console.log(` implied-prob pt — median ${r.prob.median} | p75 ${r.prob.p75} | p90 ${r.prob.p90} | max ${r.prob.max}`);
|
||||
console.log(` CROWN THRESHOLD (median ≥${CROWN_CENTS}c OR ≥${CROWN_PROB_PTS}pp): ${r.crownShips ? 'MET → crown MAY ship' : 'NOT met → crown does NOT ship'}`);
|
||||
}
|
||||
console.log('\n(JSON) ' + JSON.stringify(report));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error('[measure] failed:', e.message); process.exit(0); });
|
||||
+90
-2
@@ -1,6 +1,49 @@
|
||||
# VYNDR — STATE OF THE WORLD
|
||||
### As of `4f3f433` (main, DEPLOYED + fingerprinted live), 2026-07-22. This file opens every future session. **Start with the CURRENT STATUS + OPEN ITEMS block below.**
|
||||
|
||||
---
|
||||
# 🎯 CLV REDIRECT — investigated 2026-07-26. FINDING: it was ALREADY BUILT.
|
||||
*The premise "CLV is dead, redirect it to closing_captures" is STALE. Nothing needed building.*
|
||||
- **Redirect EXISTS + WIRED:** `closingCapture.buildCaptureRows`→`closing_captures` (append-only,
|
||||
provenance: captured_at/book/line_type/both-prices/missed_reason) via `intradayRefreshService:221`
|
||||
+ internal endpoint; `ledgerService.attachClosingProb`→`closing_prob` (de-vigs both raw sides,
|
||||
write-once, honest-absent `market_unavailable_reason`) via `snapshotScheduler:310`.
|
||||
- **Honest-absent is already implemented + is the invalidation marker:** 651,624 capture rows
|
||||
(86% `missed`, no fabricated price); ledger has **59 genuine `closing_prob`, 870
|
||||
`market_unavailable_reason`**. Of 841 old-`clv` rows, **791 are already marked unavailable**;
|
||||
**745 (89%) of old `clv` = exactly 0/'flat'** (the fake "never captured" signal). Old broken
|
||||
`clv`/`closing_line` left in place, unread by the new instrument (but STILL consumed by ledger
|
||||
ROW_COLUMNS/UI — repointing display is a FUTURE order, not done here).
|
||||
- **0.4 GATE:** captures **differ from locked** (10/10 comparable rows moved) → NOT a
|
||||
captures==locked timing defect. BUT the capture **86%-misses** → CLV is STARVED: only **~10-50
|
||||
usable rows**, and only **10 have both `closing_prob` + `fair_prob_lock` (all WNBA, 0 MLB)**.
|
||||
- **LINE-LAG HYPOTHESIS → CANNOT DETERMINE.** The 10 WNBA comparable rows move ~1pp, roughly
|
||||
symmetric (mean CLV gap −0.008 over / −0.011 under). **The +4.57% MLB-C and the over/under
|
||||
asymmetry are NEITHER confirmed nor refuted by CLV** — there is essentially no MLB CLV data.
|
||||
- **The real open defect (separate order):** WHY 86% of captures miss (props leave the feed before
|
||||
the 45-min window? matching failure?). Until that's fixed the instrument stays starved.
|
||||
- Docs corrected: CLAUDE.md (WNBA settles, not MLB-only), STATE.md grade_11 (model_snapshots only).
|
||||
Orphan `next start -p 3111` (6-day-old, the "1 shell running") killed.
|
||||
|
||||
---
|
||||
# 📐 CALIBRATION DIAGNOSIS — measured 2026-07-26 (read-only; nothing changed)
|
||||
*Run on BOTH populations: unselected both-sides (model_snapshots, p_win per side) vs published selected (ledger). Never pooled across sport. Full detail in chat log of that order.*
|
||||
|
||||
**Feasibility caveats that govern every number below:**
|
||||
- p_win window ≈ **last 10 games** (`probabilityEstimator`, last-5 double-weighted) → **SE ≈ 0.16 at p=0.5** (noisy estimator).
|
||||
- Continuous p_win coverage is thin+recent (revived ~07-20): **~18 MLB games, ~5 WNBA games** of joinable calibration data. WNBA calibration is **effectively anecdotal (~5 games)**; MLB is suggestive not confident (~18 games).
|
||||
- Effective sample generally: MLB 31 games / 190 players, **WNBA 12 games / 77 players** — prop outcomes cluster within a game; do not treat rows as independent.
|
||||
|
||||
**Findings (VERIFIED where measured; sample caveats attached):**
|
||||
1. **Selection bias, measured directly** = both-sides→selected Brier: **MLB 0.212→0.233, WNBA 0.258→0.278 (~+0.02 each)**. Published grade is inflated ~1 sub-tier by the side-pick: mean idx **MLB C→C+**, **WNBA C+→B−**. Honest A-or-better ≈ 0 → the "caps at ~B+/A-" story survives (optimistic if anything).
|
||||
2. **MLB calibration** (both-sides): Reliability 0.010 (good), Resolution 0.047 (moderate), Uncertainty 0.249 → **discriminates + roughly calibrated**; middle deciles near-perfect. SELECTED side is **overconfident, growing with p** (+0.02 at p<.5 → **+0.19 at p≥.8**).
|
||||
3. **WNBA calibration** (both-sides): Brier 0.258 **> uncertainty 0.25 = worse than always-predicting-0.5**; Resolution 0.019 (near-noise), Reliability 0.027 (poor). Realized ~flat vs predicted. **Champion does not discriminate on WNBA** — but ~5 games, so unproven.
|
||||
4. **Null baselines (real prices):** **unders lose ~−14% flat both sports; overs near break-even** (MLB −2.8%, WNBA −1.4%). **Ledger strongly supports "no unders by default."** MLB model beats always-over (MLB-C +4.6% vs −2.8%); **WNBA model LOSES to always-over** (−5% vs −1.4%). Both within noise at 31/12 games.
|
||||
5. **ev_pct = p_win×decimal−1 uses the overconfident p_win** → inflated; hero ranks on it → picks the most-overconfident reads. No current metric reliably predicts ROI; price-aware EV requires recalibrated p_win first.
|
||||
|
||||
**BUILD FORK (declared before data; sample-tempered):** MLB → *resolution good / reliability poor-when-selected* → recalibration mapping + EV ranking — **but ~18 games can't validate a holdout yet (test half 15 games)**. WNBA → *resolution poor* → champion doesn't discriminate / price-aware replacement — **but ~5 games = accrue volume before concluding.** **CLV redirect ships next regardless.** Most robust actionable finding now: **kill default unders.**
|
||||
|
||||
|
||||
---
|
||||
|
||||
# 🧭 CURRENT STATUS + OPEN ITEMS — orientation block (2026-07-20, ~03:20 UTC)
|
||||
@@ -19,8 +62,12 @@ was verified live unless explicitly marked UNVERIFIED.*
|
||||
| 6 | ESPN team-stats parser fixed (`buckets is not iterable`, 0/15 → 15/15) | live refresh |
|
||||
|
||||
### ⚠️ Two honest qualifiers on the above
|
||||
- **A STILL DOES NOT EMIT IN PRODUCTION.** The 11-step grade is now *stored*
|
||||
(`grade_11`) and A/D are *arithmetically* reachable + locked by tests, but the
|
||||
- **A STILL DOES NOT EMIT IN PRODUCTION.** The 11-step grade is stored in
|
||||
`model_snapshots.grade_11` ONLY (CORRECTED 2026-07-26: it is NOT in
|
||||
`ledger_entries` — `_grade_11` is deleted at `gradeSlateService.js:97` before
|
||||
the ledger write; sub-tier resolution on a SETTLED ledger row is recoverable
|
||||
only by joining model_snapshots). A/D are *arithmetically* reachable + locked
|
||||
by tests, but the
|
||||
±1.0 opponent factor is still dead because `opp_rank_stat` is underivable from
|
||||
ESPN (see open item 6). Live boards remain **B/C only**.
|
||||
**→ The "A-RATED" marketing hold STANDS** (`AccuracyBadge` correctly falls
|
||||
@@ -1858,6 +1905,47 @@ Dockerfile must copy every dir the runtime reads.
|
||||
`web/src/app/terms/page.tsx` (6×) + `web/src/app/privacy/page.tsx` (5×).
|
||||
A test keeps unverified entity names out until replaced.
|
||||
|
||||
## BOOK COMPARISON — data layer shipped (per the build order)
|
||||
|
||||
**What shipped (backend + tooling; UI routing is the gated next step):**
|
||||
- **Phase 1 — fenced, snapshot-locked per-book store.** `src/services/
|
||||
bookPriceStore.js` captures per-book prices from the multi-book `props` array
|
||||
BEFORE `dedupeProps` runs, keyed by `nameKey|stat_type`, written to
|
||||
`bookprices:{sport}` at SNAP_TTL (24h) inside `snapshotService.runSnapshot`.
|
||||
STRUCTURAL FENCE: it only reads `props` and writes its own key; nothing on the
|
||||
grade path reads it. Proven byte-identical — the graded slate is unchanged with
|
||||
or without the capture (`tests/unit/bookPriceStore.test.js`, incl. a grep test
|
||||
asserting no grade-path file references `bookprices`).
|
||||
- **Phase 2 — the crown is threshold-gated.** `scripts/measure-book-spread.js`
|
||||
reports best-vs-worst same-line price spread (American cents AND implied-prob
|
||||
points), book-count histogram, %-zero, pinnacle presence — per sport, never
|
||||
pooled. PRE-REGISTERED: crown ships only if median ≥8¢ OR ≥2 implied-prob pts.
|
||||
Runs post-deploy against real captured data (Redis degraded locally → exits 0).
|
||||
- **Phase 3 (backend) — honest-absent + gated crown.** `bookComparisonService.
|
||||
compareProp` now renders single-book / flat-market props with NO crown; the
|
||||
crown fires only among ≥2 books at the SAME line with DIFFERING prices, and only
|
||||
when `BOOK_CROWN_ENABLED=1` (default OFF until Phase 2 clears). `/api/books/
|
||||
:sport` (a crown claim) returns [] while gated off; `/api/books/:sport/:player/
|
||||
:stat` returns the honest grid always. Route repointed to read `bookprices:*`
|
||||
(fallback: odds cache), matched by `nameKey`. Response `source` field is the
|
||||
deploy fingerprint (`bookprices` = new store serving).
|
||||
|
||||
**What it revealed:** the premise ("BookComparison.tsx is a dead component") was
|
||||
incomplete — the ENTIRE feature (service + `/api/books` routes + Next proxy) was
|
||||
built and mounted, but NON-FUNCTIONAL end-to-end: the route fed FLAT odds-cache
|
||||
rows to a comparator that expects GROUPED `{lines|books}`, so it always returned
|
||||
empty. Per-book prices were retained only transiently (odds cache, ~1h, raw
|
||||
names, grade-path INPUT); every grade-path persistence point collapses to one
|
||||
book. Fixed by the snapshot-locked store + route repoint.
|
||||
|
||||
**Gated next step (NOT this order-turn):** route `BookComparison.tsx` onto the
|
||||
prop card fed by the store, and design the crown treatment — AFTER Phase 2's
|
||||
measurement returns real spread numbers on prod (you can't honestly design a
|
||||
crown before you know it ships). `BOOK_CROWN_ENABLED` stays 0 until then.
|
||||
HELD unchanged: dedupeProps, snapshot dedup, the selector, the grade, champion,
|
||||
challengers, ranking, edge_pct/ev_pct. No push-to-book, no movement strip, no
|
||||
pinnacle edge test (data persisted only).
|
||||
|
||||
## HONEST OPEN ITEMS
|
||||
|
||||
- **Phase 4.5 — WNBA settlement via ESPN box scores. DUE ~Jul 24** (hard
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
const express = require('express');
|
||||
const bookComparison = require('../services/bookComparisonService');
|
||||
const { cacheGet } = require('../utils/redis');
|
||||
const { nameKey } = require('../utils/playerName');
|
||||
const { createRateLimit } = require('../middleware/rateLimit');
|
||||
|
||||
const router = express.Router();
|
||||
@@ -25,15 +26,31 @@ const MISSION_HEADER = { 'X-VYNDR-Mission': 'Never leave money on the table' };
|
||||
|
||||
const SUPPORTED = new Set(['nba', 'wnba', 'mlb', 'soccer', 'nfl', 'nhl']);
|
||||
|
||||
// Read cached grouped props for a sport without triggering a fetch.
|
||||
// oddsService caches `odds:{sport}:{utcDate}` = { updated_at, props, spreads }.
|
||||
async function readCachedProps(sport) {
|
||||
// Read GROUPED per-prop book rows for a sport without triggering a fetch.
|
||||
//
|
||||
// PRIMARY: the snapshot-locked, display-only `bookprices:{sport}` store (Phase 1)
|
||||
// — its entries are already grouped `{ player, stat_type, books:[...] }`, which
|
||||
// is exactly what compareProp reads. It's normalized-name-keyed and lives at
|
||||
// SNAP_TTL, so it survives the gap between cron runs (the 1h odds cache would
|
||||
// blank). FALLBACK: group the transient odds cache flat rows the same way, so
|
||||
// the route still works before the first snapshot writes the store.
|
||||
async function readGroupedProps(sport) {
|
||||
const store = await cacheGet(`bookprices:${sport}`);
|
||||
if (store && Array.isArray(store.props) && store.props.length) return store.props;
|
||||
|
||||
const utcDate = new Date().toISOString().split('T')[0];
|
||||
const cache =
|
||||
(await cacheGet(`odds:${sport}:${utcDate}`)) ??
|
||||
(await cacheGet(`odds:${sport}`));
|
||||
if (!cache) return [];
|
||||
return Array.isArray(cache.props) ? cache.props : [];
|
||||
const flat = cache && Array.isArray(cache.props) ? cache.props : [];
|
||||
const by = new Map();
|
||||
for (const p of flat) {
|
||||
if (!p || !p.player || !p.stat_type || p.line == null || !p.book) continue;
|
||||
const k = `${p.player}|${p.stat_type}`;
|
||||
if (!by.has(k)) by.set(k, { player: p.player, stat_type: p.stat_type, books: [] });
|
||||
by.get(k).books.push({ book: p.book, line: p.line, over_odds: p.over_odds, under_odds: p.under_odds });
|
||||
}
|
||||
return [...by.values()];
|
||||
}
|
||||
|
||||
router.get('/:sport', async (req, res) => {
|
||||
@@ -44,9 +61,12 @@ router.get('/:sport', async (req, res) => {
|
||||
const side = req.query.side === 'under' ? 'under' : 'over';
|
||||
const limit = req.query.limit ? Math.max(0, parseInt(req.query.limit, 10) || 0) : 20;
|
||||
try {
|
||||
const props = await readCachedProps(sport);
|
||||
const hasStore = !!(await cacheGet(`bookprices:${sport}`));
|
||||
const props = await readGroupedProps(sport);
|
||||
const lines = bookComparison.bestLines(props, { side, limit });
|
||||
return res.set(MISSION_HEADER).json({ sport, side, bestLines: lines, source: 'odds-cache' });
|
||||
// `source` is the deploy fingerprint: 'bookprices' proves the new snapshot-
|
||||
// locked store is serving; 'odds-cache' = falling back pre-first-snapshot.
|
||||
return res.set(MISSION_HEADER).json({ sport, side, bestLines: lines, source: hasStore ? 'bookprices' : 'odds-cache' });
|
||||
} catch (err) {
|
||||
console.error(`[books/${sport}]`, err.message);
|
||||
return res.set(MISSION_HEADER).json({ sport, side, bestLines: [], source: 'odds-cache' });
|
||||
@@ -59,10 +79,14 @@ router.get('/:sport/:player/:stat', async (req, res) => {
|
||||
const stat = req.params.stat;
|
||||
const side = req.query.side === 'under' ? 'under' : 'over';
|
||||
try {
|
||||
const props = await readCachedProps(sport);
|
||||
const props = await readGroupedProps(sport);
|
||||
// Match on the normalized name key so "A.J. Ewing" / "AJ Ewing" resolve to
|
||||
// the same player (the store is already normalized-keyed).
|
||||
const wantKey = nameKey(player);
|
||||
const wantStat = stat.toLowerCase();
|
||||
const prop = props.find(
|
||||
(p) => (p.player || '').toLowerCase() === player.toLowerCase() &&
|
||||
(p.stat_type || p.stat || '').toLowerCase() === stat.toLowerCase(),
|
||||
(p) => nameKey(p.player || '') === wantKey &&
|
||||
(p.stat_type || p.stat || '').toLowerCase() === wantStat,
|
||||
);
|
||||
if (!prop) {
|
||||
return res.status(404).set(MISSION_HEADER).json({ error: 'Prop not found in current slate.' });
|
||||
|
||||
@@ -30,22 +30,73 @@ function oddsForSide(line, side) {
|
||||
return raw == null ? null : Number(raw);
|
||||
}
|
||||
|
||||
// Book Comparison order — the crown is PRE-REGISTERED and threshold-gated. It
|
||||
// stays OFF until Phase 2's spread measurement (scripts/measure-book-spread.js)
|
||||
// confirms the median same-line spread clears the bar (≥8¢ OR ≥2 implied-prob
|
||||
// pts). A crown over a flat market is a claim of edge that does not exist.
|
||||
function crownEnabled(opts) {
|
||||
if (opts && opts.crownEnabled != null) return !!opts.crownEnabled;
|
||||
return process.env.BOOK_CROWN_ENABLED === '1';
|
||||
}
|
||||
|
||||
// Render every book row honestly, with NO crown. Used for single-book props,
|
||||
// props with no shared line, and whenever the crown flag is off.
|
||||
function honestGrid(prop, side, books) {
|
||||
const rows = books.map((b) => ({
|
||||
book: b.book,
|
||||
line: b.line ?? null,
|
||||
over_odds: b.over_odds ?? null,
|
||||
under_odds: b.under_odds ?? null,
|
||||
isBest: false,
|
||||
}));
|
||||
return {
|
||||
player: prop.player,
|
||||
stat: prop.stat_type || prop.stat,
|
||||
line: prop.line ?? (books[0] && books[0].line) ?? null,
|
||||
side,
|
||||
books: rows,
|
||||
bestBook: null,
|
||||
bestOdds: null,
|
||||
bookCount: new Set(books.map((b) => b.book)).size,
|
||||
savings: 0,
|
||||
crowned: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare one grouped prop across its books for a given side.
|
||||
* Returns null when there are no usable book lines.
|
||||
* Returns null when there are no usable book rows at all.
|
||||
*
|
||||
* HONEST-ABSENT: a single-book prop renders that one book — no crown, no implied
|
||||
* second price, no savings claim. The crown fires ONLY among ≥2 books posting
|
||||
* the SAME line with DIFFERING prices, and ONLY when the flag is enabled (Phase 2
|
||||
* gate). Comparing prices across different lines is meaningless (Data Semantics).
|
||||
*/
|
||||
function compareProp(prop, side = 'over') {
|
||||
function compareProp(prop, side = 'over', opts = {}) {
|
||||
const books = (prop?.lines || prop?.books || []).filter((b) => b && b.book);
|
||||
const priced = books.filter((b) => Number.isFinite(oddsForSide(b, side)));
|
||||
if (priced.length === 0) return null;
|
||||
if (books.length === 0) return null;
|
||||
|
||||
let best = priced[0];
|
||||
for (const b of priced) {
|
||||
const priced = books.filter((b) => Number.isFinite(oddsForSide(b, side)));
|
||||
// Single priced book, or crown disabled → honest grid, no crown.
|
||||
if (priced.length < 2 || !crownEnabled(opts)) return honestGrid(prop, side, books);
|
||||
|
||||
// Crown only among books at the SAME line (the shared-line comparison set).
|
||||
const byLine = {};
|
||||
for (const b of priced) { const L = String(b.line); (byLine[L] = byLine[L] || []).push(b); }
|
||||
let shared = [];
|
||||
for (const rows of Object.values(byLine)) { if (rows.length > shared.length) shared = rows; }
|
||||
const distinctBooks = new Set(shared.map((b) => b.book));
|
||||
const distinctPrices = new Set(shared.map((b) => oddsForSide(b, side)));
|
||||
// Fewer than 2 books at one line, or all books post the identical price →
|
||||
// nothing to crown. Render honestly.
|
||||
if (distinctBooks.size < 2 || distinctPrices.size < 2) return honestGrid(prop, side, books);
|
||||
|
||||
let best = shared[0];
|
||||
for (const b of shared) {
|
||||
if (americanToDecimal(oddsForSide(b, side)) > americanToDecimal(oddsForSide(best, side))) best = b;
|
||||
}
|
||||
|
||||
const bestDecimal = americanToDecimal(oddsForSide(best, side));
|
||||
const avgDecimal = average(priced.map((b) => americanToDecimal(oddsForSide(b, side))));
|
||||
const avgDecimal = average(shared.map((b) => americanToDecimal(oddsForSide(b, side))));
|
||||
const savings = Math.round((bestDecimal - avgDecimal) * 100 * 100) / 100; // per $100, 2dp
|
||||
|
||||
return {
|
||||
@@ -53,17 +104,19 @@ function compareProp(prop, side = 'over') {
|
||||
stat: prop.stat_type || prop.stat,
|
||||
line: best.line ?? prop.line ?? null,
|
||||
side,
|
||||
books: priced.map((b) => ({
|
||||
// Show all books; crown only the best AT THE SHARED LINE.
|
||||
books: books.map((b) => ({
|
||||
book: b.book,
|
||||
line: b.line ?? null,
|
||||
over_odds: b.over_odds ?? null,
|
||||
under_odds: b.under_odds ?? null,
|
||||
isBest: b.book === best.book,
|
||||
isBest: b.book === best.book && Number(b.line) === Number(best.line),
|
||||
})),
|
||||
bestBook: best.book,
|
||||
bestOdds: oddsForSide(best, side),
|
||||
bookCount: priced.length,
|
||||
bookCount: distinctBooks.size,
|
||||
savings,
|
||||
crowned: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -72,12 +125,15 @@ function compareProp(prop, side = 'over') {
|
||||
* shopping matters most). Drops props with a single book (nothing to
|
||||
* compare). `limit` caps the result.
|
||||
*/
|
||||
function bestLines(props, { side = 'over', limit = 20 } = {}) {
|
||||
function bestLines(props, { side = 'over', limit = 20, crownEnabled: ce } = {}) {
|
||||
if (!Array.isArray(props)) return [];
|
||||
// "Best lines tonight" is inherently a crown claim — when the crown is gated
|
||||
// off (Phase 2 not yet cleared) it makes no such claim.
|
||||
if (!crownEnabled({ crownEnabled: ce })) return [];
|
||||
const out = [];
|
||||
for (const prop of props) {
|
||||
const cmp = compareProp(prop, side);
|
||||
if (cmp && cmp.bookCount >= 2) out.push(cmp);
|
||||
const cmp = compareProp(prop, side, { crownEnabled: ce });
|
||||
if (cmp && cmp.crowned && cmp.bookCount >= 2) out.push(cmp);
|
||||
}
|
||||
out.sort((a, b) => b.savings - a.savings);
|
||||
return limit > 0 ? out.slice(0, limit) : out;
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* bookPriceStore — DISPLAY-ONLY per-book price capture (Book Comparison order,
|
||||
* Phase 1).
|
||||
*
|
||||
* WHY THIS EXISTS: `normalizeProps` emits one row per book, but every
|
||||
* persistence point on the GRADE path collapses to a single book
|
||||
* (gradeSlateService.dedupeProps keeps the first book per line;
|
||||
* snapshotService.indexOdds keeps one row per player|stat; the grades cache,
|
||||
* snapshot, and ledger each lock one price). The only place per-book prices
|
||||
* survived was the transient odds cache (`odds:{sport}:{utcDate}`, ~1h TTL,
|
||||
* raw player names, grade-path INPUT). This module captures them at snapshot
|
||||
* time into a snapshot-locked, normalized-key, display-only store.
|
||||
*
|
||||
* STRUCTURAL FENCE (not a comment — an enforced property):
|
||||
* 1. This module ONLY reads a props array and RETURNS a new object. It never
|
||||
* mutates the props it is handed (a bookPriceStore.test.js case deep-freezes
|
||||
* the input and asserts capture succeeds).
|
||||
* 2. Its output is written to its OWN Redis key (`bookprices:{sport}`), read by
|
||||
* NOTHING on the grade path — not gradeSlateService, snapshotService's dedup,
|
||||
* indexOdds, any challenger, the ledger, edge_pct/ev_pct, or the selector.
|
||||
* A test greps the grade-path files and asserts zero `bookprices` references.
|
||||
* 3. In runSnapshot the capture is a leaf `cacheSet` whose result is assigned to
|
||||
* no variable used downstream — the grade-byte-identical test proves the
|
||||
* graded slate is unchanged whether or not the capture runs.
|
||||
*
|
||||
* So no value this file produces can ever reach a grade, a locked line, a
|
||||
* challenger, the ledger, or the hero. It is a pure display leaf.
|
||||
*/
|
||||
|
||||
const { nameKey, normalizeName } = require('../utils/playerName');
|
||||
|
||||
/** Canonical lookup key for a graded prop's book rows. */
|
||||
function bookKey(player, statType) {
|
||||
return `${nameKey(player)}|${String(statType || '').toLowerCase()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group a flat multi-book props array (oddsNormalizer shape) into one entry per
|
||||
* normalized player+stat, each carrying every real book row. Rows with no price
|
||||
* on EITHER side are dropped (nothing to compare — honest-absent, never a
|
||||
* fabricated price). Never mutates `props`.
|
||||
*
|
||||
* @returns {{ updated_at, props: Array, count }}
|
||||
*/
|
||||
function captureBookPrices(props, opts = {}) {
|
||||
const now = opts.now || (() => new Date().toISOString());
|
||||
const ts = now();
|
||||
const byKey = new Map();
|
||||
|
||||
for (const p of props || []) {
|
||||
if (!p || !p.player || !p.stat_type || p.line == null || !p.book) continue;
|
||||
// A row with neither price is not a comparable book row.
|
||||
if (p.over_odds == null && p.under_odds == null) continue;
|
||||
|
||||
const stat = String(p.stat_type).toLowerCase();
|
||||
const key = bookKey(p.player, stat);
|
||||
if (!byKey.has(key)) {
|
||||
byKey.set(key, {
|
||||
key,
|
||||
player: normalizeName(p.player).display || p.player,
|
||||
stat_type: stat,
|
||||
books: [],
|
||||
});
|
||||
}
|
||||
const entry = byKey.get(key);
|
||||
// A book appears once per line. Keep the FIRST row for a given book+line
|
||||
// (mirrors dedupeProps' first-wins, applied here only for display de-dup —
|
||||
// it does NOT feed grading).
|
||||
const bl = `${p.book}|${p.line}`;
|
||||
if (entry.books.some((b) => `${b.book}|${b.line}` === bl)) continue;
|
||||
entry.books.push({
|
||||
book: p.book,
|
||||
line: Number(p.line),
|
||||
over_odds: p.over_odds ?? null,
|
||||
under_odds: p.under_odds ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
const out = [...byKey.values()];
|
||||
return { updated_at: ts, props: out, count: out.length };
|
||||
}
|
||||
|
||||
/** Books posting a given line for a prop entry (shared-line comparison set). */
|
||||
function booksAtLine(entry, line) {
|
||||
if (!entry || !Array.isArray(entry.books) || line == null) return [];
|
||||
const L = Number(line);
|
||||
return entry.books.filter((b) => Number(b.line) === L);
|
||||
}
|
||||
|
||||
module.exports = { captureBookPrices, bookKey, booksAtLine };
|
||||
@@ -284,6 +284,10 @@ async function runSnapshot(sport, opts = {}) {
|
||||
// suite that doesn't know about this dep can never make a live ESPN call.
|
||||
// Session 64 — model-snapshot retention. Injectable; null disables it.
|
||||
retention: opts.retention !== undefined ? opts.retention : require('./retentionService'),
|
||||
// Book Comparison order (Phase 1) — DISPLAY-ONLY per-book price capture.
|
||||
// Fenced: written to its own `bookprices:{sport}` key, read by nothing on
|
||||
// the grade path. Injectable; a failure never touches grading.
|
||||
captureBookPrices: opts.captureBookPrices || require('./bookPriceStore').captureBookPrices,
|
||||
refreshTeamStats: opts.refreshTeamStats
|
||||
|| (process.env.NODE_ENV === 'test'
|
||||
? async () => null
|
||||
@@ -335,6 +339,21 @@ async function runSnapshot(sport, opts = {}) {
|
||||
console.warn(`[snapshot] game binding failed for ${sp} (rows without a real game time will be skipped):`, e.message);
|
||||
}
|
||||
|
||||
// Book Comparison order (Phase 1) — CAPTURE PER-BOOK PRICES BEFORE DEDUP.
|
||||
// `props` still carries one row per book here (dedupeProps runs downstream
|
||||
// inside gradeAndCacheSlate). The capture only READS props and writes its own
|
||||
// `bookprices:{sport}` key at SNAP_TTL — long enough to outlive the cron gap
|
||||
// (the 1h odds cache would blank between runs). Best-effort and structurally
|
||||
// fenced: nothing on the grade path reads this key, and the graded slate is
|
||||
// byte-identical whether or not this runs (bookPriceStore.test.js locks it).
|
||||
try {
|
||||
const bookStore = deps.captureBookPrices(props, { now: deps.now });
|
||||
await deps.cacheSet(`bookprices:${sp}`, bookStore, SNAP_TTL);
|
||||
console.log(`[snapshot] book prices ${sp}: ${bookStore.count} props with book rows captured`);
|
||||
} catch (e) {
|
||||
console.warn(`[snapshot] book-price capture failed for ${sp} (display-only, grading continues):`, e.message);
|
||||
}
|
||||
|
||||
// Session 63 — REFRESH TEAM STATS BEFORE GRADING.
|
||||
// `refreshTeamStats` is the ONLY writer of `team_stats:{sport}:{abbr}`, which
|
||||
// is the ONLY source of `opp_rank_stat` — and it had zero production callers,
|
||||
|
||||
@@ -75,22 +75,55 @@ describe('GET /api/lines/:sport/movers', () => {
|
||||
describe('GET /api/books/:sport', () => {
|
||||
const app = () => mount('/api/books', '../../src/routes/bookComparison');
|
||||
|
||||
test('returns best lines from cached props', async () => {
|
||||
mockStore[`odds:nba:${new Date().toISOString().split('T')[0]}`] = {
|
||||
// Book Comparison order: /api/books/:sport is a crown claim ("best lines
|
||||
// tonight"), so it is gated OFF until Phase 2's spread measurement clears the
|
||||
// bar — even with data it returns []. `source` is the deploy fingerprint.
|
||||
test('reads the snapshot-locked bookprices store; makes no crown claim while gated off', async () => {
|
||||
mockStore['bookprices:nba'] = {
|
||||
props: [
|
||||
{
|
||||
player: 'Wemby', stat_type: 'points',
|
||||
lines: [
|
||||
{ book: 'dk', over_odds: -110 },
|
||||
{ book: 'fd', over_odds: -105 },
|
||||
],
|
||||
},
|
||||
{ player: 'Wemby', stat_type: 'points', books: [
|
||||
{ book: 'draftkings', line: 28.5, over_odds: -110 },
|
||||
{ book: 'fanduel', line: 28.5, over_odds: -105 },
|
||||
] },
|
||||
],
|
||||
};
|
||||
const res = await request(app()).get('/api/books/nba');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.source).toBe('bookprices'); // new store is serving
|
||||
expect(res.body.bestLines).toEqual([]); // crown gated off → no claim
|
||||
});
|
||||
|
||||
test('crown ENABLED → crowns the best price from the store', async () => {
|
||||
process.env.BOOK_CROWN_ENABLED = '1';
|
||||
mockStore['bookprices:nba'] = {
|
||||
props: [
|
||||
{ player: 'Wemby', stat_type: 'points', books: [
|
||||
{ book: 'draftkings', line: 28.5, over_odds: -110 },
|
||||
{ book: 'fanduel', line: 28.5, over_odds: -105 },
|
||||
] },
|
||||
],
|
||||
};
|
||||
const res = await request(app()).get('/api/books/nba');
|
||||
delete process.env.BOOK_CROWN_ENABLED;
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.bestLines).toHaveLength(1);
|
||||
expect(res.body.bestLines[0].bestBook).toBe('fd');
|
||||
expect(res.body.bestLines[0].bestBook).toBe('fanduel');
|
||||
});
|
||||
|
||||
test('per-prop endpoint returns the honest grid (all books, no crown) even gated off', async () => {
|
||||
mockStore['bookprices:nba'] = {
|
||||
props: [
|
||||
{ player: 'Wemby', stat_type: 'points', books: [
|
||||
{ book: 'draftkings', line: 28.5, over_odds: -110 },
|
||||
{ book: 'fanduel', line: 28.5, over_odds: -105 },
|
||||
] },
|
||||
],
|
||||
};
|
||||
const res = await request(app()).get('/api/books/nba/Wemby/points');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.books).toHaveLength(2);
|
||||
expect(res.body.books.every((b) => b.isBest === false)).toBe(true);
|
||||
expect(res.body.bestBook).toBeNull();
|
||||
});
|
||||
|
||||
test('empty when no cached props', async () => {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
// Unit: book comparison service (Session 28). Pure functions.
|
||||
// Unit: book comparison service. Pure functions.
|
||||
// Book Comparison order (Phase 3) — the crown is now HONEST + threshold-gated:
|
||||
// - single-book / flat-market props render with NO crown
|
||||
// - the crown fires only among ≥2 books at the SAME line with DIFFERING prices
|
||||
// - and only when enabled (BOOK_CROWN_ENABLED / opts.crownEnabled), OFF until
|
||||
// Phase 2's spread measurement clears the bar.
|
||||
// The crown-logic cases below pass { crownEnabled: true } explicitly.
|
||||
|
||||
const { compareProp, bestLines } = require('../../src/services/bookComparisonService');
|
||||
|
||||
@@ -12,9 +18,11 @@ const prop = {
|
||||
],
|
||||
};
|
||||
|
||||
describe('bookComparisonService — compareProp', () => {
|
||||
describe('compareProp — crown enabled (real spread)', () => {
|
||||
const on = { crownEnabled: true };
|
||||
test('identifies the best OVER line (highest payout)', () => {
|
||||
const c = compareProp(prop, 'over');
|
||||
const c = compareProp(prop, 'over', on);
|
||||
expect(c.crowned).toBe(true);
|
||||
expect(c.bestBook).toBe('fanduel'); // -105 pays more than -110/-120
|
||||
expect(c.bestOdds).toBe(-105);
|
||||
expect(c.books.find((b) => b.book === 'fanduel').isBest).toBe(true);
|
||||
@@ -22,42 +30,73 @@ describe('bookComparisonService — compareProp', () => {
|
||||
});
|
||||
|
||||
test('identifies the best UNDER line', () => {
|
||||
const c = compareProp(prop, 'under');
|
||||
const c = compareProp(prop, 'under', on);
|
||||
expect(c.bestBook).toBe('betmgm'); // -102 pays more than -110/-115
|
||||
expect(c.bestOdds).toBe(-102);
|
||||
});
|
||||
|
||||
test('savings is positive (best beats the field average)', () => {
|
||||
const c = compareProp(prop, 'over');
|
||||
expect(c.savings).toBeGreaterThan(0);
|
||||
expect(compareProp(prop, 'over', on).savings).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareProp — honest-absent', () => {
|
||||
test('single-book prop renders the one book, NO crown', () => {
|
||||
const c = compareProp({ player: 'X', stat_type: 'hits', lines: [{ book: 'dk', line: 0.5, over_odds: -110 }] }, 'over', { crownEnabled: true });
|
||||
expect(c.bookCount).toBe(1);
|
||||
expect(c.bestBook).toBeNull();
|
||||
expect(c.crowned).toBe(false);
|
||||
expect(c.books[0].isBest).toBe(false);
|
||||
expect(c.savings).toBe(0);
|
||||
});
|
||||
|
||||
test('single-book prop still compares (bookCount 1)', () => {
|
||||
const c = compareProp({ player: 'X', stat_type: 'hits', lines: [{ book: 'dk', over_odds: -110 }] }, 'over');
|
||||
expect(c.bookCount).toBe(1);
|
||||
expect(c.bestBook).toBe('dk');
|
||||
test('crown DISABLED by default → multi-book prop shows all books, none crowned', () => {
|
||||
const c = compareProp(prop, 'over'); // no opts → env default (off in tests)
|
||||
expect(c.crowned).toBe(false);
|
||||
expect(c.books.every((b) => b.isBest === false)).toBe(true);
|
||||
expect(c.bestBook).toBeNull();
|
||||
});
|
||||
|
||||
test('identical prices at the same line → no crown even when enabled', () => {
|
||||
const flat = { player: 'X', stat_type: 'hits', lines: [
|
||||
{ book: 'draftkings', line: 0.5, over_odds: -110, under_odds: -110 },
|
||||
{ book: 'fanduel', line: 0.5, over_odds: -110, under_odds: -110 },
|
||||
] };
|
||||
expect(compareProp(flat, 'over', { crownEnabled: true }).crowned).toBe(false);
|
||||
});
|
||||
|
||||
test('no usable lines → null, not crash', () => {
|
||||
expect(compareProp({ player: 'X', stat_type: 'hits', lines: [] }, 'over')).toBeNull();
|
||||
expect(compareProp({ player: 'X', stat_type: 'hits' }, 'over')).toBeNull();
|
||||
});
|
||||
|
||||
test('reads a grouped `books` array (bookprices store shape)', () => {
|
||||
const c = compareProp({ player: 'Y', stat_type: 'hits', books: [
|
||||
{ book: 'draftkings', line: 1.5, over_odds: -115 },
|
||||
{ book: 'betmgm', line: 1.5, over_odds: -105 },
|
||||
] }, 'over', { crownEnabled: true });
|
||||
expect(c.bestBook).toBe('betmgm');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bookComparisonService — bestLines', () => {
|
||||
test('drops single-book props and sorts by savings desc', () => {
|
||||
describe('bestLines', () => {
|
||||
test('makes no crown claim when gated off (returns [])', () => {
|
||||
expect(bestLines([prop], { side: 'over', crownEnabled: false })).toEqual([]);
|
||||
});
|
||||
|
||||
test('enabled: drops single-book props and sorts by savings desc', () => {
|
||||
const props = [
|
||||
prop,
|
||||
{ player: 'Solo', stat_type: 'reb', lines: [{ book: 'dk', over_odds: -110 }] }, // 1 book → dropped
|
||||
{ player: 'Solo', stat_type: 'reb', lines: [{ book: 'dk', line: 5.5, over_odds: -110 }] }, // 1 book → dropped
|
||||
{
|
||||
player: 'BigEdge', stat_type: 'ast',
|
||||
lines: [
|
||||
{ book: 'dk', over_odds: -200 },
|
||||
{ book: 'fd', over_odds: +120 }, // huge spread → big savings
|
||||
{ book: 'dk', line: 5.5, over_odds: -200 },
|
||||
{ book: 'fd', line: 5.5, over_odds: +120 }, // huge spread → big savings
|
||||
],
|
||||
},
|
||||
];
|
||||
const lines = bestLines(props, { side: 'over' });
|
||||
const lines = bestLines(props, { side: 'over', crownEnabled: true });
|
||||
expect(lines.map((l) => l.player)).not.toContain('Solo');
|
||||
expect(lines[0].player).toBe('BigEdge'); // biggest savings first
|
||||
});
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// Book Comparison order (Phase 1) — display-only per-book price store.
|
||||
// Locks: capture correctness, the STRUCTURAL FENCE (no mutation, no grade-path
|
||||
// read), and grade-byte-identical proof through runSnapshot.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { captureBookPrices, bookKey, booksAtLine } = require('../../src/services/bookPriceStore');
|
||||
const snapshot = require('../../src/services/snapshotService');
|
||||
|
||||
// One prop (Judge TB 1.5) across 3 books at the shared line + a 1-book prop.
|
||||
const multiBookProps = [
|
||||
{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, over_odds: -115, under_odds: -105, book: 'draftkings' },
|
||||
{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, over_odds: -110, under_odds: -110, book: 'betmgm' },
|
||||
{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, over_odds: -120, under_odds: 100, book: 'pinnacle' },
|
||||
{ player: 'Mookie Betts', stat_type: 'hits', line: 1.5, over_odds: 120, under_odds: -150, book: 'draftkings' },
|
||||
];
|
||||
|
||||
describe('captureBookPrices — capture correctness', () => {
|
||||
it('groups one entry per normalized player+stat with every real book row', () => {
|
||||
const out = captureBookPrices(multiBookProps, { now: () => 'T' });
|
||||
expect(out.updated_at).toBe('T');
|
||||
expect(out.count).toBe(2);
|
||||
const judge = out.props.find((p) => p.key === bookKey('Aaron Judge', 'total_bases'));
|
||||
expect(judge.player).toBe('Aaron Judge');
|
||||
expect(judge.books).toHaveLength(3);
|
||||
expect(judge.books.map((b) => b.book).sort()).toEqual(['betmgm', 'draftkings', 'pinnacle']);
|
||||
const betts = out.props.find((p) => p.key === bookKey('Mookie Betts', 'hits'));
|
||||
expect(betts.books).toHaveLength(1); // single-book prop kept honestly
|
||||
});
|
||||
|
||||
it('drops rows with no price on either side (never a fabricated row)', () => {
|
||||
const out = captureBookPrices([
|
||||
{ player: 'X', stat_type: 'hits', line: 0.5, over_odds: null, under_odds: null, book: 'draftkings' },
|
||||
]);
|
||||
expect(out.count).toBe(0);
|
||||
});
|
||||
|
||||
it('de-dupes a repeated book+line (first row wins) and requires book/line', () => {
|
||||
const out = captureBookPrices([
|
||||
{ player: 'X', stat_type: 'hits', line: 0.5, over_odds: -110, under_odds: -110, book: 'draftkings' },
|
||||
{ player: 'X', stat_type: 'hits', line: 0.5, over_odds: 999, under_odds: 999, book: 'draftkings' },
|
||||
{ player: 'X', stat_type: 'hits', line: 0.5, over_odds: -110, book: null }, // no book → skip
|
||||
]);
|
||||
expect(out.props[0].books).toHaveLength(1);
|
||||
expect(out.props[0].books[0].over_odds).toBe(-110); // first-wins
|
||||
});
|
||||
|
||||
it('booksAtLine restricts to the shared line only', () => {
|
||||
const entry = { books: [{ book: 'a', line: 1.5 }, { book: 'b', line: 1.5 }, { book: 'c', line: 2.5 }] };
|
||||
expect(booksAtLine(entry, 1.5).map((b) => b.book)).toEqual(['a', 'b']);
|
||||
expect(booksAtLine(entry, 2.5)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('STRUCTURAL FENCE', () => {
|
||||
it('never mutates the props it reads (deep-frozen input)', () => {
|
||||
const frozen = multiBookProps.map((p) => Object.freeze({ ...p }));
|
||||
Object.freeze(frozen);
|
||||
expect(() => captureBookPrices(frozen)).not.toThrow();
|
||||
});
|
||||
|
||||
it('no grade-path module references the bookprices store', () => {
|
||||
const gradePathFiles = [
|
||||
'src/services/gradeSlateService.js',
|
||||
'src/services/ledgerService.js',
|
||||
'src/services/challengerProjection.js',
|
||||
'src/services/contactChallenger.js',
|
||||
'src/services/projectionChallenger.js',
|
||||
'src/services/intelligence/analyzeViaEngine1.js',
|
||||
];
|
||||
for (const rel of gradePathFiles) {
|
||||
const src = fs.readFileSync(path.join(__dirname, '../../', rel), 'utf8');
|
||||
expect(src).not.toMatch(/bookprices/);
|
||||
expect(src).not.toMatch(/bookPriceStore/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GRADE BYTE-IDENTICAL through runSnapshot', () => {
|
||||
function memCache() {
|
||||
const store = {};
|
||||
return {
|
||||
store,
|
||||
cacheGet: async (k) => (k in store ? store[k] : null),
|
||||
cacheSet: async (k, v) => { store[k] = v; },
|
||||
};
|
||||
}
|
||||
const grades = [
|
||||
{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, direction: 'over', grade: 'A', confidence: 71, edge_pct: 4.2 },
|
||||
{ player: 'Mookie Betts', stat_type: 'hits', line: 1.5, direction: 'under', grade: 'B', confidence: 60, edge_pct: 1.1 },
|
||||
];
|
||||
const fakeGrade = () => async (_s, _p, opts) => {
|
||||
await opts.cacheSet('grades:x', { grades, updated_at: opts.now(), source: 'test' });
|
||||
return { written: true, count: grades.length };
|
||||
};
|
||||
const baseOpts = (cache, extra = {}) => ({
|
||||
getOdds: async () => ({ props: multiBookProps, provider: 'test' }),
|
||||
gradeAndCacheSlate: fakeGrade(),
|
||||
resolveStats: async () => ({ found: false }),
|
||||
classify: () => ({ primary: null }),
|
||||
cacheGet: cache.cacheGet,
|
||||
cacheSet: cache.cacheSet,
|
||||
now: () => '2026-07-27T00:00:00.000Z',
|
||||
nowMs: () => 1000,
|
||||
notify: async () => {},
|
||||
retention: null,
|
||||
ledger: { recordPipelineGrades: async () => ({ written: 0 }), captureClosing: async () => {}, __internals: require('../../src/services/ledgerService').__internals },
|
||||
refreshTeamStats: async () => null,
|
||||
buildEspnIndex: async () => ({}),
|
||||
gameBinder: { attachGameTimes: async () => ({ bound: 0, alreadyHad: 2, unresolved: 0, ambiguous: 0 }) },
|
||||
...extra,
|
||||
});
|
||||
|
||||
it('produces identical grades whether or not the capture runs, and writes the fenced key', async () => {
|
||||
// WITH capture (default dep).
|
||||
const c1 = memCache();
|
||||
await snapshot.runSnapshot('mlb', baseOpts(c1));
|
||||
// WITHOUT capture (inject a no-op that writes nothing).
|
||||
const c2 = memCache();
|
||||
await snapshot.runSnapshot('mlb', baseOpts(c2, { captureBookPrices: () => ({ updated_at: 'x', props: [], count: 0 }) }));
|
||||
|
||||
const strip = (snap) => JSON.stringify((snap.grades || []).map((g) => ({
|
||||
player: g.player, stat_type: g.stat_type, line: g.line, direction: g.direction,
|
||||
grade: g.grade, gradedAt: g.gradedAt,
|
||||
})));
|
||||
expect(strip(c1.store['snapshot:mlb:latest'])).toBe(strip(c2.store['snapshot:mlb:latest']));
|
||||
expect(JSON.stringify(c1.store['grades:mlb'].grades.map((g) => g.grade)))
|
||||
.toBe(JSON.stringify(c2.store['grades:mlb'].grades.map((g) => g.grade)));
|
||||
|
||||
// The fenced store IS written, with real book rows.
|
||||
const bp = c1.store['bookprices:mlb'];
|
||||
expect(bp.count).toBe(2);
|
||||
expect(bp.props.find((p) => p.stat_type === 'total_bases').books).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user