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>
139 lines
5.7 KiB
JavaScript
139 lines
5.7 KiB
JavaScript
// Session 16 — sport-specific market scoping in oddsService.
|
|
// Replaces a runtime axios interceptor (NODE_OPTIONS --require) that
|
|
// had been deployed to filter out cross-sport markets the upstream
|
|
// odds-api 422s on. These tests pin the contract so the runtime hack
|
|
// can be retired safely.
|
|
|
|
const { SPORT_MARKETS, getMarketsForSport } = require('../../src/services/oddsService');
|
|
|
|
describe('SPORT_MARKETS — isolation', () => {
|
|
test('NBA market list contains no soccer markets', () => {
|
|
expect(SPORT_MARKETS.nba).not.toMatch(/player_goals\b/);
|
|
expect(SPORT_MARKETS.nba).not.toMatch(/player_shots_on_target/);
|
|
expect(SPORT_MARKETS.nba).not.toMatch(/player_tackles/);
|
|
expect(SPORT_MARKETS.nba).not.toMatch(/player_cards/);
|
|
expect(SPORT_MARKETS.nba).not.toMatch(/team_clean_sheet/);
|
|
});
|
|
|
|
test('NBA market list contains no MLB markets', () => {
|
|
expect(SPORT_MARKETS.nba).not.toMatch(/batter_/);
|
|
expect(SPORT_MARKETS.nba).not.toMatch(/pitcher_/);
|
|
});
|
|
|
|
test('NBA market list does contain canonical NBA markets', () => {
|
|
expect(SPORT_MARKETS.nba).toMatch(/player_points/);
|
|
expect(SPORT_MARKETS.nba).toMatch(/player_rebounds/);
|
|
expect(SPORT_MARKETS.nba).toMatch(/player_assists/);
|
|
expect(SPORT_MARKETS.nba).toMatch(/player_threes/);
|
|
expect(SPORT_MARKETS.nba).toMatch(/spreads/);
|
|
});
|
|
|
|
test('WNBA market list is NBA-shaped minus PRA combo', () => {
|
|
expect(SPORT_MARKETS.wnba).toMatch(/player_points/);
|
|
expect(SPORT_MARKETS.wnba).toMatch(/player_rebounds/);
|
|
// WNBA odds-api doesn't expose the PRA combo today.
|
|
expect(SPORT_MARKETS.wnba).not.toMatch(/points_rebounds_assists/);
|
|
expect(SPORT_MARKETS.wnba).not.toMatch(/batter_/);
|
|
expect(SPORT_MARKETS.wnba).not.toMatch(/player_goals\b/);
|
|
});
|
|
|
|
test('MLB market list contains batter + pitcher markets, no basketball', () => {
|
|
expect(SPORT_MARKETS.mlb).toMatch(/batter_home_runs/);
|
|
expect(SPORT_MARKETS.mlb).toMatch(/batter_hits/);
|
|
expect(SPORT_MARKETS.mlb).toMatch(/pitcher_strikeouts/);
|
|
expect(SPORT_MARKETS.mlb).not.toMatch(/player_points/);
|
|
expect(SPORT_MARKETS.mlb).not.toMatch(/player_goals\b/);
|
|
});
|
|
|
|
test('every soccer league shares the same market list', () => {
|
|
const soccerKeys = Object.keys(SPORT_MARKETS).filter((k) => k.startsWith('soccer_'));
|
|
expect(soccerKeys.length).toBeGreaterThanOrEqual(9);
|
|
const first = SPORT_MARKETS[soccerKeys[0]];
|
|
for (const k of soccerKeys) {
|
|
expect(SPORT_MARKETS[k]).toBe(first);
|
|
}
|
|
});
|
|
|
|
test('soccer market list contains soccer-only markets, no basketball/baseball', () => {
|
|
const wc = SPORT_MARKETS.soccer_wc;
|
|
expect(wc).toMatch(/player_goals/);
|
|
expect(wc).toMatch(/player_shots_on_target/);
|
|
expect(wc).toMatch(/player_cards/);
|
|
expect(wc).toMatch(/team_clean_sheet/);
|
|
expect(wc).not.toMatch(/player_points/);
|
|
expect(wc).not.toMatch(/batter_/);
|
|
});
|
|
|
|
test('every PLAYER-PROP market list ends with `spreads`', () => {
|
|
for (const [sport, list] of Object.entries(SPORT_MARKETS)) {
|
|
// Wave 6 — MMA is a GAME-level sport (h2h + round totals only); it has
|
|
// no player-prop `spreads` market and odds-api 422s if one is sent.
|
|
if (sport === 'mma') continue;
|
|
// We don't require spreads to be the literal final segment,
|
|
// only that it's present in the comma-separated list.
|
|
expect(list.split(',')).toContain('spreads');
|
|
}
|
|
});
|
|
|
|
test('MMA market list is game-level (h2h + totals), no spreads/player props', () => {
|
|
expect(SPORT_MARKETS.mma).toBe('h2h,totals');
|
|
expect(SPORT_MARKETS.mma).not.toMatch(/spreads/);
|
|
expect(SPORT_MARKETS.mma).not.toMatch(/player_/);
|
|
});
|
|
|
|
test('SPORT_MARKETS is frozen at the top level', () => {
|
|
expect(Object.isFrozen(SPORT_MARKETS)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('getMarketsForSport', () => {
|
|
test('returns the NBA list for nba', () => {
|
|
expect(getMarketsForSport('nba')).toBe(SPORT_MARKETS.nba);
|
|
});
|
|
test('returns the soccer_wc list for soccer_wc', () => {
|
|
expect(getMarketsForSport('soccer_wc')).toBe(SPORT_MARKETS.soccer_wc);
|
|
});
|
|
test('unknown sport falls back to NBA (safe default)', () => {
|
|
expect(getMarketsForSport('cricket')).toBe(SPORT_MARKETS.nba);
|
|
});
|
|
test('null / undefined / empty fall back to NBA', () => {
|
|
expect(getMarketsForSport(null)).toBe(SPORT_MARKETS.nba);
|
|
expect(getMarketsForSport(undefined)).toBe(SPORT_MARKETS.nba);
|
|
expect(getMarketsForSport('')).toBe(SPORT_MARKETS.nba);
|
|
});
|
|
});
|
|
|
|
describe('fetchEventOddsFromApi uses sport-scoped markets', () => {
|
|
// Mock axios so the test doesn't hit the network.
|
|
jest.resetModules();
|
|
const mockGet = jest.fn(() => Promise.resolve({ data: {}, headers: {} }));
|
|
jest.doMock('axios', () => ({ get: mockGet }));
|
|
const { fetchEventOddsFromApi, SPORT_MARKETS: SM } = require('../../src/services/oddsService');
|
|
|
|
beforeEach(() => mockGet.mockClear());
|
|
|
|
test('NBA fetch sends NBA markets only', async () => {
|
|
await fetchEventOddsFromApi('basketball_nba', 'evt1', 'key', 'nba');
|
|
const [, opts] = mockGet.mock.calls[0];
|
|
expect(opts.params.markets).toBe(SM.nba);
|
|
});
|
|
|
|
test('MLB fetch sends MLB markets only', async () => {
|
|
await fetchEventOddsFromApi('baseball_mlb', 'evt1', 'key', 'mlb');
|
|
const [, opts] = mockGet.mock.calls[0];
|
|
expect(opts.params.markets).toBe(SM.mlb);
|
|
});
|
|
|
|
test('soccer fetch sends soccer markets only', async () => {
|
|
await fetchEventOddsFromApi('soccer_fifa_world_cup', 'evt1', 'key', 'soccer_wc');
|
|
const [, opts] = mockGet.mock.calls[0];
|
|
expect(opts.params.markets).toBe(SM.soccer_wc);
|
|
});
|
|
|
|
test('omitted sport arg falls back to NBA markets (legacy callers)', async () => {
|
|
await fetchEventOddsFromApi('basketball_nba', 'evt1', 'key');
|
|
const [, opts] = mockGet.mock.calls[0];
|
|
expect(opts.params.markets).toBe(SM.nba);
|
|
});
|
|
});
|