54fa5853f5
Net-new MMA/UFC vertical — fight-card discovery, tale-of-the-tape,
style-blend archetypes, ML + round-total odds, and a style-edge VERDICT
(a MODEL read, explicitly NOT a settled grade). Built to
specs/combat-intelligence.md.
Backend:
- combatAdapter: ESPN MMA scoreboard (date-pinned, free JSON) -> fight
cards + tale-of-tape (record/weight class/rounds/ESPN athlete id);
defensive parse (null on unknown shape, never throws); injectable
fetchImpl + cache; pure normalizeCombatOdds (odds-api h2h/totals ->
ML + round total, allow-listed books, best price). Number(null) guard.
- archetypeService: 6 pinned combat styles in a SEPARATE COMBAT_ARCHETYPES
registry (FINISHER collides with soccer + its green trips the signal-
green gate); classify('mma') blends range/tempo/outcome, honest-empty on
thin data (no forced fallback); styleMatchup() honest verdict.
- oddsService: SPORT_KEYS.mma + MMA_MARKETS=['h2h','totals'] + SPORT_MARKETS
(no spreads suffix). oddsNormalizer MARKET_MAP h2h/totals.
- config/sports.js + web mirror: mma.active=true (collectData stays false;
NOT in the graded-props pipeline SPORT_CONFIG or snapshot/settle loop).
- routes/combat.js: GET /api/combat/:date + GET /api/fight/:id (public,
cached, honest empty off-card) + Next proxies.
Frontend:
- FightCard: two-fighter tale-of-the-tape (initials monogram — no photos),
GRAPPLER/STRIKER blend bars, discipline pedigree tags, shared
ArchetypeBadge (sport="mma", unicode glyphs), CENTER VERDICT, ML +
round-total real; method/round/KO = honest "data-limited", never
fabricated. Self-hides on a non-two-fighter bout.
- /fight/[id] page (server wrapper + client), EmptyState off-season.
- MMA SportBadge token (#D4AF37); archetypes.js sport-aware resolution.
DEFERRED (per spec, NOT built): matchup-GRADE engine, method/round/props
board, combat settlement, ufcstats scraping.
Tests: +3 suites (31 tests) — combat archetype cross-file color/glyph
match, classify blends, styleMatchup honesty, adapter defensive parse +
odds normalize, FightCard honesty grep; extended oddsNormalizer +
sportMarkets. Full suite 253/253 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
258 lines
8.8 KiB
JavaScript
258 lines
8.8 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,
|
|
});
|
|
});
|
|
|
|
it('filters out books not in the allowed set', () => {
|
|
const event = makeEvent({
|
|
bookmakers: [
|
|
makeBookmaker('bovada', [
|
|
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');
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|
|
});
|