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