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