131 lines
4.3 KiB
JavaScript
131 lines
4.3 KiB
JavaScript
/**
|
||
* 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 },
|
||
};
|