/** * ParlayAPI — historical prop archive. * * Free tier: 1,000 credits/month. 3.7M historical prop closing records, * 1.56M game-line archive. "Drop-in for the-odds-api, up to 6× cheaper." * * When called: * 1. Historical pull script (scripts/pull-parlayapi-history.js) — bulk * 2. Trap detection — query historical hit rates for a player/stat combo * 3. Feature enrichment — historical line accuracy * * NOT used during real-time grading (credit-limited). * Historical data lands in Supabase `historical_props` (migration 017). * * Failure modes mirror sharpApiAdapter: * - 429 → back off, no stale cache (historical data isn't time-sensitive) * - 5xx → circuit breaker (3 fails → open 60s) * - timeout → 10s, breaker counts it */ const axios = require('axios'); const { createLimiter, createCircuitBreaker } = require('../../utils/rateLimiter'); const { cacheGet, cacheSet } = require('../../utils/redis'); const SOURCE = 'parlayapi'; const HTTP_TIMEOUT_MS = 10_000; const CACHE_TTL_SECONDS = 24 * 60 * 60; // 24h — historical data is immutable const BASE_URL = process.env.PARLAYAPI_BASE_URL || 'https://api.parlayapi.io/v1'; // Conservative budget: 5 req/min lets us spread 1,000 credits/month across the // month (~33/day). Bulk script overrides with its own pacing. const limiter = createLimiter({ tokensPerInterval: 5, interval: 60_000 }); const breaker = createCircuitBreaker({ failureThreshold: 3, resetTimeout: 60_000 }); const SPORT_KEYS = Object.freeze({ nba: 'basketball_nba', wnba: 'basketball_wnba', mlb: 'baseball_mlb', nfl: 'americanfootball_nfl', nhl: 'icehockey_nhl', ncaab: 'basketball_ncaab', ncaafb: 'americanfootball_ncaaf', }); function configured() { return !!process.env.PARLAYAPI_KEY; } function sportKey(sport) { const key = SPORT_KEYS[sport]; if (!key) throw new Error(`Unsupported sport: ${sport}`); return key; } async function fetchWithGuards(url, params, cacheKey) { if (!configured()) return null; const cached = await cacheGet(cacheKey); if (cached) return cached; await limiter.waitForToken(); try { const data = await breaker.call(async () => { const res = await axios.get(url, { params, headers: { 'X-Api-Key': process.env.PARLAYAPI_KEY }, timeout: HTTP_TIMEOUT_MS, validateStatus: (s) => (s >= 200 && s < 300) || s === 429, }); if (res.status === 429) { const err = new Error('parlayapi rate limited'); err.code = 'PARLAYAPI_429'; throw err; } return res.data; }); await cacheSet(cacheKey, data, CACHE_TTL_SECONDS); return data; } catch (err) { if (err?.code === 'CIRCUIT_OPEN') return null; console.warn(`[${SOURCE}] fetch failed for ${cacheKey}:`, err?.message); return null; } } function normalizeHistoricalProp(raw, sport) { return { sport, game_date: raw.game_date ?? raw.date ?? null, player_name: raw.player ?? raw.player_name ?? null, stat_type: raw.stat_type ?? raw.market ?? null, line: Number(raw.line ?? raw.point ?? null), closing_line: Number(raw.closing_line ?? raw.close ?? null) || null, result: raw.result ?? raw.outcome ?? null, source: SOURCE, }; } async function getHistoricalProps(sport, playerName, statType, limit = 50) { const key = sportKey(sport); const cacheKey = `parlayapi:hist:${sport}:${playerName}:${statType}:${limit}`; const data = await fetchWithGuards( `${BASE_URL}/historical/player_props`, { sport: key, player: playerName, stat_type: statType, limit }, cacheKey ); if (!data) return []; const raw = data.props || data.results || data.data || []; return Array.isArray(raw) ? raw.map((r) => normalizeHistoricalProp(r, sport)) : []; } async function getClosingLines(sport, gameDate) { const key = sportKey(sport); const cacheKey = `parlayapi:close:${sport}:${gameDate}`; const data = await fetchWithGuards( `${BASE_URL}/historical/closing_lines`, { sport: key, date: gameDate }, cacheKey ); if (!data) return []; const raw = data.lines || data.results || data.data || []; return Array.isArray(raw) ? raw : []; } module.exports = { configured, getHistoricalProps, getClosingLines, __internals: { limiter, breaker, SPORT_KEYS, BASE_URL, normalizeHistoricalProp }, };