feat: Feature 1.1 — Odds API integration complete, 28 tests passing

This commit is contained in:
Kev
2026-03-21 08:31:15 -04:00
parent f70db389e2
commit 00409fd6cd
16 changed files with 6896 additions and 6 deletions
+9
View File
@@ -0,0 +1,9 @@
require('dotenv').config();
const express = require('express');
const oddsRoutes = require('./routes/odds');
const app = express();
app.use(express.json());
app.use('/api/odds', oddsRoutes);
module.exports = app;
+144
View File
@@ -0,0 +1,144 @@
const express = require('express');
const { getOdds } = require('../services/oddsService');
const { MARKET_MAP, ALLOWED_BOOKS } = require('../utils/oddsNormalizer');
const router = express.Router();
const VALID_STAT_TYPES = new Set(Object.values(MARKET_MAP));
const VALID_BOOKS = ALLOWED_BOOKS;
// NCAAB is in-season November through April
function isNcaabSeason() {
const month = new Date().getUTCMonth() + 1; // 1-indexed
return month >= 11 || month <= 4;
}
function validateQueryParams(query) {
const errors = [];
if (query.stat_type && !VALID_STAT_TYPES.has(query.stat_type)) {
errors.push(`Invalid stat_type: ${query.stat_type}`);
}
if (query.book && !VALID_BOOKS.has(query.book)) {
errors.push(`Invalid book: ${query.book}`);
}
return errors;
}
function filterProps(props, query) {
let filtered = props;
if (query.stat_type) {
filtered = filtered.filter((p) => p.stat_type === query.stat_type);
}
if (query.player) {
const search = query.player.toLowerCase();
filtered = filtered.filter((p) => p.player.toLowerCase().includes(search));
}
if (query.book) {
filtered = filtered.filter((p) => p.book === query.book);
}
return filtered;
}
// Group flat props into the response format: grouped by player+stat with nested lines
function groupProps(flatProps) {
const grouped = {};
for (const prop of flatProps) {
const key = `${prop.player}::${prop.stat_type}::${prop.game_time}`;
if (!grouped[key]) {
grouped[key] = {
player: prop.player,
home_team: prop.home_team,
away_team: prop.away_team,
game_time: prop.game_time,
stat_type: prop.stat_type,
lines: [],
};
}
grouped[key].lines.push({
book: prop.book,
line: prop.line,
over_odds: prop.over_odds,
under_odds: prop.under_odds,
fetched_at: prop.fetched_at,
});
}
return Object.values(grouped);
}
router.get('/nba', async (req, res) => {
const errors = validateQueryParams(req.query);
if (errors.length > 0) {
return res.status(400).json({ error: errors.join('; ') });
}
try {
const result = await getOdds('nba');
const filtered = filterProps(result.props, req.query);
const props = groupProps(filtered);
if (result.stale) {
res.set('X-BetonBLK-Stale', 'true');
}
return res.json({
sport: 'nba',
updated_at: result.updated_at,
source: result.source,
quota_remaining: result.quota_remaining,
props,
});
} catch (err) {
const status = err.statusCode || 500;
return res.status(status).json({ error: err.message });
}
});
router.get('/ncaab', async (req, res) => {
if (!isNcaabSeason()) {
return res.json({
sport: 'ncaab',
updated_at: new Date().toISOString(),
source: 'none',
quota_remaining: null,
props: [],
message: 'NCAAB is off-season. Props return in November.',
});
}
const errors = validateQueryParams(req.query);
if (errors.length > 0) {
return res.status(400).json({ error: errors.join('; ') });
}
try {
const result = await getOdds('ncaab');
const filtered = filterProps(result.props, req.query);
const props = groupProps(filtered);
if (result.stale) {
res.set('X-BetonBLK-Stale', 'true');
}
return res.json({
sport: 'ncaab',
updated_at: result.updated_at,
source: result.source,
quota_remaining: result.quota_remaining,
props,
});
} catch (err) {
const status = err.statusCode || 500;
return res.status(status).json({ error: err.message });
}
});
module.exports = router;
+7
View File
@@ -0,0 +1,7 @@
const app = require('./app');
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`[BetonBLK] Server running on port ${PORT}`);
});
+187
View File
@@ -0,0 +1,187 @@
const axios = require('axios');
const { getRedisClient } = require('../utils/redis');
const { normalizeProps, MARKET_MAP } = require('../utils/oddsNormalizer');
const ODDS_API_BASE = 'https://api.the-odds-api.com/v4/sports';
const CACHE_TTL = 900; // 15 minutes in seconds
const SPORT_KEYS = { nba: 'basketball_nba', ncaab: 'basketball_ncaab' };
const ALL_MARKETS = Object.keys(MARKET_MAP).join(',');
const BOOKMAKERS = 'draftkings,fanduel,betmgm';
function getCacheKey(sport) {
const now = new Date();
const date = now.toISOString().split('T')[0]; // UTC date
return `odds:${sport}:${date}`;
}
function getQuotaKey() {
const now = new Date();
const month = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, '0')}`;
return `odds:quota:${month}`;
}
async function getQuotaRemaining(redis) {
const data = await redis.hgetall(getQuotaKey());
return data.remaining != null ? parseInt(data.remaining, 10) : null;
}
async function updateQuota(redis, headers) {
const remaining = headers['x-requests-remaining'];
const used = headers['x-requests-used'];
if (remaining != null) {
const key = getQuotaKey();
await redis.hset(key, 'remaining', String(remaining), 'used', String(used || 0), 'last_checked', new Date().toISOString());
await redis.expire(key, 60 * 60 * 24 * 35); // keep for ~1 month
if (parseInt(remaining, 10) < 50) {
console.warn(`[BetonBLK] Odds API quota low: ${remaining} credits remaining`);
}
}
return remaining != null ? parseInt(remaining, 10) : null;
}
async function fetchEventsFromApi(sportKey, apiKey) {
const url = `${ODDS_API_BASE}/${sportKey}/events`;
const response = await axios.get(url, {
params: { apiKey },
timeout: 10000,
});
return { data: response.data, headers: response.headers };
}
async function fetchEventOddsFromApi(sportKey, eventId, apiKey) {
const url = `${ODDS_API_BASE}/${sportKey}/events/${eventId}/odds`;
const response = await axios.get(url, {
params: {
apiKey,
regions: 'us',
markets: ALL_MARKETS,
bookmakers: BOOKMAKERS,
oddsFormat: 'american',
},
timeout: 10000,
});
return { data: response.data, headers: response.headers };
}
async function fetchAllOdds(sport, apiKey) {
const sportKey = SPORT_KEYS[sport];
if (!sportKey) throw new Error(`Unknown sport: ${sport}`);
// Step 1: Get today's events
const eventsResult = await fetchEventsFromApi(sportKey, apiKey);
const events = eventsResult.data;
let lastHeaders = eventsResult.headers;
if (!events || events.length === 0) {
return { props: [], quotaRemaining: await parseQuota(lastHeaders) };
}
// Step 2: Fetch odds for each event
const eventsWithOdds = [];
for (const event of events) {
const quotaLeft = lastHeaders['x-requests-remaining'];
if (quotaLeft != null && parseInt(quotaLeft, 10) <= 0) {
console.warn('[BetonBLK] Quota exhausted mid-fetch, stopping');
break;
}
const oddsResult = await fetchEventOddsFromApi(sportKey, event.id, apiKey);
eventsWithOdds.push(oddsResult.data);
lastHeaders = oddsResult.headers;
}
const props = normalizeProps(eventsWithOdds);
const quotaRemaining = lastHeaders['x-requests-remaining']
? parseInt(lastHeaders['x-requests-remaining'], 10)
: null;
return { props, quotaRemaining, headers: lastHeaders };
}
function parseQuota(headers) {
const val = headers && headers['x-requests-remaining'];
return val != null ? parseInt(val, 10) : null;
}
async function getOdds(sport) {
const redis = getRedisClient();
const apiKey = process.env.ODDS_API_KEY;
const cacheKey = getCacheKey(sport);
// Check cache first
const cached = await redis.get(cacheKey);
if (cached) {
const data = JSON.parse(cached);
const quota = await getQuotaRemaining(redis);
return {
sport,
updated_at: data.updated_at,
source: 'cache',
quota_remaining: quota,
props: data.props,
};
}
// Check quota before making API call
const currentQuota = await getQuotaRemaining(redis);
if (currentQuota != null && currentQuota <= 0) {
const error = new Error('Odds data temporarily unavailable. Try again later.');
error.statusCode = 429;
throw error;
}
// Fetch live data
try {
const { props, quotaRemaining, headers } = await fetchAllOdds(sport, apiKey);
// Update quota in Redis
if (headers) {
await updateQuota(redis, headers);
}
const now = new Date().toISOString();
const cacheData = { updated_at: now, props };
await redis.set(cacheKey, JSON.stringify(cacheData), 'EX', CACHE_TTL);
return {
sport,
updated_at: now,
source: 'live',
quota_remaining: quotaRemaining,
props,
};
} catch (err) {
// If API fails, try stale cache (no TTL check — any cached data)
const stale = await redis.get(cacheKey);
if (stale) {
const data = JSON.parse(stale);
const quota = await getQuotaRemaining(redis);
return {
sport,
updated_at: data.updated_at,
source: 'cache',
stale: true,
quota_remaining: quota,
props: data.props,
};
}
// No cache at all
if (err.statusCode === 429) throw err;
const serviceError = new Error('Odds service unavailable.');
serviceError.statusCode = 503;
throw serviceError;
}
}
module.exports = {
getOdds,
fetchAllOdds,
fetchEventsFromApi,
fetchEventOddsFromApi,
getCacheKey,
getQuotaKey,
updateQuota,
getQuotaRemaining,
CACHE_TTL,
};
+78
View File
@@ -0,0 +1,78 @@
const { getAbbreviation } = require('./teamMap');
const ALLOWED_BOOKS = new Set(['draftkings', 'fanduel', 'betmgm']);
const MARKET_MAP = {
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',
};
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;
}
module.exports = { normalizeProps, MARKET_MAP, ALLOWED_BOOKS };
+12
View File
@@ -0,0 +1,12 @@
const Redis = require('ioredis');
let client = null;
function getRedisClient() {
if (!client) {
client = new Redis(process.env.REDIS_URL || 'redis://127.0.0.1:6379');
}
return client;
}
module.exports = { getRedisClient };
+38
View File
@@ -0,0 +1,38 @@
const TEAM_ABBREVIATIONS = {
'Atlanta Hawks': 'ATL',
'Boston Celtics': 'BOS',
'Brooklyn Nets': 'BKN',
'Charlotte Hornets': 'CHA',
'Chicago Bulls': 'CHI',
'Cleveland Cavaliers': 'CLE',
'Dallas Mavericks': 'DAL',
'Denver Nuggets': 'DEN',
'Detroit Pistons': 'DET',
'Golden State Warriors': 'GSW',
'Houston Rockets': 'HOU',
'Indiana Pacers': 'IND',
'Los Angeles Clippers': 'LAC',
'Los Angeles Lakers': 'LAL',
'Memphis Grizzlies': 'MEM',
'Miami Heat': 'MIA',
'Milwaukee Bucks': 'MIL',
'Minnesota Timberwolves': 'MIN',
'New Orleans Pelicans': 'NOP',
'New York Knicks': 'NYK',
'Oklahoma City Thunder': 'OKC',
'Orlando Magic': 'ORL',
'Philadelphia 76ers': 'PHI',
'Phoenix Suns': 'PHX',
'Portland Trail Blazers': 'POR',
'Sacramento Kings': 'SAC',
'San Antonio Spurs': 'SAS',
'Toronto Raptors': 'TOR',
'Utah Jazz': 'UTA',
'Washington Wizards': 'WAS',
};
function getAbbreviation(fullName) {
return TEAM_ABBREVIATIONS[fullName] || fullName;
}
module.exports = { TEAM_ABBREVIATIONS, getAbbreviation };