Sessions 5-7a: 955 tests, deployment ready

This commit is contained in:
Kev
2026-06-08 18:35:13 -04:00
parent 06b82624a2
commit 1fa04dc776
371 changed files with 49366 additions and 955 deletions
+130
View File
@@ -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 },
};