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:
Kev
2026-07-10 17:00:29 -04:00
parent d09a06c054
commit 2ae8a5697e
21 changed files with 1607 additions and 11 deletions
+114
View File
@@ -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');
})();