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

158 lines
5.3 KiB
JavaScript

/**
* OddsPapi — Pinnacle closing-line capture for CLV.
*
* Closing lines are immutable facts. Once captured at tip-off they live in
* Supabase forever; we never re-read them, never cache them in Redis (would
* be wasted space — they don't change).
*
* Called from the resolution poller the FIRST time it sees a game flip to
* STATUS_IN_PROGRESS. One row per (game_id, player_espn_id, stat_type) via
* the UNIQUE constraint in migration 016, so repeated triggers no-op
* cleanly.
*/
const axios = require('axios');
const { createLimiter, createCircuitBreaker, API_BUDGETS } = require('../../utils/rateLimiter');
const { devig } = require('../../utils/odds');
const { getSupabaseServiceClient } = require('../../utils/supabase');
const HTTP_TIMEOUT_MS = 10_000;
const BASE_URL = process.env.ODDSPAPI_BASE_URL || 'https://api.oddspapi.io/v1';
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',
});
const limiter = createLimiter(API_BUDGETS.oddsPapi);
const breaker = createCircuitBreaker({ failureThreshold: 3, resetTimeout: 60_000 });
function configured() {
return !!process.env.ODDSPAPI_KEY;
}
function sportKey(sport) {
const key = SPORT_KEYS[sport];
if (!key) throw new Error(`Unsupported sport: ${sport}`);
return key;
}
async function fetchPinnacleProp(sport, gameId, playerName, statType) {
if (!configured()) return null;
await limiter.waitForToken();
try {
return await breaker.call(async () => {
const res = await axios.get(`${BASE_URL}/sports/${sportKey(sport)}/events/${gameId}/odds`, {
params: { bookmaker: 'pinnacle', market: 'player_props' },
headers: { 'X-Api-Key': process.env.ODDSPAPI_KEY },
timeout: HTTP_TIMEOUT_MS,
});
const props = res.data?.props || res.data?.data || [];
return Array.isArray(props)
? props.find(
(p) =>
(p.player ?? p.player_name)?.toLowerCase() === playerName.toLowerCase()
&& (p.stat_type ?? p.market) === statType
) || null
: null;
});
} catch (err) {
if (err?.code !== 'CIRCUIT_OPEN') {
console.warn(`[oddspapi] fetch failed for ${sport}/${gameId}/${playerName}/${statType}:`, err?.message);
}
return null;
}
}
async function getPinnacleClosingLine(sport, gameId, playerEspnId, statType, playerName) {
if (!configured()) return null;
const prop = await fetchPinnacleProp(sport, gameId, playerName, statType);
if (!prop) return null;
const line = Number(prop.line ?? prop.point);
const overOdds = Number(prop.over_price ?? prop.overOdds);
const underOdds = Number(prop.under_price ?? prop.underOdds);
if (!Number.isFinite(line) || !Number.isFinite(overOdds) || !Number.isFinite(underOdds)) return null;
const fair = devig(overOdds, underOdds);
return {
line,
overOdds,
underOdds,
fairOver: fair?.fairOver ?? null,
fairUnder: fair?.fairUnder ?? null,
capturedAt: new Date().toISOString(),
};
}
async function batchCapture(sport, gameId) {
if (!configured()) return { captured: 0, skipped: 0, reason: 'not_configured' };
const supabase = getSupabaseServiceClient();
// Pull every unresolved prop for this game from the grading pipeline.
// resolved_at IS NULL prevents double-capture for games we've already
// processed (matters for retries from the poller).
const { data: graded, error } = await supabase
.from('grade_history')
.select('player_id, player_name, stat_type')
.eq('game_id', gameId)
.is('resolved_at', null);
if (error) {
console.warn('[oddspapi] grade_history lookup failed:', error.message);
return { captured: 0, error: error.message };
}
if (!graded || graded.length === 0) {
return { captured: 0, skipped: 0, reason: 'no_graded_props' };
}
// Deduplicate by (player, stat) — same player can be graded twice on
// different lines but we only need one Pinnacle reference per stat.
const seen = new Set();
const targets = [];
for (const row of graded) {
const key = `${row.player_id}|${row.stat_type}`;
if (seen.has(key)) continue;
seen.add(key);
targets.push(row);
}
let captured = 0;
let skipped = 0;
for (const t of targets) {
const line = await getPinnacleClosingLine(sport, gameId, t.player_id, t.stat_type, t.player_name);
if (!line) { skipped += 1; continue; }
const { error: upsertErr } = await supabase
.from('closing_lines')
.upsert({
game_id: gameId,
sport,
player_name: t.player_name,
player_espn_id: t.player_id,
stat_type: t.stat_type,
pinnacle_line: line.line,
pinnacle_over_odds: line.overOdds,
pinnacle_under_odds: line.underOdds,
fair_over_probability: line.fairOver,
fair_under_probability: line.fairUnder,
}, { onConflict: 'game_id,player_espn_id,stat_type' });
if (upsertErr) {
console.warn('[oddspapi] closing_lines upsert failed:', upsertErr.message);
skipped += 1;
continue;
}
captured += 1;
}
return { captured, skipped, total: targets.length };
}
module.exports = {
configured,
getPinnacleClosingLine,
batchCapture,
__internals: { limiter, breaker, SPORT_KEYS },
};