feat: Feature 1.1 — Odds API integration complete, 28 tests passing
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user