Files
vyndr/src/services/oddsService.js
T
builtbykev f0c8b4f29b Session 32: Grades pipeline + NFL/NHL wiring + rate limiting + audit cleanup (1718 tests)
- gradeSlateService writes grades:{sport} cache (closes content pipeline →
  dataLevel full); fire-and-forget from oddsService.recordDownstream, gated
  by shouldGradeSlate (off in test, GRADE_SLATE_ON_FETCH override)
- NFL/NHL wired: oddsService SPORT_KEYS/SPORT_MARKETS (correct the-odds-api
  keys americanfootball_nfl/icehockey_nhl), proplineAdapter MARKETS, NHL
  MARKET_MAP keys to avoid silent-zero
- rate limiting mounted on 8 public cached routers (odds/parlay 30/min,
  rest 60/min)
- jsonlLogger writes to temp under test (no more dirtied tracked artifact);
  5MB pipeline test given 20s timeout

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:21:32 -04:00

515 lines
19 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',
// Session 32 — NFL + NHL. odds-api keys per the-odds-api sports list.
// Off-season returns an empty events array; the route layer surfaces an
// empty slate (never a crash). NFL props normalize through the MARKET_MAP
// keys added in Session 31; NHL keys were added alongside this wiring.
nfl: 'americanfootball_nfl',
nhl: 'icehockey_nhl',
// 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',
];
// Session 32 — NFL + NHL market lists. NFL keys mirror the MARKET_MAP
// entries added Session 31 (both _yds and _yards spellings normalize). NHL
// keys were added to MARKET_MAP alongside this wiring so they don't
// silently normalize to zero in-season.
const NFL_MARKETS = [
'player_pass_yds',
'player_pass_tds',
'player_rush_yds',
'player_reception_yds',
'player_receptions',
'player_anytime_td',
];
const NHL_MARKETS = [
'player_goals',
'player_shots_on_goal',
'player_assists',
'goalie_saves',
];
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),
nfl: buildMarketString(NFL_MARKETS),
nhl: buildMarketString(NHL_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;
}
// Best-effort post-fetch processing shared by both providers (PropLine +
// odds-api): line movement, scratch cascade, and rolling line snapshots.
// Never throws — a failure here must not break the odds response.
async function recordDownstream(sport, props, provider = 'odds-api') {
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 || [];
const lineSnapshots = require('./lineSnapshotService');
await lineSnapshots.recordSnapshots(sport, props);
} catch (e) {
console.warn('[VYNDR] Movement/cascade detection error:', e.message);
}
// Session 32 — grade the slate into the `grades:{sport}` cache that
// contentTemplateService reads, closing the content pipeline. Fire-and-
// forget: grading fans out to feature computation and must NOT hold the
// odds HTTP response — content endpoints read the grades cache later and
// independently. Errors are self-contained inside the service.
if (shouldGradeSlate()) {
require('./gradeSlateService')
.gradeAndCacheSlate(sport, props, { source: provider })
.catch((e) => console.warn('[VYNDR] slate grading error:', e.message));
}
return { movements, scratchedPlayers };
}
// Auto-grade the slate on a fresh odds fetch by default (closes the content
// pipeline). Skipped under the test env so its background feature-computation
// fan-out doesn't pollute call-count assertions in the odds integration
// tests; `GRADE_SLATE_ON_FETCH` is the explicit operator override
// ('1' forces on even in test, '0' is the production kill-switch if the
// feature-compute cost ever needs to be shed).
function shouldGradeSlate() {
const flag = process.env.GRADE_SLATE_ON_FETCH;
if (flag === '1') return true;
if (flag === '0') return false;
return process.env.NODE_ENV !== 'test';
}
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',
provider: data.provider || 'odds-api',
quota_remaining: quota,
props: data.props,
spreads: data.spreads || [],
};
}
// Session 30 — PropLine is the PRIMARY props provider when configured:
// 3 free keys, 3,000 req/day combined (vs odds-api's 500/month). Try it
// first; on empty/error fall through to the conserved odds-api path
// below. Gated on hasKeys() so environments without PropLine keys (incl.
// the test suite) keep the exact prior behavior.
const propline = require('./adapters/proplineAdapter');
if (propline.hasKeys()) {
try {
const pl = await propline.getProps(sport);
if (pl && Array.isArray(pl.props) && pl.props.length > 0) {
const now = new Date().toISOString();
const cacheData = { updated_at: now, props: pl.props, spreads: pl.spreads || [], provider: 'propline' };
await redis.set(cacheKey, JSON.stringify(cacheData), 'EX', CACHE_TTL);
const { movements, scratchedPlayers } = await recordDownstream(sport, pl.props, 'propline');
return {
sport,
updated_at: now,
source: 'live',
provider: 'propline',
props: pl.props,
spreads: pl.spreads || [],
movements,
scratchedPlayers,
};
}
} catch (e) {
console.warn('[oddsService] PropLine failed, falling back to odds-api:', e.message);
}
}
// 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, provider: 'odds-api' };
await redis.set(cacheKey, JSON.stringify(cacheData), 'EX', CACHE_TTL);
// Line movement + cascade + snapshots (best-effort; shared helper).
const { movements, scratchedPlayers } = await recordDownstream(sport, props, 'odds-api');
return {
sport,
updated_at: now,
source: 'live',
provider: 'odds-api',
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,
// Session 32 — slate auto-grade gate (exposed for tests).
shouldGradeSlate,
};