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