f0c8b4f29b
- gradeSlateService writes grades:{sport} cache (closes content pipeline →
dataLevel full); fire-and-forget from oddsService.recordDownstream, gated
by shouldGradeSlate (off in test, GRADE_SLATE_ON_FETCH override)
- NFL/NHL wired: oddsService SPORT_KEYS/SPORT_MARKETS (correct the-odds-api
keys americanfootball_nfl/icehockey_nhl), proplineAdapter MARKETS, NHL
MARKET_MAP keys to avoid silent-zero
- rate limiting mounted on 8 public cached routers (odds/parlay 30/min,
rest 60/min)
- jsonlLogger writes to temp under test (no more dirtied tracked artifact);
5MB pipeline test given 20s timeout
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
186 lines
6.7 KiB
JavaScript
186 lines
6.7 KiB
JavaScript
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 };
|