Session 56: Full audit — PropLine + boxscore + pipeline + sport coverage (2289 tests)
Research (verified against live MLB Stats / ESPN / The Odds APIs): - specs/propline-audit.md — every stat_type mapped against our 4-layer pipeline; real MLB boxscore fields; sport coverage status; pipeline gap analysis. - specs/vyndr-roadmap.md — priority-ordered Sessions 57–64 + coverage targets. - scripts/propline-audit.js + specs/audit-data/ (raw capture). Headline bug: oddsNormalizer mapped batter_rbis → 'rbis' while the whole grade/feature/outcome chain keys on 'rbi' — every PropLine RBI prop silently failed to grade AND settle. Fixed (+ regression test). Phase 4 — wired missing MLB stats end-to-end: - PropLine MLB markets 6 → 12 (+runs, walks, doubles, earned_runs, hits_allowed, outs — same request, no extra quota). - doubles/outs/triples added to featureCache + outcomeService MLB_LOG_FIELD and all three grade whitelists (analyze/scan/validation.py). Phase 6 — pipeline resilience: - opsNotify.js: ntfy alerts (never throws, test-disabled). Snapshot success/ stale/failure alerts; retry-once on hard odds error (not on empty slate). - Missed-cron watchdog (mostRecentExpectedSlot/isSnapshotOverdue); status probe now returns `overdue`. Coverage truth: MLB is the only end-to-end-live sport; outcome settlement is MLB-only (WNBA/NBA/soccer never settle) — documented as the #1 roadmap gap. Backend 2276 → 2289 tests (+13). Web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* scripts/propline-audit.js (Session 56) — the data-source audit.
|
||||
*
|
||||
* Inventories what our providers CAN send (from code) and what the FREE
|
||||
* settled-result APIs (MLB Stats, ESPN) actually return, so we can map every
|
||||
* prop stat_type to its box-score field. PropLine live fetch runs only when
|
||||
* PROPLINE_API_KEY_* are present (they aren't in dev) — otherwise we report the
|
||||
* authoritative code inventory (MARKETS × MARKET_MAP).
|
||||
*
|
||||
* Usage: node scripts/propline-audit.js (writes JSON to stdout)
|
||||
*/
|
||||
|
||||
require('dotenv').config({ quiet: true });
|
||||
const propline = require('../src/services/adapters/proplineAdapter');
|
||||
const { MARKET_MAP } = require('../src/utils/oddsNormalizer');
|
||||
|
||||
const out = { generatedAt: new Date().toISOString(), sections: {} };
|
||||
|
||||
async function getJson(url, headers) {
|
||||
const r = await fetch(url, { headers: headers || {}, signal: AbortSignal.timeout(12000) });
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
// 1. PropLine code inventory — the markets we REQUEST per sport + the stat_type
|
||||
// each normalizes to (or "UNMAPPED" if MARKET_MAP has no entry → silent zero).
|
||||
function proplineInventory() {
|
||||
const { MARKETS, SPORT_KEYS } = propline.__internals;
|
||||
const map = MARKET_MAP || {};
|
||||
const inv = {};
|
||||
for (const [sport, markets] of Object.entries(MARKETS)) {
|
||||
inv[sport] = {
|
||||
sportKey: SPORT_KEYS[sport],
|
||||
requested: markets.map((m) => ({ market: m, stat_type: map[m] || 'UNMAPPED' })),
|
||||
};
|
||||
}
|
||||
// Also list every MARKET_MAP entry (what we CAN normalize even if not requested).
|
||||
inv._allMappedMarkets = Object.entries(map).map(([m, s]) => ({ market: m, stat_type: s }));
|
||||
inv._hasKeys = propline.hasKeys();
|
||||
return inv;
|
||||
}
|
||||
|
||||
// 2. The Odds API active sports (FREE — /v4/sports does not spend quota).
|
||||
async function oddsApiSports() {
|
||||
const key = process.env.ODDS_API_KEY;
|
||||
if (!key) return { skipped: 'no ODDS_API_KEY' };
|
||||
try {
|
||||
const data = await getJson(`https://api.the-odds-api.com/v4/sports?apiKey=${key}`);
|
||||
return (data || [])
|
||||
.filter((s) => s.active)
|
||||
.map((s) => ({ key: s.key, group: s.group, title: s.title }));
|
||||
} catch (e) { return { error: e.message }; }
|
||||
}
|
||||
|
||||
// 3. MLB Stats API — a recent FINAL game's boxscore field keys + a game-log row.
|
||||
async function mlbBoxscore() {
|
||||
try {
|
||||
// Yesterday's schedule.
|
||||
const d = new Date(Date.now() - 24 * 3600 * 1000).toISOString().slice(0, 10);
|
||||
const sched = await getJson(`https://statsapi.mlb.com/api/v1/schedule?sportId=1&date=${d}`);
|
||||
const games = (sched.dates?.[0]?.games) || [];
|
||||
const final = games.find((g) => g.status?.abstractGameState === 'Final') || games[0];
|
||||
if (!final) return { note: `no games ${d}` };
|
||||
const box = await getJson(`https://statsapi.mlb.com/api/v1/game/${final.gamePk}/boxscore`);
|
||||
// Pull one batter + one pitcher stat object.
|
||||
const sampleTeam = box.teams?.away || box.teams?.home || {};
|
||||
const players = Object.values(sampleTeam.players || {});
|
||||
const batter = players.find((p) => p.stats?.batting && Object.keys(p.stats.batting).length);
|
||||
const pitcher = players.find((p) => p.stats?.pitching && Object.keys(p.stats.pitching).length);
|
||||
return {
|
||||
date: d, gamePk: final.gamePk, matchup: final.teams?.away?.team?.name + ' @ ' + final.teams?.home?.team?.name,
|
||||
battingFields: batter ? Object.keys(batter.stats.batting) : [],
|
||||
pitchingFields: pitcher ? Object.keys(pitcher.stats.pitching) : [],
|
||||
sampleBatting: batter ? batter.stats.batting : null,
|
||||
samplePitching: pitcher ? pitcher.stats.pitching : null,
|
||||
};
|
||||
} catch (e) { return { error: e.message }; }
|
||||
}
|
||||
|
||||
// 4. ESPN WNBA — today's scoreboard status + a completed game's boxscore labels.
|
||||
async function espnWnba() {
|
||||
try {
|
||||
const sb = await getJson('https://site.api.espn.com/apis/site/v2/sports/basketball/wnba/scoreboard');
|
||||
const events = (sb.events || []).map((e) => ({ name: e.name, status: e.status?.type?.description, date: e.date }));
|
||||
const final = (sb.events || []).find((e) => e.status?.type?.completed);
|
||||
let boxLabels = null;
|
||||
if (final) {
|
||||
const summary = await getJson(`https://site.api.espn.com/apis/site/v2/sports/basketball/wnba/summary?event=${final.id}`);
|
||||
const teamStats = summary.boxscore?.players?.[0]?.statistics?.[0];
|
||||
boxLabels = teamStats ? { labels: teamStats.labels, names: teamStats.names } : null;
|
||||
}
|
||||
return { count: events.length, events, completedBoxLabels: boxLabels };
|
||||
} catch (e) { return { error: e.message }; }
|
||||
}
|
||||
|
||||
// 5. ESPN soccer — check a common league scoreboard for activity.
|
||||
async function espnSoccer() {
|
||||
try {
|
||||
const sb = await getJson('https://site.api.espn.com/apis/site/v2/sports/soccer/usa.1/scoreboard');
|
||||
const events = (sb.events || []).map((e) => ({ name: e.name, status: e.status?.type?.description }));
|
||||
return { league: 'usa.1 (MLS)', count: events.length, events: events.slice(0, 6) };
|
||||
} catch (e) { return { error: e.message }; }
|
||||
}
|
||||
|
||||
(async () => {
|
||||
out.sections.proplineInventory = proplineInventory();
|
||||
out.sections.oddsApiActiveSports = await oddsApiSports();
|
||||
out.sections.mlbBoxscore = await mlbBoxscore();
|
||||
out.sections.espnWnba = await espnWnba();
|
||||
out.sections.espnSoccer = await espnSoccer();
|
||||
process.stdout.write(JSON.stringify(out, null, 2) + '\n');
|
||||
})();
|
||||
Reference in New Issue
Block a user