'use strict'; /** * outcomeService — the self-learning loop (Session 55). * * VYNDR grades props but never checked whether it was right. This closes the * loop: after games complete, settle each locked snapshot grade against the * REAL result (did the player clear the line?), record hit/miss/push, and * aggregate a rolling accuracy record by grade tier. That powers the accuracy * display ("A-rated props: 68% hit rate"), the #1 trust builder — a system that * shows its misses, not just its hits. * * Data source: the FREE MLB Stats API game log (mlbStatsAdapter.getPlayerStats) * — the same source the grade pipeline already uses. Presence of a game-log row * for the graded date ⇒ the game is FINAL. NBA/WNBA degrade to `pending` when * their (usually offline) Python stats service returns nothing — never throw. * Zero new dependency, zero paid API credits. * * Redis keys written: * outcomes:{sport}:log — settled outcomes, newest first, cap 1000, deduped * accuracy:{sport} — { overall, byGrade } over a trailing 30-day window * accuracy:overall — same, aggregated across sports (dashboard header) * * Everything is injectable → the whole cycle is unit-tested with zero network. */ const { nameKey } = require('../utils/playerName'); const LOG_CAP = 1000; const LOG_TTL = 30 * 24 * 3600; // 30d — matches the accuracy window const ACC_TTL = 7 * 24 * 3600; const WINDOW_DAYS = 30; const MIN_SAMPLE = 8; // below this, callers should hide the pct const SPORTS = ['mlb', 'nba', 'wnba', 'soccer']; // VYNDR stat_type → the per-game field in a statsapi.mlb.com game-log row. // Mirrors featureCache.MLB_LOG_FIELD (settlement is a distinct concern, kept // self-contained so this service doesn't depend on test-only internals). const MLB_LOG_FIELD = { total_bases: 'totalBases', home_runs: 'homeRuns', hits: 'hits', rbi: 'rbi', 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', }; // Wave 1 — NBA/WNBA settlement box-field map. A SEPARATE local map from // MLB_LOG_FIELD (the S11 three-map-split rule — settlement, features, and live // tracking each own their own map; never merge). The ESPN gamelog row's `stat` // object (espnStatsAdapter.getPlayerGameLog) is already keyed by VYNDR stat // names, so simple stats map 1:1; combo stat_types (pts_reb_ast, …) sum their // components at read time — mirroring featureCache.statFromGameLog. A stat_type // absent here does NOT settle (absent beats a fabricated outcome); add a new // NBA/WNBA stat here (and to featureCache + liveTrackingService) to unlock it. const NBA_BOX_KEY = { points: 'points', rebounds: 'rebounds', assists: 'assists', threes: 'threes', steals: 'steals', blocks: 'blocks', turnovers: 'turnovers', pra: 'pra', }; const NBA_COMBO = { pts_reb_ast: ['points', 'rebounds', 'assists'], pts_reb: ['points', 'rebounds'], pts_ast: ['points', 'assists'], reb_ast: ['rebounds', 'assists'], stl_blk: ['steals', 'blocks'], }; // Resolve one NBA/WNBA per-game stat value. Combos require EVERY component to // be present (a played game reports 0, not absent) — a partial box never // fabricates a combo total. function nbaStatValue(statObj, statType) { if (!statObj) return null; const st = String(statType || '').toLowerCase(); const combo = NBA_COMBO[st]; if (combo) { let sum = 0; for (const c of combo) { const n = parseFloat(statObj[c]); if (!Number.isFinite(n)) return null; sum += n; } return sum; } const f = NBA_BOX_KEY[st]; if (!f) return null; const n = parseFloat(statObj[f]); return Number.isFinite(n) ? n : null; } // Sport-aware actual-value resolver. Default (unspecified/mlb) reads the // statsapi.mlb.com game-log field; nba/wnba read the ESPN gamelog box object. function statValue(statObj, statType, sport) { const sp = String(sport || 'mlb').toLowerCase(); if (sp === 'nba' || sp === 'wnba') return nbaStatValue(statObj, statType); const f = MLB_LOG_FIELD[String(statType || '').toLowerCase()]; if (!f || !statObj) return null; const n = parseFloat(statObj[f]); return Number.isFinite(n) ? n : null; } // The UTC date AND the America/New_York date of an ISO timestamp — covers a // late game whose ET calendar date differs from UTC, without heavy TZ math. function dateStrings(ts) { if (!ts) return []; const d = new Date(ts); if (isNaN(d.getTime())) return []; const utc = d.toISOString().slice(0, 10); let et = utc; try { et = new Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York' }).format(d); } catch { /* Intl missing → UTC only */ } return [...new Set([utc, et])]; } // The America/New_York calendar date (YYYY-MM-DD) of an ISO timestamp. function etDate(ts) { if (!ts) return null; const d = new Date(ts); if (isNaN(d.getTime())) return null; try { return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York' }).format(d); } catch { return d.toISOString().slice(0, 10); } } /** * Does a game-log row fall on `targetDate` (YYYY-MM-DD)? * MLB rows are already plain YYYY-MM-DD → exact compare (unchanged behavior). * NBA/WNBA rows carry a FULL ISO timestamp from ESPN (e.g. a late tip is * 00:30Z the next calendar day) → match on either its UTC or ET date so a * game whose UTC date rolled past midnight still matches its ET slate date. */ function logRowOnDate(logRow, targetDate, sport) { if (!logRow || !logRow.date || !targetDate) return false; const sp = String(sport || 'mlb').toLowerCase(); if (sp === 'nba' || sp === 'wnba') return dateStrings(logRow.date).includes(targetDate); return logRow.date === targetDate; } const sideOver = (side) => { const s = String(side || 'over').toLowerCase(); return s === 'over' || s === 'o'; }; // hit / miss / push for a graded side given the actual result and the line. function settleResult(side, actual, line) { if (actual == null || line == null) return null; const a = Number(actual), l = Number(line); if (!Number.isFinite(a) || !Number.isFinite(l)) return null; if (a === l) return 'push'; return sideOver(side) ? (a > l ? 'hit' : 'miss') : (a < l ? 'hit' : 'miss'); } // Bucket a letter grade into a tier: A+ stands alone; A-/A → A; B±/B → B; … function gradeBucket(grade) { const g = String(grade || '').trim().toUpperCase(); if (!g) return null; if (g === 'A+') return 'A+'; return g[0]; } const TIERS = ['A+', 'A', 'B', 'C', 'D', 'F']; function outcomeKey(o) { return `${nameKey(o.player)}|${String(o.stat).toLowerCase()}|${o.line}|${sideOver(o.side) ? 'O' : 'U'}|${o.date}`; } /** * Settle one sport's latest snapshot against real results. Returns * { sport, settled, pending, log } and persists the merged log + accuracy. * Never throws; a player/stat that can't be resolved is simply left pending. * * opts (all injectable): cacheGet, cacheSet, getPlayerStats(name, sport), * now (ISO string). */ async function settleSnapshot(sport, opts = {}) { const sp = String(sport || '').toLowerCase(); const deps = { cacheGet: opts.cacheGet || require('../utils/redis').cacheGet, cacheSet: opts.cacheSet || require('../utils/redis').cacheSet, getPlayerStats: opts.getPlayerStats || defaultGetPlayerStats, now: opts.now || (() => new Date().toISOString()), }; const nowIso = deps.now(); const snap = await deps.cacheGet(`snapshot:${sp}:latest`); const grades = snap && Array.isArray(snap.grades) ? snap.grades : []; if (grades.length === 0) return { sport: sp, settled: 0, pending: 0, log: [] }; // Existing settled log (idempotency source). const prevLog = normalizeLog(await deps.cacheGet(`outcomes:${sp}:log`)); const seen = new Set(prevLog.map(outcomeKey)); // Resolve each unique player's game log ONCE per run. const players = [...new Set(grades.map((g) => g.player || g.player_name).filter(Boolean))]; const logByPlayer = {}; for (const player of players) { try { const stats = await deps.getPlayerStats(player, sp); logByPlayer[player] = stats && stats.found && Array.isArray(stats.last10) ? stats.last10 : []; } catch { logByPlayer[player] = []; } } const todayEt = etDate(nowIso); const isBasketball = sp === 'nba' || sp === 'wnba'; const fresh = []; let pending = 0; for (const g of grades) { const player = g.player || g.player_name; const stat = g.stat_type || g.stat; const side = g.direction || 'over'; const line = g.line; const gradedTs = (g.gradedAt && g.gradedAt.timestamp) || snap.updated_at || nowIso; const dates = dateStrings(gradedTs); const log = logByPlayer[player] || []; // Find the game played on the graded date (sport-aware date normalization). const row = log.find((r) => dates.some((d) => logRowOnDate(r, d, sp))); if (!row) { pending += 1; continue; } // Detect FINAL honestly: a game-log row is a COMPLETED game, but never // settle a game whose ET date is today (could be an in-progress partial // row for NBA/WNBA). MLB game logs are final-only + settle same-day, so // this guard is scoped to basketball — it must not stall MLB afternoons. if (isBasketball) { const rowEt = etDate(row.date); if (!rowEt || rowEt >= todayEt) { pending += 1; continue; } } const actual = statValue(row.stat, stat, sp); if (actual == null) { pending += 1; continue; } const result = settleResult(side, actual, line); if (!result) { pending += 1; continue; } // Store the game's ET calendar date (YYYY-MM-DD). MLB rows are already in // that form; basketball rows carry a full ISO timestamp → normalize so the // accuracy 30-day window filter + the idempotency key (both key off `date`) // behave identically across sports. const outcomeDate = isBasketball ? (etDate(row.date) || row.date) : row.date; const outcome = { player, stat, line, side: sideOver(side) ? 'O' : 'U', grade: g.grade, actual, result, date: outcomeDate, gradedAt: gradedTs, settledAt: nowIso, }; const key = outcomeKey(outcome); if (seen.has(key)) continue; // idempotent — already settled seen.add(key); fresh.push({ ...outcome, key }); } // Merge (newest first), cap. const merged = [...fresh, ...prevLog].slice(0, LOG_CAP); await deps.cacheSet(`outcomes:${sp}:log`, merged, LOG_TTL); const accuracy = computeAccuracy(sp, merged, nowIso); await deps.cacheSet(`accuracy:${sp}`, accuracy, ACC_TTL); return { sport: sp, settled: fresh.length, pending, log: merged, accuracy }; } // Aggregate a settled log into an accuracy record over the trailing window. function computeAccuracy(sport, log, nowIso, windowDays = WINDOW_DAYS) { const cutoff = new Date(nowIso).getTime() - windowDays * 24 * 3600 * 1000; const inWindow = (log || []).filter((o) => { const t = new Date(`${o.date}T12:00:00Z`).getTime(); return Number.isFinite(t) && t >= cutoff; }); const bucketFor = (grade) => { const b = gradeBucket(grade); return TIERS.includes(b) ? b : null; }; const blank = () => ({ hits: 0, misses: 0, pushes: 0, total: 0, pct: null }); const byGrade = {}; for (const t of TIERS) byGrade[t] = blank(); const overall = blank(); for (const o of inWindow) { const tier = bucketFor(o.grade); const targets = [overall]; if (tier) targets.push(byGrade[tier]); for (const bucket of targets) { if (o.result === 'hit') bucket.hits += 1; else if (o.result === 'miss') bucket.misses += 1; else if (o.result === 'push') bucket.pushes += 1; } } const finalize = (b) => { b.total = b.hits + b.misses + b.pushes; const decided = b.hits + b.misses; b.pct = decided > 0 ? Math.round((b.hits / decided) * 100) : null; return b; }; finalize(overall); for (const t of TIERS) finalize(byGrade[t]); return { sport, updated_at: nowIso, window_days: windowDays, sample: overall.total, min_sample: MIN_SAMPLE, overall, byGrade, }; } function normalizeLog(raw) { if (Array.isArray(raw)) return raw; if (raw && Array.isArray(raw.log)) return raw.log; return []; } async function defaultGetPlayerStats(name, sport) { const sp = String(sport || '').toLowerCase(); if (sp === 'mlb') { return require('./adapters/mlbStatsAdapter').getPlayerStats(name); } // Wave 1 — NBA/WNBA settle against the FREE ESPN per-game log (Wave 0's // getPlayerGameLog returns the same { found, last10:[{date, stat}] } shape). if (sp === 'nba' || sp === 'wnba') { return require('./adapters/espnStatsAdapter').getPlayerGameLog(name, sp); } // soccer: no free settled-result feed yet → pending. return { found: false }; } /** * Recompute the cross-sport accuracy:overall record from every sport's log. * Called after settling. Deps: cacheGet, cacheSet, now. */ async function recomputeOverall(opts = {}) { const deps = { cacheGet: opts.cacheGet || require('../utils/redis').cacheGet, cacheSet: opts.cacheSet || require('../utils/redis').cacheSet, now: opts.now || (() => new Date().toISOString()), }; const nowIso = deps.now(); const logs = []; for (const sp of SPORTS) { const l = normalizeLog(await deps.cacheGet(`outcomes:${sp}:log`)); logs.push(...l); } const acc = computeAccuracy('overall', logs, nowIso); await deps.cacheSet('accuracy:overall', acc, ACC_TTL); return acc; } /** Settle every sport, then recompute the overall record. Cron entrypoint. */ async function settleAllOutcomes(opts = {}) { const results = []; for (const sp of SPORTS) { try { results.push(await settleSnapshot(sp, opts)); } catch (e) { results.push({ sport: sp, settled: 0, pending: 0, error: e.message }); } } await recomputeOverall(opts); return results; } /** * Read the persisted accuracy record for the public endpoint. Cold-cache safe: * returns an empty-but-valid shape. Deps: cacheGet. */ async function getAccuracy(opts = {}) { const cacheGet = opts.cacheGet || require('../utils/redis').cacheGet; const overall = await cacheGet('accuracy:overall'); const sports = {}; for (const sp of SPORTS) { const a = await cacheGet(`accuracy:${sp}`); if (a) sports[sp] = a; } return { overall: overall || computeAccuracy('overall', [], new Date().toISOString()), sports, min_sample: MIN_SAMPLE, updated_at: overall && overall.updated_at ? overall.updated_at : null, }; } /** Flatten an accuracy record into the ledger `buckets` shape. */ function accuracyBuckets(acc) { if (!acc || !acc.byGrade) return []; return TIERS .map((tier) => { const b = acc.byGrade[tier] || {}; return { grade: tier, hits: b.hits || 0, total: b.total || 0, pct: b.pct }; }) .filter((b) => b.total > 0); } module.exports = { settleSnapshot, settleAllOutcomes, recomputeOverall, getAccuracy, computeAccuracy, accuracyBuckets, // Session 58 — settlement primitives shared with ledgerService (single // source of truth for hit/miss/push + log-field resolution). Wave 1 — // logRowOnDate normalizes the ESPN ISO game-log date for NBA/WNBA. settleResult, statValue, logRowOnDate, SPORTS, MIN_SAMPLE, __internals: { settleResult, gradeBucket, dateStrings, etDate, statValue, nbaStatValue, logRowOnDate, outcomeKey, MLB_LOG_FIELD, NBA_BOX_KEY, NBA_COMBO, TIERS, }, };