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:
+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,
|
||||
|
||||
Reference in New Issue
Block a user