158 lines
5.3 KiB
JavaScript
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 },
|
|
};
|