Files
vyndr/scripts/propline-audit.js
builtbykev 2ae8a5697e 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>
2026-07-10 17:00:29 -04:00

115 lines
5.2 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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');
})();