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:
@@ -46,7 +46,11 @@ const SPORT_KEYS = {
|
||||
const MARKETS = {
|
||||
nba: ['player_points', 'player_rebounds', 'player_assists', 'player_threes', 'player_blocks', 'player_steals'],
|
||||
wnba: ['player_points', 'player_rebounds', 'player_assists', 'player_threes'],
|
||||
mlb: ['batter_hits', 'batter_home_runs', 'batter_total_bases', 'batter_rbis', 'batter_stolen_bases', 'pitcher_strikeouts'],
|
||||
// Session 56 audit — expanded from 6 to 12 markets. runs/walks/earned_runs/
|
||||
// hits_allowed were already fully supported downstream (whitelist + features +
|
||||
// outcomes) but never requested; doubles/outs are wired this session. All ride
|
||||
// the SAME request (no extra quota) → materially more graded props per slate.
|
||||
mlb: ['batter_hits', 'batter_home_runs', 'batter_total_bases', 'batter_rbis', 'batter_stolen_bases', 'batter_runs', 'batter_walks', 'batter_doubles', 'pitcher_strikeouts', 'pitcher_earned_runs', 'pitcher_hits_allowed', 'pitcher_outs'],
|
||||
nfl: ['player_pass_yds', 'player_rush_yds', 'player_reception_yds', 'player_receptions', 'player_anytime_td', 'player_pass_tds'],
|
||||
nhl: ['player_goals', 'player_shots_on_goal', 'goalie_saves'],
|
||||
ncaab: ['player_points', 'player_rebounds', 'player_assists'],
|
||||
|
||||
@@ -82,6 +82,8 @@ const MLB_LOG_FIELD = {
|
||||
runs: 'runs', stolen_bases: 'stolenBases', walks: 'baseOnBalls',
|
||||
strikeouts: 'strikeOuts', earned_runs: 'earnedRuns', hits_allowed: 'hits',
|
||||
innings_pitched: 'inningsPitched',
|
||||
// Session 56 audit — real boxscore/game-log fields (Braves@Pirates verified).
|
||||
doubles: 'doubles', triples: 'triples', outs: 'outs',
|
||||
};
|
||||
|
||||
function mlbStatValue(statObj, statType) {
|
||||
|
||||
@@ -41,6 +41,8 @@ const MLB_LOG_FIELD = {
|
||||
runs: 'runs', stolen_bases: 'stolenBases', walks: 'baseOnBalls',
|
||||
strikeouts: 'strikeOuts', earned_runs: 'earnedRuns', hits_allowed: 'hits',
|
||||
innings_pitched: 'inningsPitched',
|
||||
// Session 56 audit — confirmed present in the real boxscore/game log.
|
||||
doubles: 'doubles', triples: 'triples', outs: 'outs',
|
||||
};
|
||||
|
||||
function statValue(statObj, statType) {
|
||||
|
||||
@@ -18,7 +18,9 @@ VALID_STAT_TYPES = {
|
||||
'steals', 'blocks', 'turnovers'],
|
||||
'mlb': ['strikeouts', 'hits', 'home_runs', 'rbi', 'total_bases',
|
||||
'walks', 'runs', 'earned_runs', 'innings_pitched',
|
||||
'hits_allowed', 'stolen_bases']
|
||||
'hits_allowed', 'stolen_bases',
|
||||
# Session 56 audit — keep in sync with analyze.js + scan.js.
|
||||
'doubles', 'outs']
|
||||
}
|
||||
|
||||
VALID_SPORTS = ['nba', 'mlb']
|
||||
|
||||
@@ -184,18 +184,36 @@ async function runSnapshot(sport, opts = {}) {
|
||||
cacheSet: opts.cacheSet || require('../utils/redis').cacheSet,
|
||||
now: opts.now || (() => new Date().toISOString()),
|
||||
nowMs: opts.nowMs || (() => Date.now()),
|
||||
// Session 56 — ops alerting (ntfy) + retry-once on a hard odds failure.
|
||||
notify: opts.notify || require('../utils/opsNotify').notify,
|
||||
sleep: opts.sleep || ((ms) => new Promise((r) => setTimeout(r, ms))),
|
||||
retryDelayMs: opts.retryDelayMs != null ? opts.retryDelayMs : 60_000,
|
||||
};
|
||||
const start = deps.nowMs();
|
||||
const ts = deps.now();
|
||||
|
||||
// Session 56 — retry ONCE on a hard failure (thrown error / null response = a
|
||||
// transient PropLine/network blip). A successful-but-empty slate is NOT a
|
||||
// failure (off-hours), so it is not retried — that would waste quota + latency.
|
||||
let odds;
|
||||
try {
|
||||
odds = await deps.getOdds(sp);
|
||||
if (odds == null) throw new Error('null odds response');
|
||||
} catch (e) {
|
||||
return { sport: sp, status: 'error', reason: e.message, gradeCount: 0 };
|
||||
try {
|
||||
await deps.sleep(deps.retryDelayMs);
|
||||
odds = await deps.getOdds(sp);
|
||||
if (odds == null) throw new Error('null odds response (retry)');
|
||||
} catch (e2) {
|
||||
await deps.notify(`❌ ${sp.toUpperCase()} snapshot FAILED: ${e2.message}`, { title: 'VYNDR pipeline', priority: 'high', tags: ['x'] });
|
||||
return { sport: sp, status: 'error', reason: e2.message, gradeCount: 0 };
|
||||
}
|
||||
}
|
||||
const props = (odds && Array.isArray(odds.props)) ? odds.props : [];
|
||||
if (props.length === 0) return { sport: sp, status: 'skipped', reason: 'no props', gradeCount: 0 };
|
||||
if (props.length === 0) {
|
||||
await deps.notify(`⚠️ ${sp.toUpperCase()} snapshot: 0 props (odds unavailable)`, { title: 'VYNDR pipeline', priority: 'low', tags: ['warning'] });
|
||||
return { sport: sp, status: 'skipped', reason: 'no props', gradeCount: 0 };
|
||||
}
|
||||
|
||||
// Grade the slate via the existing service; capture the envelope instead of
|
||||
// letting it write (we re-write an ENRICHED version below).
|
||||
@@ -273,6 +291,18 @@ async function runSnapshot(sport, opts = {}) {
|
||||
const events = generateTickerEvents(sp, enriched, deltas, ts);
|
||||
await pushTickerItems(events, deps);
|
||||
|
||||
// Session 56 — success alert, enriched with the rolling accuracy (if settled).
|
||||
let accPart = '';
|
||||
try {
|
||||
const acc = await deps.cacheGet(`accuracy:${sp}`);
|
||||
const pct = acc && acc.overall && acc.overall.pct;
|
||||
if (pct != null) accPart = `, ${pct}% accuracy (30d)`;
|
||||
} catch { /* accuracy is best-effort in the alert */ }
|
||||
await deps.notify(
|
||||
`✅ ${sp.toUpperCase()} snapshot: ${enriched.length} props graded, ${deltas.length} deltas${accPart}`,
|
||||
{ title: 'VYNDR pipeline', tags: ['white_check_mark'] },
|
||||
);
|
||||
|
||||
return {
|
||||
sport: sp,
|
||||
status: 'ok',
|
||||
|
||||
Reference in New Issue
Block a user