Files
vyndr/src/services/oddsService.js
T

425 lines
15 KiB
JavaScript

const axios = require('axios');
const { getRedisClient } = require('../utils/redis');
const { normalizeProps, extractSpreads, MARKET_MAP } = require('../utils/oddsNormalizer');
// Session 20 — every odds-api hit now flows through the gateway so
// the quota counter advances and the fallback chain (oddspapi,
// parlayapi) can take over when we approach the monthly cap. The
// gateway is intentionally light-weight on the hot path — it adds
// one Redis GET + SET per call (degrades open if Redis is down).
const gateway = require('./providerGateway');
const ODDS_API_BASE = 'https://api.the-odds-api.com/v4/sports';
// Session 22 — cache TTL is the dominant lever on monthly odds-api
// credit spend. Each cache miss fans out to (1 + N) upstream calls
// (1 events lookup + 1 per game-day event). At the free tier's
// 500 credits/month, the previous 15-min TTL was an order of
// magnitude too aggressive for live serving across multiple sports.
//
// Default raised to 60 minutes; operators can override via
// `ODDS_CACHE_TTL_SECONDS` from Coolify without a redeploy:
// ODDS_CACHE_TTL_SECONDS=3600 → 1 hour (default)
// ODDS_CACHE_TTL_SECONDS=7200 → 2 hours (free tier with 4+ sports)
// ODDS_CACHE_TTL_SECONDS=900 → 15 min (legacy, only on paid tier)
const DEFAULT_CACHE_TTL = 3600; // 1h
function getConfiguredCacheTTL() {
const raw = process.env.ODDS_CACHE_TTL_SECONDS;
if (!raw) return DEFAULT_CACHE_TTL;
const n = Number.parseInt(raw, 10);
// Defensive — refuse to bypass cache entirely (very_low_ttl < 60s
// would shred credits). Same upper bound (1 day) prevents a
// typo from holding stale data forever.
if (!Number.isFinite(n) || n < 60 || n > 86400) return DEFAULT_CACHE_TTL;
return n;
}
// Kept as a top-level binding for backward compat with the existing
// `module.exports.CACHE_TTL` consumers (tests + the route layer
// probe). Resolved at module load so deploys that change the env
// var require a restart — same contract as every other env-driven
// constant in the codebase.
const CACHE_TTL = getConfiguredCacheTTL();
// Sport identifiers consumed by getOdds → mapped to the odds-api.com
// sport key. Soccer leagues are listed individually so the route layer
// can fetch per-league without changing the upstream contract. Only
// fetched on user demand (on-demand cache with 15-min TTL); leagues
// nobody queries don't consume odds-api quota.
const SPORT_KEYS = {
nba: 'basketball_nba',
ncaab: 'basketball_ncaab',
// Session 14 — WNBA + MLB. odds-api may not always carry WNBA props
// (off-season returns empty); the route layer surfaces an empty
// array with a friendly message in that case.
wnba: 'basketball_wnba',
mlb: 'baseball_mlb',
// Soccer (Session 7j) — odds-api sport keys verified against
// https://the-odds-api.com/sports-odds-data/sports-apis.html
soccer_wc: 'soccer_fifa_world_cup',
soccer_epl: 'soccer_epl',
soccer_laliga: 'soccer_spain_la_liga',
soccer_bundesliga: 'soccer_germany_bundesliga',
soccer_seriea: 'soccer_italy_serie_a',
soccer_ligue1: 'soccer_france_ligue_one',
soccer_ucl: 'soccer_uefa_champs_league',
soccer_mls: 'soccer_usa_mls',
soccer_ligamx: 'soccer_mexico_ligamx',
};
const SOCCER_SPORT_KEYS = Object.freeze(
Object.keys(SPORT_KEYS).filter((k) => k.startsWith('soccer_'))
);
// Session 16 — per-sport market lists.
//
// The old `ALL_MARKETS = every key in MARKET_MAP` would send
// soccer markets (player_goals, player_shots_on_target, etc.) to
// basketball + baseball endpoints, triggering odds-api 422 errors.
// Production briefly worked around this with a runtime axios
// interceptor injected via `NODE_OPTIONS=--require /app/data/patch.js`;
// the proper fix is to scope the markets list to the sport before
// the request leaves the process.
//
// After this lands, the operator can drop the NODE_OPTIONS env var
// from Coolify and delete /app/data/patch.js.
const NBA_MARKETS = [
'player_points',
'player_rebounds',
'player_assists',
'player_threes',
'player_blocks',
'player_steals',
'player_turnovers',
'player_points_rebounds_assists',
];
const WNBA_MARKETS = [
'player_points',
'player_rebounds',
'player_assists',
'player_threes',
'player_blocks',
'player_steals',
'player_turnovers',
];
const MLB_MARKETS = [
'batter_home_runs',
'batter_hits',
'batter_total_bases',
'batter_rbis',
'batter_runs_scored',
'batter_stolen_bases',
'pitcher_strikeouts',
'pitcher_outs',
];
const SOCCER_MARKETS = [
'player_goals',
'player_shots_on_target',
'player_shots',
'player_tackles',
'player_cards',
'player_corners',
'player_saves',
'player_goals_conceded',
'player_passes',
'team_clean_sheet',
];
function buildMarketString(markets) {
return [...markets, 'spreads'].join(',');
}
// Indexed by the local sport key (the keys in SPORT_KEYS, not the
// odds-api keys). Soccer leagues all share the same market list.
const SPORT_MARKETS = Object.freeze({
nba: buildMarketString(NBA_MARKETS),
wnba: buildMarketString(WNBA_MARKETS),
mlb: buildMarketString(MLB_MARKETS),
ncaab: buildMarketString(NBA_MARKETS), // NCAAB markets mirror NBA
// Every soccer league code shares the same market set.
...Object.fromEntries(
Object.keys(SPORT_KEYS)
.filter((k) => k.startsWith('soccer_'))
.map((k) => [k, buildMarketString(SOCCER_MARKETS)]),
),
});
// Kept for backward-compat with any caller that still imports it,
// but the call site (`fetchEventOddsFromApi`) now uses the sport-
// specific lookup. Composed once on module load.
const ALL_MARKETS = Object.keys(MARKET_MAP).join(',') + ',spreads';
function getMarketsForSport(sport) {
if (!sport) return SPORT_MARKETS.nba; // safe default (basketball)
return SPORT_MARKETS[sport] || SPORT_MARKETS.nba;
}
const BOOKMAKERS = 'draftkings,fanduel,betmgm,caesars,fanatics,bet365,hardrockbet,pointsbet,betrivers';
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'];
// Session 20 — sync the per-provider tracker from the same
// headers. The gateway's syncHeadersFrom already does this on
// each call; doing it here too is belt-and-suspenders for any
// call path that bypassed the gateway. Lazy-required so the
// tests that don't mock providerGateway don't crash on load.
try {
const quotaTracker = require('./quotaTracker');
await quotaTracker.syncFromHeaders('odds-api', headers);
} catch (e) {
// Tracker failure must never break odds delivery — it's a
// signal, not a dependency.
console.warn('[oddsService] quotaTracker sync failed:', e.message);
}
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(`[VYNDR] 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 gateway.fetch(
'odds-api',
() => axios.get(url, { params: { apiKey }, timeout: 10000 }),
{
capability: 'odds',
// Best-effort sport tag for the fallback chain. The events
// endpoint isn't sport-scoped on the fallback providers, but
// passing it through lets the registry filter to relevant
// candidates.
sport: sportKey.replace(/^.*?_/, ''),
syncHeadersFrom: (r) => r && r.headers,
},
);
return { data: response.data, headers: response.headers };
}
// Session 16 — third arg is now a local sport key (nba, mlb,
// soccer_wc, ...) so we can scope the markets list. Backwards-
// compatible: if `sport` is omitted, falls back to the basketball
// market set, which is what every legacy caller assumed.
async function fetchEventOddsFromApi(sportKey, eventId, apiKey, sport) {
const url = `${ODDS_API_BASE}/${sportKey}/events/${eventId}/odds`;
const response = await gateway.fetch(
'odds-api',
() => axios.get(url, {
params: {
apiKey,
regions: 'us',
markets: getMarketsForSport(sport),
bookmakers: BOOKMAKERS,
oddsFormat: 'american',
},
timeout: 10000,
}),
{
capability: 'odds',
sport,
syncHeadersFrom: (r) => r && r.headers,
},
);
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('[VYNDR] Quota exhausted mid-fetch, stopping');
break;
}
const oddsResult = await fetchEventOddsFromApi(sportKey, event.id, apiKey, sport);
eventsWithOdds.push(oddsResult.data);
lastHeaders = oddsResult.headers;
}
const props = normalizeProps(eventsWithOdds);
const spreads = extractSpreads(eventsWithOdds);
const quotaRemaining = lastHeaders['x-requests-remaining']
? parseInt(lastHeaders['x-requests-remaining'], 10)
: null;
return { props, spreads, 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,
spreads: data.spreads || [],
};
}
// Session 22 — pre-flight quota check now reads from the
// Session 20 tracker (truth source: synced from upstream
// response headers on every call). The legacy
// `getQuotaRemaining(redis)` read a separate Redis hash that
// drifted (Chrome Claude found it showing 46 remaining while
// reality was 7) because that hash was only updated by THIS
// file's `updateQuota` — gateway calls and other callers
// bypassed it. The tracker is updated by both the gateway and
// updateQuota, so it can't lag behind.
//
// Threshold: tracker BLOCKs at >=95%. Below that, we still
// proceed (matches legacy behavior of allowing up to the very
// last credit). Degraded-mode (Redis down) fails OPEN — see
// quotaTracker.js for the rationale.
const quotaTracker = require('./quotaTracker');
const quotaStatus = await quotaTracker.getQuotaStatus('odds-api');
if (!quotaStatus.allowed) {
const error = new Error('Odds data temporarily unavailable. Try again later.');
error.statusCode = 429;
error.quotaStatus = quotaStatus;
throw error;
}
// Fetch live data
try {
const { props, spreads, 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, spreads };
await redis.set(cacheKey, JSON.stringify(cacheData), 'EX', CACHE_TTL);
// Line movement + cascade detection (best-effort, don't block response)
let movements = [];
let scratchedPlayers = [];
try {
const lineMovement = require('./lineMovementService');
const cascade = require('./cascadeService');
const moveResult = await lineMovement.processNewOdds(sport, props);
movements = moveResult.movements || [];
const cascadeResult = await cascade.detectScratches(sport, props);
scratchedPlayers = cascadeResult.scratchedPlayers || [];
// Session 28 — append a rolling line-history snapshot per prop so the
// sparkline / biggest-movers views have data. Redis-only, free.
const lineSnapshots = require('./lineSnapshotService');
await lineSnapshots.recordSnapshots(sport, props);
} catch (e) {
// Non-fatal — log and continue
console.warn('[VYNDR] Movement/cascade detection error:', e.message);
}
return {
sport,
updated_at: now,
source: 'live',
quota_remaining: quotaRemaining,
props,
spreads,
movements,
scratchedPlayers,
};
} 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,
spreads: data.spreads || [],
};
}
// No cache at all. Session 19 — surface the underlying cause in
// logs so operators can tell odds-api auth failures apart from
// network blips apart from rate-limit responses. The 503 wrapper
// hides everything by design (don't leak details to clients);
// this log gives the operator the signal they need.
const upstreamStatus = err.response && err.response.status;
const upstreamBody = err.response && err.response.data;
console.error(
`[oddsService] ${sport} fetch failed — no cache fallback. ` +
`upstream_status=${upstreamStatus || 'n/a'} ` +
`code=${err.code || 'n/a'} ` +
`message=${err.message} ` +
`upstream_body=${upstreamBody ? JSON.stringify(upstreamBody).slice(0, 200) : 'n/a'}`,
);
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,
SPORT_KEYS,
SOCCER_SPORT_KEYS,
// Session 16 — per-sport market scoping.
SPORT_MARKETS,
getMarketsForSport,
getQuotaKey,
updateQuota,
getQuotaRemaining,
CACHE_TTL,
// Session 22 — exposed for tests that exercise env-driven TTL
// resolution without re-loading the module.
getConfiguredCacheTTL,
};