Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* 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 },
|
||||
};
|
||||
Reference in New Issue
Block a user