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
+13
View File
@@ -0,0 +1,13 @@
/**
* BetMGM adapter — STUB.
*
* BetMGM props live under `sports.betmgm.com` JSON endpoints. State-gated
* by IP; requires a state code in the path.
*/
const SOURCE = 'betmgm';
async function getGames(/* sport */) { return []; }
async function getPlayerProps(/* sport */) { return []; }
module.exports = { name: SOURCE, getGames, getPlayerProps };
+13
View File
@@ -0,0 +1,13 @@
/**
* Caesars adapter — STUB.
*
* Caesars exposes props via `sportsbook.caesars.com` GraphQL endpoints. Same
* geo-gating story as DraftKings/FanDuel.
*/
const SOURCE = 'caesars';
async function getGames(/* sport */) { return []; }
async function getPlayerProps(/* sport */) { return []; }
module.exports = { name: SOURCE, getGames, getPlayerProps };
+14
View File
@@ -0,0 +1,14 @@
/**
* Covers consensus adapter — STUB.
*
* Covers.com publishes public-betting consensus percentages by game. Used
* by the CONTRARIAN / CONFIRMED badge logic. No official API — page scrape.
*/
const SOURCE = 'covers';
async function getConsensus(/* sport */) { return []; }
async function getGames(/* sport */) { return []; }
async function getPlayerProps(/* sport */) { return []; }
module.exports = { name: SOURCE, getConsensus, getGames, getPlayerProps };
@@ -0,0 +1,28 @@
/**
* DraftKings adapter — STUB.
*
* DK serves props through an undocumented REST API behind
* sportsbook-nash.draftkings.com / api.draftkings.com. Endpoints are stable
* for weeks but break without warning. Categories and subcategory IDs vary
* per sport and per market.
*
* Implementation TODOs are tracked in specs/data-pipeline-books.md.
* Until that's done this adapter conforms to the contract and returns []
* so the orchestrator records "draftkings: 0" rather than failing.
*/
const { NotImplementedAdapter } = require('./OddsAdapter');
const SOURCE = 'draftkings';
async function getGames(/* sport */) {
return [];
}
async function getPlayerProps(/* sport */) {
// STUB: returning [] keeps the unified provider's `sources` array honest.
// When real impl lands, this should use the rate limiter + breaker.
return [];
}
module.exports = { name: SOURCE, getGames, getPlayerProps, NotImplementedAdapter };
+100
View File
@@ -0,0 +1,100 @@
/**
* ESPN public scoreboard adapter.
*
* ESPN's `site.api.espn.com` scoreboard endpoints are unauthenticated and
* stable. They cover every sport we care about. We use them for:
* - Game schedule + status (scheduled / in progress / final)
* - Game-level moneyline + spread + total (when ESPN has odds)
* - Live scores during in-progress games
*
* They do NOT carry player props — that's other adapters' job.
*/
const axios = require('axios');
const rateLimiter = require('../rateLimiter');
const breaker = require('../circuitBreaker');
const ENDPOINTS = Object.freeze({
nba: 'https://site.api.espn.com/apis/site/v2/sports/basketball/nba/scoreboard',
wnba: 'https://site.api.espn.com/apis/site/v2/sports/basketball/wnba/scoreboard',
mlb: 'https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/scoreboard',
nfl: 'https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard',
nhl: 'https://site.api.espn.com/apis/site/v2/sports/hockey/nhl/scoreboard',
mma: 'https://site.api.espn.com/apis/site/v2/sports/mma/ufc/scoreboard',
golf: 'https://site.api.espn.com/apis/site/v2/sports/golf/pga/scoreboard',
boxing: 'https://site.api.espn.com/apis/site/v2/sports/boxing/scoreboard',
});
const HTTP_TIMEOUT_MS = 8_000;
const SOURCE = 'espn';
function normalizeGame(event, sport) {
const competition = event.competitions?.[0];
if (!competition) return null;
const competitors = competition.competitors || [];
const home = competitors.find((c) => c.homeAway === 'home');
const away = competitors.find((c) => c.homeAway === 'away');
const oddsRow = (competition.odds || [])[0];
return {
game_id: String(event.id),
sport,
away: away?.team?.abbreviation || null,
home: home?.team?.abbreviation || null,
away_name: away?.team?.displayName || null,
home_name: home?.team?.displayName || null,
start_time: event.date,
status: event.status?.type?.name || null, // STATUS_SCHEDULED | STATUS_IN_PROGRESS | STATUS_FINAL
venue: competition.venue?.fullName || null,
odds: oddsRow
? {
provider: oddsRow.provider?.name || null,
details: oddsRow.details || null,
spread: oddsRow.spread ?? null,
over_under: oddsRow.overUnder ?? null,
}
: null,
score:
event.status?.type?.state === 'in'
? {
away: parseInt(away?.score ?? '0', 10),
home: parseInt(home?.score ?? '0', 10),
period: event.status?.period,
clock: event.status?.displayClock,
}
: null,
source: SOURCE,
fetched_at: new Date().toISOString(),
};
}
async function getGames(sport) {
const url = ENDPOINTS[sport];
if (!url) {
const err = new Error(`espn adapter does not support sport: ${sport}`);
err.skipBreaker = true;
throw err;
}
await rateLimiter.take(SOURCE);
return breaker.call(SOURCE, async () => {
const res = await axios.get(url, {
timeout: HTTP_TIMEOUT_MS,
headers: { 'User-Agent': 'VYNDR/1.0 (+https://vyndr.app)' },
validateStatus: (s) => s >= 200 && s < 500,
});
if (res.status >= 400) {
const err = new Error(`espn returned ${res.status}`);
err.upstream = SOURCE;
throw err;
}
const events = Array.isArray(res.data?.events) ? res.data.events : [];
return events.map((e) => normalizeGame(e, sport)).filter(Boolean);
});
}
async function getPlayerProps(/* sport */) {
// ESPN's public API doesn't carry player props.
return [];
}
module.exports = { name: SOURCE, getGames, getPlayerProps, ENDPOINTS };
+14
View File
@@ -0,0 +1,14 @@
/**
* FanDuel adapter — STUB.
*
* FanDuel serves props through `sbapi.fanduel.com` and `app.fanduel.com`
* endpoints. Geo-restricted; requires a state-specific session for live
* data. Same caveats as DraftKings.
*/
const SOURCE = 'fanduel';
async function getGames(/* sport */) { return []; }
async function getPlayerProps(/* sport */) { return []; }
module.exports = { name: SOURCE, getGames, getPlayerProps };
+42
View File
@@ -0,0 +1,42 @@
/**
* OddsAdapter — common interface every odds source must implement.
*
* The UnifiedOddsProvider calls these methods on every adapter via
* Promise.allSettled, so adapters should never throw at the module boundary
* — surface failures as a fulfilled result with `error` set, or a rejected
* promise that the orchestrator can attribute to a specific source.
*
* Return shapes:
* getGames(sport): Game[]
* getPlayerProps(sport): PlayerProp[]
*
* Game = {
* game_id, sport, away, home, start_time, status, score?,
* moneyline?: { away, home }, spread?: { line, juice }, total?: { line, juice }
* }
*
* PlayerProp = {
* game_id, player_name, player_id?, stat_type, line,
* odds_over, odds_under, book, fetched_at
* }
*/
class NotImplementedAdapter {
constructor(name) {
this.name = name;
}
async getGames(/* sport */) {
return this._notImplemented('getGames');
}
async getPlayerProps(/* sport */) {
return this._notImplemented('getPlayerProps');
}
_notImplemented(method) {
const err = new Error(`${this.name}.${method} not implemented`);
err.code = 'NOT_IMPLEMENTED';
err.skipBreaker = true; // don't penalize the breaker for missing impls
throw err;
}
}
module.exports = { NotImplementedAdapter };
+96
View File
@@ -0,0 +1,96 @@
/**
* Pinnacle sharp-reference adapter.
*
* Pinnacle's lines are the sharp benchmark every other book chases. We don't
* scrape Pinnacle's site directly (TOS-grey, anti-bot). Instead we read from
* a configurable upstream — by default the public-facing `pinnacle.com` REST
* API used by their own site. If `PINNACLE_API_BASE` is set in env we use
* that (typical: a paid odds provider that proxies Pinnacle).
*
* The adapter implements the OddsAdapter contract — failure mode is an empty
* array, not a thrown error, so the orchestrator's `sources` array reflects
* who actually contributed.
*/
const axios = require('axios');
const rateLimiter = require('../rateLimiter');
const breaker = require('../circuitBreaker');
const SOURCE = 'pinnacle';
const HTTP_TIMEOUT_MS = 10_000;
const SPORT_IDS = Object.freeze({
// These are Pinnacle's internal sport IDs as observed on pinnacle.com's
// public guest API. They occasionally change.
nba: 4,
wnba: 4,
mlb: 9,
nhl: 17,
nfl: 29,
mma: 22,
golf: 12,
});
const BASE = process.env.PINNACLE_API_BASE || 'https://guest.api.arcadia.pinnacle.com/0.1';
function buildHeaders() {
// Pinnacle's guest API expects an X-API-Key header on calls; the public
// site embeds it in JS. If you have one, set PINNACLE_API_KEY.
const key = process.env.PINNACLE_API_KEY;
const headers = {
'User-Agent': 'VYNDR/1.0',
Accept: 'application/json',
};
if (key) headers['X-API-Key'] = key;
return headers;
}
async function getGames(sport) {
const sportId = SPORT_IDS[sport];
if (!sportId) {
const err = new Error(`pinnacle adapter does not support sport: ${sport}`);
err.skipBreaker = true;
throw err;
}
if (!process.env.PINNACLE_API_KEY) {
// Without an API key Pinnacle's guest endpoint refuses requests. Return
// empty so the orchestrator surfaces a clean 'pinnacle: 0 games' status
// rather than tripping the breaker.
return [];
}
await rateLimiter.take(SOURCE);
return breaker.call(SOURCE, async () => {
const url = `${BASE}/sports/${sportId}/matchups`;
const res = await axios.get(url, {
timeout: HTTP_TIMEOUT_MS,
headers: buildHeaders(),
validateStatus: (s) => s >= 200 && s < 500,
});
if (res.status >= 400) {
const err = new Error(`pinnacle returned ${res.status}`);
err.upstream = SOURCE;
throw err;
}
const matchups = Array.isArray(res.data) ? res.data : [];
return matchups.map((m) => ({
game_id: String(m.id ?? m.matchupId ?? ''),
sport,
home: m.participants?.find?.((p) => p.alignment === 'home')?.name || null,
away: m.participants?.find?.((p) => p.alignment === 'away')?.name || null,
start_time: m.startTime || null,
status: m.status || null,
source: SOURCE,
fetched_at: new Date().toISOString(),
}));
});
}
async function getPlayerProps(/* sport */) {
// Pinnacle does carry player props but they live behind a separate prices
// endpoint and are only emitted close to game time. Wired here as TODO so
// the orchestrator just gets an empty array until we light it up.
return [];
}
module.exports = { name: SOURCE, getGames, getPlayerProps, SPORT_IDS };
@@ -0,0 +1,16 @@
/**
* PrizePicks adapter — STUB.
*
* PrizePicks projections come from `api.prizepicks.com` (public). They have
* one of the cleanest schemas of any DFS provider; if we light this up early
* it gives us an excellent multi-source comparison signal.
*
* TODO: implement against /projections + /players + /leagues.
*/
const SOURCE = 'prizepicks';
async function getGames(/* sport */) { return []; }
async function getPlayerProps(/* sport */) { return []; }
module.exports = { name: SOURCE, getGames, getPlayerProps };
+16
View File
@@ -0,0 +1,16 @@
/**
* Rotowire daily projections adapter — STUB.
*
* Rotowire publishes daily lineup + projection pages per sport. Layout is
* HTML-driven and changes seasonally; we'll lock in selectors against a
* snapshot before flipping this on.
*
* Why it matters: when Rotowire's number agrees with VYNDR's projection
* AND disagrees with the book line, model-stack confidence increases.
*/
const SOURCE = 'rotowire';
async function getProjections(/* sport */) { return { sport: null, projections: [], note: 'not implemented' }; }
module.exports = { name: SOURCE, getProjections };
+97
View File
@@ -0,0 +1,97 @@
/**
* College Football Data (CFBD) — advanced college analytics.
*
* 100% free API key. Covers historical betting lines, player usage, team
* talent composites, advanced efficiency (PPA), recruiting. nba_api
* doesn't cover college; CFBD fills the gap for NCAAB and NCAAFB props.
*
* Note: CFBD's primary product is college football. College *basketball*
* coverage is via the /cbb endpoints. We expose both via the sport-aware
* functions below.
*/
const axios = require('axios');
const { createLimiter, createCircuitBreaker } = require('../../utils/rateLimiter');
const { cacheGet, cacheSet } = require('../../utils/redis');
const SOURCE = 'cfbd';
const HTTP_TIMEOUT_MS = 10_000;
const CACHE_TTL_SECONDS = 6 * 60 * 60; // 6h — most CFBD data is daily-fresh
const BASE_URL = process.env.CFBD_BASE_URL || 'https://api.collegefootballdata.com';
// Generous free tier — 10 req/min keeps us well under documented limits.
const limiter = createLimiter({ tokensPerInterval: 10, interval: 60_000 });
const breaker = createCircuitBreaker({ failureThreshold: 3, resetTimeout: 60_000 });
function configured() {
return !!process.env.CFBD_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: { Authorization: `Bearer ${process.env.CFBD_KEY}` },
timeout: HTTP_TIMEOUT_MS,
validateStatus: (s) => (s >= 200 && s < 300) || s === 429,
});
if (res.status === 429) {
const err = new Error('cfbd rate limited');
err.code = 'CFBD_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;
}
}
async function getTeamStats(team, year) {
const cacheKey = `cfbd:teamstats:${team}:${year}`;
const data = await fetchWithGuards(`${BASE_URL}/stats/season`, { year, team }, cacheKey);
return Array.isArray(data) ? data : [];
}
async function getPlayerUsage(player, team, year) {
const cacheKey = `cfbd:usage:${player}:${team}:${year}`;
const data = await fetchWithGuards(
`${BASE_URL}/player/usage`,
{ year, team, player },
cacheKey
);
return Array.isArray(data) ? data : [];
}
async function getTalentComposite(team, year) {
const cacheKey = `cfbd:talent:${team}:${year}`;
const data = await fetchWithGuards(`${BASE_URL}/talent`, { year }, cacheKey);
if (!Array.isArray(data)) return null;
return data.find((row) => (row.school || row.team) === team) || null;
}
async function getHistoricalLines(team, year) {
const cacheKey = `cfbd:lines:${team}:${year}`;
const data = await fetchWithGuards(`${BASE_URL}/lines`, { year, team }, cacheKey);
return Array.isArray(data) ? data : [];
}
module.exports = {
configured,
getTeamStats,
getPlayerUsage,
getTalentComposite,
getHistoricalLines,
__internals: { limiter, breaker, BASE_URL },
};
+157
View File
@@ -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 },
};
+157
View File
@@ -0,0 +1,157 @@
/**
* OpenRouter — LLM inference adapter (Engine 2).
*
* Primary: DeepSeek V3 (deepseek/deepseek-chat) — best reasoning/dollar,
* returns clean JSON when asked nicely.
* Fallback: Nemotron (nvidia/llama-3.3-nemotron-super-49b-v1) — used when
* primary 429s, 5xxs, or times out.
*
* SECURITY POSTURE:
* - OPENROUTER_API_KEY is the most sensitive secret in this app. We
* accept the key from env and pass it as a Bearer header — it never
* appears in URLs, logs, or error messages we emit. Axios errors that
* wrap the request are caught before re-throw to scrub headers.
* - We do NOT include the string 'VYNDR' in prompts. OpenRouter is a
* pass-through to third-party models and we don't want our brand
* name in their training/QA pipelines.
*
* EXPORTS:
* configured() → boolean
* analyze(systemMessage, userPrompt) → { response, modelUsed, latencyMs }
* or null on total failure
* getUsage() → { requestsToday, requestsRemaining }
*/
const axios = require('axios');
const { createLimiter, createCircuitBreaker } = require('../../utils/rateLimiter');
const SOURCE = 'openrouter';
const BASE_URL = process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1';
const HTTP_TIMEOUT_MS = 30_000;
const PRIMARY_MODEL = process.env.OPENROUTER_PRIMARY_MODEL || 'deepseek/deepseek-chat';
const FALLBACK_MODEL = process.env.OPENROUTER_FALLBACK_MODEL || 'nvidia/llama-3.3-nemotron-super-49b-v1';
// 20 req/min, 1000/day. The day counter is in-memory; it resets on process
// restart. That's good enough for free-tier accounting — we hit the cap
// well before midnight in normal traffic patterns.
const limiter = createLimiter({ tokensPerInterval: 20, interval: 60_000 });
const breaker = createCircuitBreaker({ failureThreshold: 3, resetTimeout: 60_000 });
const DAILY_CAP = 1000;
const usage = { requestsToday: 0, dayBucket: new Date().toISOString().slice(0, 10) };
function noteUsage() {
const today = new Date().toISOString().slice(0, 10);
if (today !== usage.dayBucket) {
usage.dayBucket = today;
usage.requestsToday = 0;
}
usage.requestsToday += 1;
}
function configured() {
return !!process.env.OPENROUTER_API_KEY;
}
function getUsage() {
return {
requestsToday: usage.requestsToday,
requestsRemaining: Math.max(0, DAILY_CAP - usage.requestsToday),
};
}
// Scrub axios errors before anything user-facing — the headers, request
// body, and full URL may contain the key.
function scrubError(err) {
return {
code: err?.code,
status: err?.response?.status,
message: err?.message || 'unknown',
};
}
async function callModel(model, systemMessage, userPrompt) {
const start = Date.now();
const body = {
model,
messages: [
{ role: 'system', content: systemMessage },
{ role: 'user', content: userPrompt },
],
temperature: 0.1,
max_tokens: 500,
// response_format works on OpenAI-compatible endpoints; harmless if a
// model ignores it. We still validate the response ourselves.
response_format: { type: 'json_object' },
};
const res = await axios.post(`${BASE_URL}/chat/completions`, body, {
headers: {
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
'Content-Type': 'application/json',
// OpenRouter recommends setting referer + title for usage tracking.
// Neither contains 'VYNDR' branding — they're generic per their docs.
'HTTP-Referer': process.env.OPENROUTER_REFERER || 'https://vyndr.app',
'X-Title': process.env.OPENROUTER_TITLE || 'Sports Analytics',
},
timeout: HTTP_TIMEOUT_MS,
validateStatus: (s) => (s >= 200 && s < 300) || s === 429 || (s >= 500 && s < 600),
});
if (res.status === 429) {
const err = new Error('openrouter rate limited');
err.code = 'OPENROUTER_429';
throw err;
}
if (res.status >= 500) {
const err = new Error(`openrouter 5xx (${res.status})`);
err.code = 'OPENROUTER_5XX';
throw err;
}
const content = res.data?.choices?.[0]?.message?.content;
if (!content) {
const err = new Error('openrouter empty response');
err.code = 'OPENROUTER_EMPTY';
throw err;
}
return { response: content, modelUsed: model, latencyMs: Date.now() - start };
}
async function analyze(systemMessage, userPrompt) {
if (!configured()) return null;
if (typeof systemMessage !== 'string' || typeof userPrompt !== 'string') return null;
if (usage.requestsToday >= DAILY_CAP) {
console.warn(`[${SOURCE}] daily cap reached (${DAILY_CAP})`);
return null;
}
await limiter.waitForToken();
// Try primary; on failure, retry once with the fallback model.
try {
const result = await breaker.call(() => callModel(PRIMARY_MODEL, systemMessage, userPrompt));
noteUsage();
return result;
} catch (primaryErr) {
const scrubbed = scrubError(primaryErr);
if (primaryErr?.code === 'CIRCUIT_OPEN') {
// Don't burn the second model when the breaker says everything is down.
return null;
}
console.warn(`[${SOURCE}] primary failed:`, scrubbed);
try {
// Fallback bypasses the breaker — different model, different upstream.
const result = await callModel(FALLBACK_MODEL, systemMessage, userPrompt);
noteUsage();
return result;
} catch (fallbackErr) {
console.warn(`[${SOURCE}] fallback also failed:`, scrubError(fallbackErr));
return null;
}
}
}
module.exports = {
configured,
analyze,
getUsage,
__internals: { limiter, breaker, callModel, scrubError, PRIMARY_MODEL, FALLBACK_MODEL, usage },
};
+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 },
};
+116
View File
@@ -0,0 +1,116 @@
/**
* PropOdds — player-prop specialist. Consensus source #2 alongside SharpAPI.
*
* Strict free-tier monthly limits — use sparingly. Specialized for the exact
* lane we live in: player props.
*
* When called: during grading, AFTER SharpAPI, to get a second consensus
* data point. Three-way consensus (SharpAPI + PropOdds + OddsPapi) is a
* stronger signal than two-way for the line-divergence trap.
*/
const axios = require('axios');
const { createLimiter, createCircuitBreaker } = require('../../utils/rateLimiter');
const { cacheGet, cacheSet } = require('../../utils/redis');
const { devig } = require('../../utils/odds');
const SOURCE = 'propodds';
const HTTP_TIMEOUT_MS = 10_000;
const CACHE_TTL_SECONDS = 90; // odds are time-sensitive but rarer fetch
const STALE_CACHE_TTL_SECONDS = 300;
const BASE_URL = process.env.PROPODDS_BASE_URL || 'https://api.prop-odds.com/v1';
// 3 req/min — strict because the free monthly cap is low.
const limiter = createLimiter({ tokensPerInterval: 3, interval: 60_000 });
const breaker = createCircuitBreaker({ failureThreshold: 3, resetTimeout: 60_000 });
const SPORT_KEYS = Object.freeze({
nba: 'nba',
wnba: 'wnba',
mlb: 'mlb',
nfl: 'nfl',
nhl: 'nhl',
ncaab: 'ncaab',
ncaafb: 'ncaaf',
});
function configured() {
return !!process.env.PROPODDS_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 && !cached.stale) return cached;
await limiter.waitForToken();
try {
const data = await breaker.call(async () => {
const res = await axios.get(url, {
params: { ...params, api_key: process.env.PROPODDS_KEY },
timeout: HTTP_TIMEOUT_MS,
validateStatus: (s) => (s >= 200 && s < 300) || s === 429,
});
if (res.status === 429) {
const err = new Error('propodds rate limited');
err.code = 'PROPODDS_429';
throw err;
}
return res.data;
});
await cacheSet(cacheKey, data, CACHE_TTL_SECONDS);
return data;
} catch (err) {
if (err?.code === 'PROPODDS_429' && cached) {
const stale = { ...cached, stale: true };
await cacheSet(cacheKey, stale, STALE_CACHE_TTL_SECONDS);
return stale;
}
if (err?.code === 'CIRCUIT_OPEN') return cached ? { ...cached, stale: true } : null;
console.warn(`[${SOURCE}] fetch failed for ${cacheKey}:`, err?.message);
return cached ? { ...cached, stale: true } : null;
}
}
function normalizeProp(raw) {
const overOdds = raw.over_odds ?? raw.over_price ?? null;
const underOdds = raw.under_odds ?? raw.under_price ?? null;
const fair = (overOdds != null && underOdds != null) ? devig(overOdds, underOdds) : null;
return {
book: raw.book ?? raw.bookmaker ?? null,
player: raw.player ?? raw.player_name ?? null,
statType: raw.market ?? raw.stat_type ?? null,
line: Number(raw.line ?? raw.point ?? null),
overOdds,
underOdds,
fairOver: fair?.fairOver ?? null,
fairUnder: fair?.fairUnder ?? null,
};
}
async function getPlayerProps(sport, gameId, player, statType) {
const key = sportKey(sport);
const cacheKey = `propodds:${sport}:${gameId}:${player || 'all'}:${statType || 'all'}`;
const data = await fetchWithGuards(
`${BASE_URL}/sports/${key}/games/${gameId}/odds`,
{ player, market: statType },
cacheKey
);
if (!data) return [];
const raw = data.props || data.markets || data.data || [];
const normalized = Array.isArray(raw) ? raw.map(normalizeProp) : [];
return data.stale ? Object.assign(normalized, { stale: true }) : normalized;
}
module.exports = {
configured,
getPlayerProps,
__internals: { limiter, breaker, SPORT_KEYS, BASE_URL, normalizeProp },
};
+229
View File
@@ -0,0 +1,229 @@
/**
* SharpAPI — PRIMARY real-time odds source.
*
* Called by the GRADING PIPELINE (n8n) before a game tips. NOT used by the
* resolution poller — closing-line capture goes through OddsPapi for the
* Pinnacle benchmark.
*
* The adapter exposes three reads:
* getPlayerProps — every player prop across books, de-vigged
* getGameOdds — spread / total / moneyline for one game
* getConsensusLine — median / min / max line across books (trap detector)
*
* Free-tier budget is 12 req/min. We cap at 10 to leave headroom for
* incident retries. Responses cache in Redis for 60s — long enough to
* coalesce duplicate grade requests for the same prop, short enough that a
* line move propagates inside a minute.
*
* Failure modes:
* 429 — back off, serve stale cache marked `{ stale: true }`
* 5xx — circuit breaker (3 fails → open 60s)
* timeout — 10s connect/read, circuit breaker counts it as a failure
*/
const axios = require('axios');
const { createLimiter, createCircuitBreaker, API_BUDGETS } = require('../../utils/rateLimiter');
const { cacheGet, cacheSet } = require('../../utils/redis');
const { devig } = require('../../utils/odds');
const { getSupabaseServiceClient } = require('../../utils/supabase');
const SOURCE = 'sharpapi';
const HTTP_TIMEOUT_MS = 10_000;
const CACHE_TTL_SECONDS = 60;
const STALE_CACHE_TTL_SECONDS = 300;
const BASE_URL = process.env.SHARPAPI_BASE_URL || 'https://api.sharpapi.com/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.sharpApi);
const breaker = createCircuitBreaker({ failureThreshold: 3, resetTimeout: 60_000 });
function configured() {
return !!process.env.SHARPAPI_KEY;
}
function authHeaders() {
return { 'X-Api-Key': process.env.SHARPAPI_KEY };
}
function sportKey(sport) {
const key = SPORT_KEYS[sport];
if (!key) throw new Error(`Unsupported sport: ${sport}`);
return key;
}
function median(nums) {
if (!nums.length) return null;
const sorted = [...nums].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}
async function fetchWithGuards(url, params, cacheKey) {
if (!configured()) return null;
// 1. Hot cache — fresh hit returns immediately.
const cached = await cacheGet(cacheKey);
if (cached && !cached.stale) return cached;
await limiter.waitForToken();
try {
const data = await breaker.call(async () => {
const res = await axios.get(url, {
params,
headers: authHeaders(),
timeout: HTTP_TIMEOUT_MS,
validateStatus: (s) => (s >= 200 && s < 300) || s === 429,
});
if (res.status === 429) {
const err = new Error('sharpapi rate limited');
err.code = 'SHARPAPI_429';
throw err;
}
return res.data;
});
await cacheSet(cacheKey, data, CACHE_TTL_SECONDS);
return data;
} catch (err) {
if (err?.code === 'SHARPAPI_429' && cached) {
// Serve stale cache marked so callers can decide whether to trust it.
const stale = { ...cached, stale: true };
await cacheSet(cacheKey, stale, STALE_CACHE_TTL_SECONDS);
return stale;
}
if (err?.code === 'CIRCUIT_OPEN') {
// Don't spam logs while the breaker is open — one warn per minute is
// enough; the snapshot tells ops the state.
return cached ? { ...cached, stale: true } : null;
}
console.warn(`[sharpapi] fetch failed for ${cacheKey}:`, err?.message);
return cached ? { ...cached, stale: true } : null;
}
}
function normalizePlayerProp(raw) {
// Defensive shape — SharpAPI returns slightly different field names across
// markets. We only surface the fields downstream consumers actually need.
const overOdds = raw.over_price ?? raw.overOdds ?? null;
const underOdds = raw.under_price ?? raw.underOdds ?? null;
const fair = (overOdds != null && underOdds != null) ? devig(overOdds, underOdds) : null;
return {
book: raw.book ?? raw.bookmaker ?? null,
player: raw.player ?? raw.player_name ?? null,
statType: raw.stat_type ?? raw.market ?? null,
line: Number(raw.line ?? raw.point ?? null),
overOdds,
underOdds,
fairOver: fair?.fairOver ?? null,
fairUnder: fair?.fairUnder ?? null,
};
}
// Fire-and-forget snapshot writer. Persists each line we observe so we can
// later detect reverse line movement + juice degradation. Never blocks the
// caller — a Supabase outage must not stop the grading pipeline.
function snapshotProps(sport, gameId, normalized, consensusMedian) {
if (!normalized || normalized.length === 0) return;
const rows = normalized
.filter((p) => Number.isFinite(p.line))
.map((p) => ({
game_id: gameId,
sport,
player_name: p.player,
player_id: null,
stat_type: p.statType,
line: p.line,
over_odds: p.overOdds,
under_odds: p.underOdds,
book: p.book,
consensus_median: consensusMedian ?? null,
}));
if (rows.length === 0) return;
// Run after the response — Promise.resolve().then keeps it off the
// caller's critical path without leaking unhandled rejections.
Promise.resolve().then(async () => {
try {
const supabase = getSupabaseServiceClient();
const { error } = await supabase.from('line_snapshots').insert(rows);
if (error) console.warn('[sharpapi] snapshot insert failed:', error.message);
} catch (err) {
console.warn('[sharpapi] snapshot insert threw:', err?.message);
}
});
}
async function getPlayerProps(sport, gameId) {
const key = sportKey(sport);
const cacheKey = `odds:${sport}:${gameId}:player_props`;
const data = await fetchWithGuards(
`${BASE_URL}/sports/${key}/events/${gameId}/odds`,
{ markets: 'player_props' },
cacheKey
);
if (!data) return [];
const raw = data.props || data.markets || data.data || [];
const normalized = Array.isArray(raw) ? raw.map(normalizePlayerProp) : [];
// Only snapshot fresh data — stale-cache fallbacks are previously stored
// already; re-snapshotting would mint duplicate "now" rows on every call.
if (!data.stale && normalized.length) {
snapshotProps(sport, gameId, normalized);
}
return data.stale ? Object.assign(normalized, { stale: true }) : normalized;
}
async function getGameOdds(sport, gameId) {
const key = sportKey(sport);
const cacheKey = `odds:${sport}:${gameId}:game`;
const data = await fetchWithGuards(
`${BASE_URL}/sports/${key}/events/${gameId}/odds`,
{ markets: 'spreads,totals,h2h' },
cacheKey
);
if (!data) return null;
return {
spread: data.spread ?? null,
total: data.total ?? null,
moneyline: data.h2h ?? data.moneyline ?? null,
stale: !!data.stale,
};
}
async function getConsensusLine(sport, gameId, playerName, statType) {
const props = await getPlayerProps(sport, gameId);
const matches = props.filter(
(p) => p.player && p.statType
&& p.player.toLowerCase() === playerName.toLowerCase()
&& p.statType === statType
&& Number.isFinite(p.line)
);
if (!matches.length) return null;
const lines = matches.map((p) => p.line);
return {
median: median(lines),
min: Math.min(...lines),
max: Math.max(...lines),
bookCount: matches.length,
stale: !!props.stale,
};
}
module.exports = {
configured,
getPlayerProps,
getGameOdds,
getConsensusLine,
// Exported for tests so they can poke the circuit breaker / limiter state.
__internals: { limiter, breaker, SPORT_KEYS },
};