425 lines
15 KiB
JavaScript
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,
|
|
};
|