const { getAbbreviation } = require('./teamMap'); // Session 30 — PropLine (The-Odds-API-compatible) carries pinnacle, the // sharp-line reference the odds-api allow-list lacked. Added so PropLine // prop data through Pinnacle survives normalization. (bovada deliberately // left OUT — it's the canonical "not-allowed" example in the tests, and // VYNDR surfaces regulated US books.) const ALLOWED_BOOKS = new Set(['draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365', 'hardrockbet', 'pointsbet', 'betrivers', 'pinnacle']); const MARKET_MAP = { // NBA / WNBA props player_points: 'points', player_rebounds: 'rebounds', player_assists: 'assists', player_threes: 'threes', player_blocks: 'blocks', player_steals: 'steals', player_points_rebounds_assists: 'pra', player_turnovers: 'turnovers', // MLB props (Session 30) — The Odds API + PropLine share these market // keys for baseball. Without them PropLine/odds-api MLB props would // normalize to ZERO (MARKET_MAP previously had no baseball keys). // Internal stat_type names match the streaks/grading engines. batter_hits: 'hits', batter_home_runs: 'home_runs', batter_total_bases: 'total_bases', batter_rbis: 'rbis', batter_runs: 'runs', batter_stolen_bases: 'stolen_bases', batter_singles: 'singles', batter_doubles: 'doubles', batter_walks: 'walks', batter_strikeouts: 'batter_strikeouts', pitcher_strikeouts: 'strikeouts', pitcher_earned_runs: 'earned_runs', pitcher_hits_allowed: 'hits_allowed', pitcher_outs: 'outs', // NFL props (Session 31 audit) — defensive mapping added BEFORE NFL is // fully wired into the props flow, so it can't repeat the MLB silent-zero // bug (MARKET_MAP dropping every market → props normalize to nothing). // The Odds API + PropLine use the abbreviated `_yds` keys; the spec's // `_yards` spellings are mapped too so either form survives. Internal // stat_type names align with config/statFilters.js (passing_yards, // rushing_yards, receiving_yards, interceptions). player_pass_yds: 'passing_yards', player_pass_yards: 'passing_yards', player_pass_tds: 'pass_tds', player_pass_completions: 'pass_completions', player_pass_attempts: 'pass_attempts', player_pass_interceptions: 'interceptions', player_rush_yds: 'rushing_yards', player_rush_yards: 'rushing_yards', player_rush_attempts: 'rush_attempts', player_rush_tds: 'rush_tds', player_receptions: 'receptions', player_reception_yds: 'receiving_yards', player_receiving_yards: 'receiving_yards', player_reception_tds: 'reception_tds', player_anytime_td: 'anytime_td', player_kicking_points: 'kicking_points', // Soccer props — World Cup 2026 + permanent league support. // odds-api keys verified against soccer_fifa_world_cup market list. // 'assists' is shared with NBA — sport context discriminates downstream. player_goals: 'goals', player_shots_on_target: 'shots_on_target', player_shots: 'shots', player_tackles: 'tackles', player_cards: 'cards', player_corners: 'corners', player_saves: 'saves', player_goals_conceded: 'goals_conceded', player_passes: 'passes', team_clean_sheet: 'clean_sheet', // NHL (Session 32) — added alongside the NFL/NHL sport-key wiring so NHL // props don't silently normalize to zero in-season (same silent-failure // class as the NFL gap Session 31 closed). player_goals/player_assists // are shared with soccer/NBA — sport context discriminates downstream. player_shots_on_goal: 'shots_on_goal', goalie_saves: 'saves', }; function normalizeProps(eventsWithOdds) { const props = []; for (const event of eventsWithOdds) { const homeTeam = getAbbreviation(event.home_team); const awayTeam = getAbbreviation(event.away_team); const gameTime = event.commence_time; if (!Array.isArray(event.bookmakers)) continue; for (const bookmaker of event.bookmakers) { if (!ALLOWED_BOOKS.has(bookmaker.key)) continue; if (!Array.isArray(bookmaker.markets)) continue; for (const market of bookmaker.markets) { const statType = MARKET_MAP[market.key]; if (!statType) continue; const fetchedAt = market.last_update; const outcomes = market.outcomes || []; // Group outcomes by player+point to pair Over/Under const grouped = {}; for (const outcome of outcomes) { if (!outcome.description || outcome.point == null) continue; const key = `${outcome.description}::${outcome.point}`; if (!grouped[key]) { grouped[key] = { player: outcome.description, point: outcome.point }; } if (outcome.name === 'Over') { grouped[key].over_odds = outcome.price; } else if (outcome.name === 'Under') { grouped[key].under_odds = outcome.price; } } for (const entry of Object.values(grouped)) { // Skip if we don't have both sides if (entry.over_odds == null && entry.under_odds == null) continue; // Player-to-team resolution deferred to Feature 1.2 (roster data) props.push({ player: entry.player, home_team: homeTeam, away_team: awayTeam, game_time: gameTime, stat_type: statType, book: bookmaker.key, line: entry.point, over_odds: entry.over_odds ?? null, under_odds: entry.under_odds ?? null, fetched_at: fetchedAt, }); } } } } return props; } function extractSpreads(eventsWithOdds) { const spreads = []; for (const event of eventsWithOdds) { const homeTeam = getAbbreviation(event.home_team); const awayTeam = getAbbreviation(event.away_team); const gameTime = event.commence_time; if (!Array.isArray(event.bookmakers)) continue; for (const bookmaker of event.bookmakers) { if (!ALLOWED_BOOKS.has(bookmaker.key)) continue; if (!Array.isArray(bookmaker.markets)) continue; for (const market of bookmaker.markets) { if (market.key !== 'spreads') continue; const outcomes = market.outcomes || []; for (const outcome of outcomes) { if (outcome.point == null) continue; // Home team spread: outcome.name matches the team full name if (outcome.name === event.home_team) { spreads.push({ home_team: homeTeam, away_team: awayTeam, game_time: gameTime, book: bookmaker.key, home_spread: outcome.point, fetched_at: market.last_update, }); break; } } } } } return spreads; } module.exports = { normalizeProps, extractSpreads, MARKET_MAP, ALLOWED_BOOKS };