Files
vyndr/tests/unit/oddsNormalizer.test.js
T
builtbykev f0543b57a4 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
2026-08-01 00:50:54 -04:00

286 lines
10 KiB
JavaScript

const { normalizeProps, MARKET_MAP, ALLOWED_BOOKS } = require('../../src/utils/oddsNormalizer');
function makeEvent(overrides = {}) {
return {
id: 'event-1',
sport_key: 'basketball_nba',
home_team: 'Denver Nuggets',
away_team: 'Los Angeles Lakers',
commence_time: '2026-03-21T19:00:00Z',
bookmakers: [],
...overrides,
};
}
function makeBookmaker(key, markets) {
return { key, title: key, markets };
}
function makeMarket(marketKey, outcomes, lastUpdate = '2026-03-21T14:28:00Z') {
return { key: marketKey, last_update: lastUpdate, outcomes };
}
function makeOutcome(name, player, price, point) {
return { name, description: player, price, point };
}
describe('oddsNormalizer', () => {
describe('normalizeProps', () => {
it('normalizes a raw response with multiple books and markets', () => {
const event = makeEvent({
bookmakers: [
makeBookmaker('draftkings', [
makeMarket('player_points', [
makeOutcome('Over', 'Nikola Jokic', -110, 26.5),
makeOutcome('Under', 'Nikola Jokic', -110, 26.5),
]),
]),
makeBookmaker('fanduel', [
makeMarket('player_points', [
makeOutcome('Over', 'Nikola Jokic', -105, 27.0),
makeOutcome('Under', 'Nikola Jokic', -115, 27.0),
]),
]),
],
});
const result = normalizeProps([event]);
expect(result).toHaveLength(2);
expect(result[0]).toMatchObject({
player: 'Nikola Jokic',
home_team: 'DEN',
away_team: 'LAL',
stat_type: 'points',
book: 'draftkings',
line: 26.5,
over_odds: -110,
under_odds: -110,
});
expect(result[1]).toMatchObject({
player: 'Nikola Jokic',
book: 'fanduel',
line: 27.0,
over_odds: -105,
under_odds: -115,
});
});
// 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: ['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),
]),
])),
});
const result = normalizeProps([event]);
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)', () => {
const markets = Object.entries(MARKET_MAP);
const bookmaker = makeBookmaker(
'draftkings',
markets.map(([key]) =>
makeMarket(key, [
makeOutcome('Over', 'Test Player', -110, 10.5),
makeOutcome('Under', 'Test Player', -110, 10.5),
])
)
);
const event = makeEvent({ bookmakers: [bookmaker] });
const result = normalizeProps([event]);
const statTypes = result.map((p) => p.stat_type);
const expected = Object.values(MARKET_MAP);
expect(statTypes).toEqual(expected);
});
it('exposes the soccer market keys added in Session 7j', () => {
// Sanity: soccer odds flow through the same normalizer as NBA. If a
// future refactor splits MARKET_MAP per-sport, this test makes the
// surface visible.
const soccerStatTypes = ['goals', 'shots_on_target', 'shots', 'tackles',
'cards', 'corners', 'saves', 'goals_conceded', 'passes', 'clean_sheet'];
const values = Object.values(MARKET_MAP);
for (const t of soccerStatTypes) {
expect(values).toContain(t);
}
});
it('Session 56 audit — batter_rbis normalizes to rbi (singular), matching the grade chain', () => {
// The whole grade/feature/outcome chain keys on 'rbi'. If this reverts to
// 'rbis', PropLine RBI props silently stop grading + settling again.
expect(MARKET_MAP.batter_rbis).toBe('rbi');
expect(MARKET_MAP.batter_doubles).toBe('doubles');
expect(MARKET_MAP.pitcher_outs).toBe('outs');
});
it('exposes the NFL market keys added in the Session 31 audit', () => {
// Defensive mapping landed before NFL is fully wired so it can't
// repeat the MLB silent-zero bug. Both odds-api `_yds` and the
// `_yards` spellings must resolve, and internal names align with
// config/statFilters.js (passing/rushing/receiving_yards, interceptions).
expect(MARKET_MAP.player_pass_yds).toBe('passing_yards');
expect(MARKET_MAP.player_pass_yards).toBe('passing_yards');
expect(MARKET_MAP.player_rush_yds).toBe('rushing_yards');
expect(MARKET_MAP.player_reception_yds).toBe('receiving_yards');
expect(MARKET_MAP.player_receiving_yards).toBe('receiving_yards');
expect(MARKET_MAP.player_receptions).toBe('receptions');
expect(MARKET_MAP.player_pass_interceptions).toBe('interceptions');
expect(MARKET_MAP.player_anytime_td).toBe('anytime_td');
// End-to-end: an NFL market normalizes to a real prop, not zero.
const event = makeEvent({
bookmakers: [
makeBookmaker('draftkings', [
makeMarket('player_pass_yds', [
makeOutcome('Over', 'Patrick Mahomes', -110, 275.5),
makeOutcome('Under', 'Patrick Mahomes', -110, 275.5),
]),
]),
],
});
const result = normalizeProps([event]);
expect(result).toHaveLength(1);
expect(result[0].stat_type).toBe('passing_yards');
expect(result[0].player).toBe('Patrick Mahomes');
});
it('handles missing/null odds gracefully (skips incomplete outcomes)', () => {
const event = makeEvent({
bookmakers: [
makeBookmaker('draftkings', [
makeMarket('player_points', [
// Missing description
{ name: 'Over', description: null, price: -110, point: 26.5 },
// Missing point
{ name: 'Over', description: 'Jokic', price: -110, point: null },
// Valid pair
makeOutcome('Over', 'LeBron James', -110, 25.5),
makeOutcome('Under', 'LeBron James', -110, 25.5),
]),
]),
],
});
const result = normalizeProps([event]);
expect(result).toHaveLength(1);
expect(result[0].player).toBe('LeBron James');
});
it('returns empty array for empty input', () => {
expect(normalizeProps([])).toEqual([]);
});
it('returns empty array for events with no bookmakers', () => {
const event = makeEvent({ bookmakers: undefined });
expect(normalizeProps([event])).toEqual([]);
});
it('handles an outcome with only Over (no Under pair)', () => {
const event = makeEvent({
bookmakers: [
makeBookmaker('fanduel', [
makeMarket('player_points', [
makeOutcome('Over', 'Solo Player', -110, 20.5),
]),
]),
],
});
const result = normalizeProps([event]);
expect(result).toHaveLength(1);
expect(result[0].over_odds).toBe(-110);
expect(result[0].under_odds).toBeNull();
});
it('uses UTC timestamps from the API as fetched_at', () => {
const ts = '2026-03-21T18:00:00Z';
const event = makeEvent({
bookmakers: [
makeBookmaker('betmgm', [
makeMarket('player_points', [
makeOutcome('Over', 'Player A', -110, 10.5),
makeOutcome('Under', 'Player A', -110, 10.5),
], ts),
]),
],
});
const result = normalizeProps([event]);
expect(result[0].fetched_at).toBe(ts);
});
it('maps team names to 3-letter abbreviations', () => {
const event = makeEvent({
home_team: 'Golden State Warriors',
away_team: 'Phoenix Suns',
bookmakers: [
makeBookmaker('draftkings', [
makeMarket('player_points', [
makeOutcome('Over', 'Steph Curry', -110, 28.5),
makeOutcome('Under', 'Steph Curry', -110, 28.5),
]),
]),
],
});
const result = normalizeProps([event]);
expect(result[0].home_team).toBe('GSW');
expect(result[0].away_team).toBe('PHX');
});
});
// Wave 6 — combat (MMA) game-level markets. Without these MARKET_MAP keys,
// combat moneyline/round-total odds would silently normalize to zero (same
// silent-failure class as the MLB/NHL gaps closed earlier).
describe('MMA / combat market keys (Wave 6)', () => {
it('maps h2h → moneyline and totals → round_total', () => {
expect(MARKET_MAP.h2h).toBe('moneyline');
expect(MARKET_MAP.totals).toBe('round_total');
});
});
});