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
+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,
};