Files
vyndr/src/services/adapters/parlayApiAdapter.js
T

131 lines
4.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 },
};