Product identity + widen books for DISPLAY, model input byte-identical
IDENTITY (CLAUDE.md top + MASTER-PLAN header). VYNDR is a PREDICTIVE MODEL: it projects what a player will DO and picks accurately. Market edge is a BYPRODUCT of a good prediction, never the success criterion. Success = the forecast is honest about its own confidence AND still ranks -- calibration and resolution, both. No edge/CLV term belongs in a pass/fail gate; they are diagnostics we report, not thresholds a model must clear. A model tuned to beat a closing line has been fitted to the market instead of to the game. Per-sport doctrine (Phillips 2022, classify by what players DO not by position): each sport is its own model -- own variables, archetypes, conditions, calibration, honest ceiling. Shared across sports: ONLY the Bayesian inference math. Truth Law: no fabricated data; honest-absent over invented; label limitations in-band; provisional stays provisional until re-run; documented is not verified. PHASE 2 -- AGGREGATOR WIDENING (live). normalizeProps now emits every DISPLAY book instead of 5 of 18. Before this we discarded 13 books of our own accord and 64.8% of the MLB slate was invisible to users. Every prop carries book_role (both/takeable/reference/dfs/offshore) so the display layer can say WHAT a price is -- a fixed-payout DFS number and a two-way sportsbook price are not interchangeable objects. Unknown books are still dropped. PHASE 3 -- MODEL GATE (the model does not move). bookRoles splits MODEL_BOOKS (the legacy allow-list, character for character) from DISPLAY_BOOKS. Both model paths re-filter before they pick a line: gradeSlateService.dedupeProps (before first-row-wins AND before the limit) and intradayRefreshService.indexOddsProps (which RE-GRADES at the current line -- without the gate, widening would have silently moved locked lines onto books the model has never been calibrated against). A test asserts the graded set is byte-identical through the widening. CURRENT_RULER_VERSION stays v1_first_book. The gate lifts only when the MLB calibration is re-run on the consensus ruler and v2 is promoted. HONEST FRAMING, recorded in the plan: this is an AGGREGATOR win and it does NOT fix the model. WNBA still abstains -- a model problem, not a coverage problem; it is better covered than MLB. MLB isotonic still provisional. The consensus is MARKET, not SHARP: pinnacle, matchbook and polymarket are 0% on both sports, so no sharp anchor exists in our feed. Two superseded tests updated to stronger properties rather than deleted: roleOf now names the KIND of book, and the normalizer test asserts the display set widens WHILE the model set does not. Gates: 4,027 tests / 322 suites green; next build exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
This commit is contained in:
@@ -1,5 +1,49 @@
|
||||
# VYNDR — Claude Code Project Context
|
||||
|
||||
---
|
||||
|
||||
# 🔷 PRODUCT IDENTITY — READ FIRST, EVERY SESSION
|
||||
|
||||
**VYNDR IS A PREDICTIVE MODEL.** It projects what a player will **DO**, and picks
|
||||
accurately. It reads and pulls the market apart — a student of the game that is
|
||||
also an aggregator.
|
||||
|
||||
**Market edge is a BYPRODUCT of a good prediction. It is NEVER the success
|
||||
criterion.**
|
||||
|
||||
> **SUCCESS = the forecast is honest about its own confidence AND still ranks.**
|
||||
> Calibration (does 60% mean 60%?) *and* resolution (do higher forecasts actually
|
||||
> hit more often?). Both, or it isn't working.
|
||||
|
||||
**No edge or CLV term belongs in a pass/fail gate.** CLV and market-relative edge
|
||||
are *diagnostics we report*, never thresholds a model must clear to ship. A model
|
||||
that forecasts honestly and ranks correctly is working even in a week the market
|
||||
moved against it; a model tuned to beat a closing line has been fitted to the
|
||||
market instead of to the game.
|
||||
|
||||
## PER-SPORT DOCTRINE
|
||||
*(Rashad Phillips, "Basketball Position Metric," 2022 — classify players by **what
|
||||
they do**, not by position labels.)*
|
||||
|
||||
**Each sport is its OWN model** — its own variables, archetypes, conditions,
|
||||
calibration and honest ceiling. The **only** thing shared across sports is the
|
||||
**Bayesian inference math**. Never one model fit to all sports; never a sport
|
||||
stubbed in on another sport's template and counted as covered.
|
||||
|
||||
## TRUTH LAW
|
||||
- **No fabricated data anywhere.** If it renders a number, it comes from the
|
||||
database or it doesn't render. `Number(null) === 0` is the classic breach.
|
||||
- **Honest-absent beats invented.** An empty state is a valid answer.
|
||||
- **Label limitations in-band** — e.g. "market consensus, **not sharp**",
|
||||
"RECORD BUILDING", "MODEL · LEARNING".
|
||||
- **Provisional results stay provisional until re-run.** A measurement taken
|
||||
against an instrument that has since changed is not a result; it is a result
|
||||
*pending*.
|
||||
- **Verify by inducing the real code path on demand** — never wait on a cron slot
|
||||
to find out whether something works. Documented ≠ verified.
|
||||
|
||||
---
|
||||
|
||||
## What This Is
|
||||
Sports betting intelligence SaaS. Real software product.
|
||||
Three tiers: Free (5 scans), Analyst ($19.99 / $14.99 founder), Desk ($49.99 / $34.99 founder).
|
||||
|
||||
+59
-1
@@ -1,6 +1,64 @@
|
||||
# VYNDR — MASTER PLAN
|
||||
**Single source of truth. Sessions EXECUTE against this and UPDATE it in place.**
|
||||
Created 2026-07-31 by consolidation. Nothing built in this pass.
|
||||
Created 2026-07-31 by consolidation. Last updated 2026-08-01.
|
||||
|
||||
---
|
||||
|
||||
## 🔷 PRODUCT IDENTITY — the thing being built
|
||||
|
||||
**VYNDR IS A PREDICTIVE MODEL.** It projects what a player will **DO** and picks
|
||||
accurately — reading and pulling the market apart. Student of the game, and an
|
||||
aggregator.
|
||||
|
||||
**Market edge is a BYPRODUCT of a good prediction, NEVER the success criterion.**
|
||||
|
||||
> **SUCCESS = the forecast is honest about its own confidence AND still ranks.**
|
||||
> Calibration *and* resolution. **No edge/CLV term belongs in a pass/fail gate** —
|
||||
> they are diagnostics we report, not thresholds a model must clear to ship.
|
||||
|
||||
**PER-SPORT DOCTRINE** (Rashad Phillips, *Basketball Position Metric*, 2022 —
|
||||
classify players by **what they do**, not position labels): each sport is its OWN
|
||||
model, with its own variables, archetypes, conditions, calibration and honest
|
||||
ceiling. Shared across sports: **only the Bayesian inference math.**
|
||||
|
||||
**TRUTH LAW:** no fabricated data anywhere · honest-absent over invented · label
|
||||
limitations in-band ("market consensus, **not sharp**") · **provisional results
|
||||
stay provisional until re-run** · documented ≠ verified.
|
||||
|
||||
---
|
||||
|
||||
## ▶ NEXT EXECUTABLE ORDER
|
||||
|
||||
**The MLB calibration RE-RUN against the consensus ruler.** Everything
|
||||
model-shaped is downstream of it:
|
||||
|
||||
- MLB isotonic `p_win` cannot promote until re-run (it was calibrated against
|
||||
`v1_first_book`).
|
||||
- The newly-visible props cannot feed the model until it promotes.
|
||||
- Every edge/CLV number resets to `ruler_version = v2_consensus` at that boundary
|
||||
and **must not be pooled** with what came before.
|
||||
|
||||
*Blocked on nothing. This is the next build.*
|
||||
|
||||
---
|
||||
|
||||
## 📍 STATE AS OF 2026-08-01 (Order Zero, measured on prod with the real key)
|
||||
|
||||
| finding | number | what it means |
|
||||
|---|---|---|
|
||||
| **MLB slate invisible to us** | **64.8%** | our own allow-list, not the feed — now widened for DISPLAY |
|
||||
| **books/prop, MLB** | 3.61 feed → 0.57 after filter | the filter cost, quantified |
|
||||
| **books/prop, WNBA** | **4.21** feed → 1.20 | **WNBA is BETTER covered than MLB** |
|
||||
| **consensus ruler** | **MARKET, not SHARP** | `pinnacle`/`matchbook`/`polymarket` = **0%** on both sports. No sharp anchor exists in our feed. Permanent limitation, not a milestone |
|
||||
| **ruler delta** (consensus − incumbent) | MLB mean +1.50 pts, median 0, **17% of props move ≥5 pts** | rulers genuinely differ; "better" is unproven |
|
||||
| **MLB isotonic `p_win`** | **PROVISIONAL** | calibrated on the bent ruler; does not promote until re-run |
|
||||
| **WNBA** | **still abstains** | a MODEL problem, not a coverage problem — coverage was never its constraint |
|
||||
| **soccer** | **settles** — ~15 competitions, 30d | "grades into a void" is a **$19/mo Pro-tier** problem, not a data problem |
|
||||
| **CLV + results feeds** | `/odds/closing` + `/movement` **redacted**; `/results` + `/exports/resolved-props` **403** | free tier behaving exactly as its contract says |
|
||||
| **accrual clock** | **sequential, post-completion** | see §11. Pre-completion data does not count and is never pooled |
|
||||
|
||||
**The honest framing:** widening books is an **AGGREGATOR** win. **It does not fix
|
||||
the model.** Do not let the free-side win read as model progress.
|
||||
|
||||
> **HOW TO USE:** this supersedes ad-hoc re-derivation. Before any order, read the
|
||||
> phase you're in. After any order, tick the item and add one line. **Do not
|
||||
|
||||
+46
-1
@@ -102,14 +102,59 @@ const MIN_CONSENSUS_BOOKS = 2;
|
||||
|
||||
const roleOf = (book) => {
|
||||
const b = String(book || '').toLowerCase();
|
||||
if (EXCLUDED_FROM_PRICING.has(b)) return 'excluded';
|
||||
if (DFS_PLATFORMS.has(b)) return 'dfs';
|
||||
if (OFFSHORE_OR_INTL.has(b)) return 'offshore';
|
||||
if (REFERENCE_BOOKS.has(b) && TAKEABLE_BOOKS.has(b)) return 'both';
|
||||
if (REFERENCE_BOOKS.has(b)) return 'reference';
|
||||
if (TAKEABLE_BOOKS.has(b)) return 'takeable';
|
||||
return 'unknown';
|
||||
};
|
||||
|
||||
/**
|
||||
* MODEL_BOOKS — the ONLY books whose prices may reach a grade.
|
||||
*
|
||||
* This is the legacy `oddsNormalizer.ALLOWED_BOOKS`, character for character,
|
||||
* ON PURPOSE. Widening what the SURFACES show is an aggregator change; widening
|
||||
* what the MODEL eats is a model change, and the model has never been measured
|
||||
* against those books. Until the MLB calibration is re-run on the consensus
|
||||
* ruler, the model's input stays byte-identical. Two different questions, two
|
||||
* different sets — that separation is the whole point of this file.
|
||||
*/
|
||||
const MODEL_BOOKS = Object.freeze(new Set([
|
||||
'draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365',
|
||||
'hardrockbet', 'pointsbet', 'betrivers', 'pinnacle', 'thescore',
|
||||
]));
|
||||
|
||||
/**
|
||||
* DISPLAY_BOOKS — everything the surfaces may SHOW.
|
||||
*
|
||||
* Line shopping across many books is the category's #1 free-tier hook and the
|
||||
* acquisition surface we have never had. PropLine already sends these; we were
|
||||
* throwing 13 of 18 away, which made 64.8% of the MLB slate invisible.
|
||||
*
|
||||
* Every prop carries a `book_role` so the display layer can be honest about
|
||||
* WHAT each price is: a DFS pick'em number and a two-way sportsbook price are
|
||||
* not the same object and must never be rendered as interchangeable.
|
||||
*/
|
||||
const DISPLAY_BOOKS = Object.freeze(new Set([
|
||||
...MODEL_BOOKS,
|
||||
...REFERENCE_BOOKS,
|
||||
...DFS_PLATFORMS,
|
||||
...OFFSHORE_OR_INTL,
|
||||
]));
|
||||
|
||||
/** May this book's price reach a grade? (Not "may we show it" — see below.) */
|
||||
const isModelBook = (book) => MODEL_BOOKS.has(String(book || '').toLowerCase());
|
||||
/** May we render this book at all? */
|
||||
const isDisplayBook = (book) => DISPLAY_BOOKS.has(String(book || '').toLowerCase());
|
||||
/** May this book price the fair-probability ruler? DFS/offshore: never. */
|
||||
const isReferenceBook = (book) => REFERENCE_BOOKS.has(String(book || '').toLowerCase());
|
||||
/** Can a user actually place this bet? Drives best-price + deep links. */
|
||||
const isTakeableBook = (book) => TAKEABLE_BOOKS.has(String(book || '').toLowerCase());
|
||||
|
||||
module.exports = {
|
||||
MODEL_BOOKS, DISPLAY_BOOKS,
|
||||
isModelBook, isDisplayBook, isReferenceBook, isTakeableBook,
|
||||
TAKEABLE_BOOKS, REFERENCE_BOOKS, EXCLUDED_FROM_PRICING,
|
||||
DFS_PLATFORMS, OFFSHORE_OR_INTL, PHANTOM_ON_PROPLINE,
|
||||
EXCHANGES, SHARP, US_MAJORS,
|
||||
|
||||
@@ -26,16 +26,28 @@
|
||||
// We dedupe to unique player+stat+line and cap how many we grade, because
|
||||
// each grade fans out to feature computation. Grading runs at most once per
|
||||
// cache-miss per sport, but we still bound the herd.
|
||||
const { isModelBook } = require('../config/bookRoles');
|
||||
|
||||
const DEFAULT_LIMIT = 25;
|
||||
const DEFAULT_CONCURRENCY = 5;
|
||||
const DEFAULT_TTL = 7200; // 2 hours — matches the spec's grades-cache TTL.
|
||||
|
||||
// Collapse the multi-book prop rows to one entry per gradeable prop.
|
||||
//
|
||||
// ORDER ZERO GATE (2026-08-01). `normalizeProps` now emits every DISPLAY book so
|
||||
// the surfaces can shop lines, which means DFS pick'em and exchange rows arrive
|
||||
// here for the first time. The MODEL must not eat them: it has never been
|
||||
// measured against those books, and a fixed-payout DFS number is not a market
|
||||
// price at all. So we re-filter to MODEL_BOOKS BEFORE the first-row-wins pick
|
||||
// and before the limit — which makes the graded set byte-identical to what it
|
||||
// was before the widening. This gate lifts only when the MLB calibration is
|
||||
// re-run on the consensus ruler and v2 is promoted.
|
||||
function dedupeProps(props, limit) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const p of props || []) {
|
||||
if (!p || !p.player || !p.stat_type || p.line == null) continue;
|
||||
if (!isModelBook(p.book)) continue;
|
||||
const key = `${p.player}::${p.stat_type}::${p.line}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
@@ -36,6 +36,8 @@ const STEAM_NOISE = 0.5; // ignore movement below this (both directions)
|
||||
const REGRADE_TRIGGER = 1.0; // moved-against threshold that triggers a re-grade
|
||||
const SNAP_TTL = 24 * 3600; // keep in sync with snapshotService
|
||||
const TICKER_MOVE_CAP = 6;
|
||||
const { isModelBook } = require('../config/bookRoles');
|
||||
|
||||
const HISTORY_CAP = 24; // {t, line} points per grade (S6 sparklines)
|
||||
|
||||
const GRADE_RANK = { 'A+': 0, A: 1, 'A-': 2, 'B+': 3, B: 4, 'B-': 5, 'C+': 6, C: 7, 'C-': 8, D: 9, F: 10 };
|
||||
@@ -43,10 +45,16 @@ const rank = (g) => (g && GRADE_RANK[g] !== undefined ? GRADE_RANK[g] : 99);
|
||||
|
||||
const sideOver = (dir) => String(dir || 'over').toLowerCase() !== 'under';
|
||||
|
||||
// ORDER ZERO GATE — same reasoning as gradeSlateService.dedupeProps. This index
|
||||
// feeds line-movement detection and RE-GRADES props at the current line, so it
|
||||
// is a MODEL path: it must see only MODEL_BOOKS. Without this gate the widened
|
||||
// display feed would silently move locked lines onto DFS/exchange numbers the
|
||||
// model has never been calibrated against.
|
||||
function indexOddsProps(props) {
|
||||
const map = {};
|
||||
for (const p of props || []) {
|
||||
if (!p || !p.player || !p.stat_type) continue;
|
||||
if (!isModelBook(p.book)) continue;
|
||||
const k = `${nameKey(p.player)}|${String(p.stat_type).toLowerCase()}`;
|
||||
if (!map[k]) map[k] = p;
|
||||
}
|
||||
|
||||
@@ -382,6 +382,12 @@ async function probe(path, params, note, httpGet) {
|
||||
...(rows != null ? { csv_rows: rows } : {}),
|
||||
...(Object.keys(headers).length ? { headers } : {}),
|
||||
shape: scrubKeys(summarise(body)).slice(0, 700),
|
||||
// The REASON, verbatim. A bare 403 does not distinguish "free tier
|
||||
// excludes this" from "this key lacks a scope" from "your plan lapsed",
|
||||
// and the difference is a $19/mo decision. PropLine puts it in `detail`.
|
||||
...(status >= 400 && body && typeof body === 'object'
|
||||
? { detail: scrubKeys(JSON.stringify(body.detail != null ? body.detail : body)).slice(0, 600) }
|
||||
: {}),
|
||||
};
|
||||
} catch (err) {
|
||||
return { path, verdict: 'no', note, error: scrubKeys(err && err.message) };
|
||||
|
||||
@@ -6,7 +6,24 @@ const { getAbbreviation } = require('./teamMap');
|
||||
// left OUT — it's the canonical "not-allowed" example in the tests, and
|
||||
// VYNDR surfaces regulated US books.)
|
||||
// 'thescore' = theScore Bet (PENN), successor to the defunct ESPN BET (item 7).
|
||||
const ALLOWED_BOOKS = new Set(['draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365', 'hardrockbet', 'pointsbet', 'betrivers', 'pinnacle', 'thescore']);
|
||||
//
|
||||
// ORDER ZERO (2026-08-01) — the allow-list is now TWO sets, because it was
|
||||
// answering two different questions with one answer.
|
||||
//
|
||||
// DISPLAY_BOOKS — what the SURFACES may show. Widened to everything the feed
|
||||
// already sends. Before this, we discarded 13 of PropLine's
|
||||
// 18 books and 64.8% of the MLB slate was invisible to users.
|
||||
// Line shopping across books is the category's #1 free hook.
|
||||
// MODEL_BOOKS — what may reach a GRADE. Byte-identical to the old list on
|
||||
// purpose: the model has never been measured against the new
|
||||
// books, and it must not start eating them until the MLB
|
||||
// calibration is re-run on the consensus ruler.
|
||||
//
|
||||
// `ALLOWED_BOOKS` is kept as an alias for MODEL_BOOKS so existing model-side
|
||||
// callers (combatAdapter, consensusRuler's incumbent) keep their old meaning.
|
||||
// Every emitted prop now carries `book_role` so no consumer has to re-derive it.
|
||||
const { DISPLAY_BOOKS, MODEL_BOOKS, roleOf } = require('../config/bookRoles');
|
||||
const ALLOWED_BOOKS = MODEL_BOOKS;
|
||||
|
||||
const MARKET_MAP = {
|
||||
// NBA / WNBA props
|
||||
@@ -107,7 +124,9 @@ function normalizeProps(eventsWithOdds) {
|
||||
if (!Array.isArray(event.bookmakers)) continue;
|
||||
|
||||
for (const bookmaker of event.bookmakers) {
|
||||
if (!ALLOWED_BOOKS.has(bookmaker.key)) continue;
|
||||
// DISPLAY gate — the model re-filters to MODEL_BOOKS downstream
|
||||
// (gradeSlateService.dedupeProps, intradayRefreshService.indexOddsProps).
|
||||
if (!DISPLAY_BOOKS.has(bookmaker.key)) continue;
|
||||
|
||||
if (!Array.isArray(bookmaker.markets)) continue;
|
||||
|
||||
@@ -142,6 +161,11 @@ function normalizeProps(eventsWithOdds) {
|
||||
player: entry.player,
|
||||
home_team: homeTeam,
|
||||
away_team: awayTeam,
|
||||
// What KIND of price this is. 'dfs' = fixed-payout pick'em, which is
|
||||
// NOT a market price and must never enter a consensus or a fair
|
||||
// value — it is shown for breadth and tagged so nothing downstream
|
||||
// can mistake it for a book.
|
||||
book_role: roleOf(bookmaker.key),
|
||||
game_time: gameTime,
|
||||
stat_type: statType,
|
||||
book: bookmaker.key,
|
||||
|
||||
@@ -19,7 +19,13 @@ describe('bookRoles — the three-way split', () => {
|
||||
for (const dfs of roles.DFS_PLATFORMS) {
|
||||
expect(roles.REFERENCE_BOOKS.has(dfs)).toBe(false);
|
||||
expect(roles.EXCLUDED_FROM_PRICING.has(dfs)).toBe(true);
|
||||
expect(roles.roleOf(dfs)).toBe('excluded');
|
||||
// roleOf now names the KIND ('dfs'), which is stronger than 'excluded':
|
||||
// the display layer needs to say WHAT a price is, not just that it can't
|
||||
// price. Exclusion from pricing is asserted on the line above.
|
||||
expect(roles.roleOf(dfs)).toBe('dfs');
|
||||
expect(roles.isReferenceBook(dfs)).toBe(false);
|
||||
expect(roles.isModelBook(dfs)).toBe(false);
|
||||
expect(roles.isDisplayBook(dfs)).toBe(true); // shown for breadth, tagged
|
||||
}
|
||||
});
|
||||
|
||||
@@ -144,3 +150,32 @@ describe('consensusRuler — the incumbent it is challenging', () => {
|
||||
expect(compareRulers([q('prizepicks', 1.5, -119, -119)], 1.5, 'over', { allowedBooks: LIVE_ALLOWED }).delta_pts).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('bookRoles — display vs model separation (Order Zero Phase 2/3)', () => {
|
||||
it('MODEL_BOOKS is the legacy allow-list, unchanged — the model must not move', () => {
|
||||
expect([...roles.MODEL_BOOKS].sort()).toEqual([
|
||||
'bet365', 'betmgm', 'betrivers', 'caesars', 'draftkings', 'fanatics',
|
||||
'fanduel', 'hardrockbet', 'pinnacle', 'pointsbet', 'thescore',
|
||||
].sort());
|
||||
});
|
||||
|
||||
it('DISPLAY_BOOKS is a strict superset of MODEL_BOOKS', () => {
|
||||
for (const b of roles.MODEL_BOOKS) expect(roles.DISPLAY_BOOKS.has(b)).toBe(true);
|
||||
expect(roles.DISPLAY_BOOKS.size).toBeGreaterThan(roles.MODEL_BOOKS.size);
|
||||
});
|
||||
|
||||
it('NO DFS or offshore book may reach the model, however visible it is', () => {
|
||||
for (const b of [...roles.DFS_PLATFORMS, ...roles.OFFSHORE_OR_INTL]) {
|
||||
expect(roles.isDisplayBook(b)).toBe(true);
|
||||
expect(roles.isModelBook(b)).toBe(false);
|
||||
expect(roles.isReferenceBook(b)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('an exchange is visible and may rule, but is never TAKEABLE', () => {
|
||||
for (const b of roles.EXCHANGES) {
|
||||
expect(roles.isDisplayBook(b)).toBe(true);
|
||||
expect(roles.isTakeableBook(b)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,27 +66,55 @@ describe('oddsNormalizer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('filters out books not in the allowed set', () => {
|
||||
// SUPERSEDED 2026-08-01 (Order Zero). This used to assert bovada was
|
||||
// DROPPED. It is now emitted for DISPLAY — we were discarding 13 of the
|
||||
// feed's 18 books, which made 64.8% of the MLB slate invisible. The
|
||||
// property that replaces it is strictly stronger: the display set widens,
|
||||
// every row is TAGGED with what kind of price it is, an unknown book is
|
||||
// still dropped, and the MODEL set does not move.
|
||||
it('emits every DISPLAY book, tagged with its role, and still drops unknown books', () => {
|
||||
const { MODEL_BOOKS } = require('../../src/config/bookRoles');
|
||||
const event = makeEvent({
|
||||
bookmakers: [
|
||||
makeBookmaker('bovada', [
|
||||
bookmakers: ['bovada', 'draftkings', 'prizepicks', 'novig', 'not_a_real_book'].map((k) =>
|
||||
makeBookmaker(k, [
|
||||
makeMarket('player_points', [
|
||||
makeOutcome('Over', 'Jokic', -110, 26.5),
|
||||
makeOutcome('Under', 'Jokic', -110, 26.5),
|
||||
]),
|
||||
]),
|
||||
makeBookmaker('draftkings', [
|
||||
makeMarket('player_points', [
|
||||
makeOutcome('Over', 'Jokic', -110, 26.5),
|
||||
makeOutcome('Under', 'Jokic', -110, 26.5),
|
||||
]),
|
||||
]),
|
||||
],
|
||||
])),
|
||||
});
|
||||
|
||||
const result = normalizeProps([event]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].book).toBe('draftkings');
|
||||
const byBook = Object.fromEntries(result.map((r) => [r.book, r.book_role]));
|
||||
|
||||
expect(byBook).toEqual({
|
||||
draftkings: 'both',
|
||||
bovada: 'reference',
|
||||
novig: 'reference',
|
||||
prizepicks: 'dfs', // shown for breadth, tagged, never a market price
|
||||
});
|
||||
expect(byBook.not_a_real_book).toBeUndefined(); // unknown books still dropped
|
||||
expect(ALLOWED_BOOKS).toBe(MODEL_BOOKS); // the alias still means MODEL
|
||||
});
|
||||
|
||||
it('MODEL INPUT IS BYTE-IDENTICAL despite the display widening', () => {
|
||||
// The grader re-filters to MODEL_BOOKS before first-row-wins, so widening
|
||||
// what the surfaces show cannot change a single graded prop. This is the
|
||||
// gate that lifts only when the consensus ruler is promoted.
|
||||
const { dedupeProps } = require('../../src/services/gradeSlateService').__internals;
|
||||
const event = makeEvent({
|
||||
bookmakers: ['prizepicks', 'novig', 'bovada', 'draftkings'].map((k) =>
|
||||
makeBookmaker(k, [
|
||||
makeMarket('player_points', [
|
||||
makeOutcome('Over', 'Jokic', -110, 26.5),
|
||||
makeOutcome('Under', 'Jokic', -110, 26.5),
|
||||
]),
|
||||
])),
|
||||
});
|
||||
|
||||
const graded = dedupeProps(normalizeProps([event]), 25);
|
||||
expect(graded).toHaveLength(1);
|
||||
expect(graded[0].book).toBe('draftkings'); // exactly what it was before
|
||||
});
|
||||
|
||||
it('maps every market key to its internal stat_type (NBA + soccer)', () => {
|
||||
|
||||
Reference in New Issue
Block a user