/** * 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 }, };