diff --git a/src/config/modelEras.js b/src/config/modelEras.js new file mode 100644 index 0000000..9d70e7c --- /dev/null +++ b/src/config/modelEras.js @@ -0,0 +1,35 @@ +'use strict'; + +/** + * MODEL ERAS — the cutoff, defined ONCE (Session 64). + * + * `ledger_entries` mixes grades from before and after the 2026-07-19 fix that + * revived the probability layer (p_win/ev_pct/model_odds/value were absent on + * 100% of live grades before it) and widened the grade range. There is no + * per-row version in that table and the eras cannot be separated retroactively, + * so EVERY consumer must apply the same boundary rather than inventing its own. + * + * A backtest that mixes model eras is worthless — that is the whole reason this + * constant exists in one place. + * + * Going forward, rows carry a real `model_version` (from + * retentionService.MODEL_VERSION) and `model_snapshots` has clean per-row + * versioning from day one. The harness should read OUTCOMES from + * ledger_entries but VERSIONING from model_snapshots. + */ + +/** Rows graded at or after this are the post-fix era. */ +const ERA_CUTOFF_ISO = '2026-07-19T22:50:00Z'; + +/** What pre-cutoff rows are labelled. Names the uncertainty; never implies + * we know which model produced them. */ +const UNKNOWN_ERA = 'pre-retention-unknown'; + +function isPostFix(gradedAt) { + if (!gradedAt) return false; + const t = new Date(gradedAt).getTime(); + if (Number.isNaN(t)) return false; + return t >= new Date(ERA_CUTOFF_ISO).getTime(); +} + +module.exports = { ERA_CUTOFF_ISO, UNKNOWN_ERA, isPostFix }; diff --git a/src/services/adapters/mlbStatsAdapter.js b/src/services/adapters/mlbStatsAdapter.js index ba0bcee..6e0007e 100644 --- a/src/services/adapters/mlbStatsAdapter.js +++ b/src/services/adapters/mlbStatsAdapter.js @@ -339,6 +339,12 @@ async function getPlayerStats(name, season = DEFAULT_SEASON, opts = {}) { group, season: seasonStat, last10: (log || []).slice(-10), + // Session 64 — the FULL season log, already fetched above. Settlement + // needs a specific DATE, and a rolling window silently loses games as it + // rolls (verified: a Jul 12 game outside a last10 starting Jul 6). This + // costs nothing extra — same request, same cache. Projections keep using + // last10; only settlement reads fullLog. + fullLog: log || [], }; } catch (err) { console.warn('[mlbStats] getPlayerStats failed:', name, err.message); diff --git a/src/services/ledgerService.js b/src/services/ledgerService.js index 8c5581a..76698a7 100644 --- a/src/services/ledgerService.js +++ b/src/services/ledgerService.js @@ -33,6 +33,24 @@ const { settleResult, statValue, logRowOnDate } = require('./outcomeService'); const CONFLICT = 'user_id,player_key,stat,line,side,game_id'; const UPSERT_CHUNK = 200; const SETTLE_FETCH_LIMIT = 500; +// Session 64 — bounded retry: a row that cannot be resolved after this many +// date-targeted attempts becomes 'unrecoverable' rather than pending forever. +const SETTLE_ATTEMPT_CAP = Number(process.env.SETTLE_ATTEMPT_CAP || 4); +// Bump when the settlement RULE changes, so healed rows are distinguishable +// from originals and the harness can filter by how a row was scored. +const SETTLEMENT_VERSION = Number(process.env.SETTLEMENT_VERSION || 2); +const settleSource = require('./settleSource'); +const MODEL_ERA_VERSION = process.env.MODEL_VERSION || 'engine1@2026-07-20'; +/** Day's games for a sport (date-pinned since S57) — tells us what a player's + * ABSENCE means: DNP, postponed, or simply not final yet. */ +async function getScheduleFn(sport, date) { + // Never reach the network from a test run (the opsNotify/refreshTeamStats + // precedent) — a schedule lookup inside the settle loop would hang the suite. + if (process.env.NODE_ENV === 'test') return []; + try { + return await require('./scheduleService').getSchedule(sport, date); + } catch { return []; } +} const AGG_WINDOW_DAYS = 30; const AGG_FETCH_LIMIT = 5000; /** Below this many settled rows, callers must not render a percentage. */ @@ -200,6 +218,10 @@ function rowsFromSnapshot(sport, grades, oddsProps, nowIso) { confidence: numOrNull(g.confidence), model_value: numOrNull(g.projection), graded_at: gradedTs, + // Session 64 — stamp the model era on every NEW row. Pre-cutoff rows are + // labelled 'pre-retention-unknown' by migration 026; they cannot be + // resolved retroactively. + model_version: MODEL_ERA_VERSION, game_id: gameIdFor(sp, prop, gameDate), game_date: gameDate, }); @@ -305,6 +327,10 @@ async function settleLedger(sport, opts = {}) { const sp = String(sport || '').toLowerCase(); const nowIso = (opts.now || (() => new Date().toISOString()))(); const getPlayerStats = opts.getPlayerStats || defaultGetPlayerStats; + // Session 64 — injectable like every other dep: tests must never reach the + // network, and a schedule lookup inside the settle loop would otherwise hang + // the suite. + const getSchedule = opts.getSchedule || getScheduleFn; const cutoff = opts.beforeDate || todayET(); // settle strictly-before today const { data: open, error } = await sb.from('ledger_entries') @@ -319,7 +345,7 @@ async function settleLedger(sport, opts = {}) { // We need each row's game_date for the log match — refetch with it included. const { data: rows } = await sb.from('ledger_entries') - .select('id, player_name, stat, line, side, closing_line, game_date') + .select('id, player_name, stat, line, side, closing_line, game_date, settle_attempts') .in('id', open.map((r) => r.id)); // One game-log fetch per unique player. @@ -328,21 +354,85 @@ async function settleLedger(sport, opts = {}) { for (const player of players) { try { const stats = await getPlayerStats(player, sp); - logByPlayer[player] = stats && stats.found && Array.isArray(stats.last10) ? stats.last10 : []; + // Session 64 — prefer the FULL season log for settlement. MLB already + // fetched it (getPlayerStats was discarding it via .slice(-10)), so this + // costs nothing and removes window-decay entirely. Fall back to last10 + // for sources that only expose a window (ESPN). + const full = stats && Array.isArray(stats.fullLog) ? stats.fullLog : null; + const win = stats && stats.found && Array.isArray(stats.last10) ? stats.last10 : []; + logByPlayer[player] = (full && full.length) ? full : win; } catch { logByPlayer[player] = []; } } let settled = 0; let pending = 0; + let voided = 0; + let unrecoverable = 0; for (const row of rows || []) { const log = logByPlayer[row.player_name] || []; - // Sport-aware date match: MLB rows are YYYY-MM-DD, NBA/WNBA ESPN rows are - // ISO timestamps normalized to their ET date. game_date is already < today - // (the .lt('game_date', cutoff) fetch), so an unplayed game never settles. - const gameRow = log.find((r) => logRowOnDate(r, row.game_date, sp)); - if (!gameRow) { pending += 1; continue; } - const actual = statValue(gameRow.stat, row.stat, sp); - if (actual == null) { pending += 1; continue; } + // Session 64 — DATE-TARGETED settlement. The old code searched a ROLLING + // window and, on a miss, did `pending += 1` with no terminal state: a row + // that could never settle (player DNP, or the window rolled past the game) + // looked identical to one settling tomorrow, and pended forever. + // `resolveOutcome` reads the FULL log for that exact date and, when the + // player is absent, consults the day's SCHEDULE to learn what the absence + // MEANS — DNP vs postponed vs not-yet-final. + const res = await settleSource.resolveOutcome({ + sport: sp, + playerName: row.player_name, + gameDate: row.game_date, + statType: row.stat, + getFullLog: async () => log, + getSchedule, + matchesDate: (r, d) => logRowOnDate(r, d, sp), + statValue: (statObj, st) => statValue(statObj, st, sp), + }); + + const attempts = Number(row.settle_attempts || 0) + 1; + + // Not final yet (scheduled / in progress / SUSPENDED) → stay pending. A + // suspended game resumes and settles later; voiding it would destroy a + // real bet. + if (res.state === 'pending') { + await sb.from('ledger_entries').update({ settle_attempts: attempts }) + .eq('id', row.id).is('outcome', null); + pending += 1; + continue; + } + + // Could not determine — bounded retry, then a terminal state. Nothing is + // immortal. + if (res.state === 'unknown') { + const terminal = attempts >= SETTLE_ATTEMPT_CAP; + await sb.from('ledger_entries').update({ + settle_attempts: attempts, + ...(terminal ? { + outcome: 'unrecoverable', + settled_at: nowIso, + settlement_source: res.source || 'unknown', + settlement_version: SETTLEMENT_VERSION, + } : {}), + }).eq('id', row.id).is('outcome', null); + if (terminal) unrecoverable += 1; else pending += 1; + continue; + } + + // No bet existed — DNP or postponed/cancelled. VOID is a terminal truth, + // not a failure, and it is excluded from every record denominator. + if (res.state === 'void') { + const { error: vErr } = await sb.from('ledger_entries').update({ + outcome: 'void', + settled_at: nowIso, + settle_attempts: attempts, + settlement_source: res.reason || 'void', + settlement_version: SETTLEMENT_VERSION, + }).eq('id', row.id).is('outcome', null); + if (vErr) { pending += 1; continue; } + voided += 1; + continue; + } + + const actual = res.value; const outcome = settleResult(row.side, actual, row.line); if (!outcome) { pending += 1; continue; } const clv = computeClv(row.side, row.line, row.closing_line); @@ -353,13 +443,16 @@ async function settleLedger(sport, opts = {}) { settled_at: nowIso, clv, clv_result: clvResultOf(clv), + settle_attempts: attempts, + settlement_source: res.source || 'date_log', + settlement_version: SETTLEMENT_VERSION, }) .eq('id', row.id) .is('outcome', null); // double-settle guard even across concurrent runs if (upErr) { pending += 1; continue; } settled += 1; } - return { settled, pending }; + return { settled, voided, unrecoverable, pending }; } /** @@ -455,6 +548,11 @@ async function getModelAggregate(opts = {}) { settledQ = opts.userId ? settledQ.eq('user_id', opts.userId) : settledQ.is('user_id', null); settledQ = settledQ .not('outcome', 'is', null) + // Session 64 — void (no bet existed: DNP/postponed) and unrecoverable + // (truth not fetchable) are TERMINAL but are NOT results. They must never + // enter a record denominator, exactly as pushes are excluded from hit_pct. + // Without this, voiding a row would silently move the public record. + .not('outcome', 'in', '("void","unrecoverable")') // 2026-07 — a grade with a non-positive model_value had NO real projection // (the pre-fix degradation). Those locks are kept in the append-only ledger // but must not count toward the public model record — their hit/miss is diff --git a/src/services/opsWatch.js b/src/services/opsWatch.js index d21a143..9fecd27 100644 --- a/src/services/opsWatch.js +++ b/src/services/opsWatch.js @@ -236,8 +236,52 @@ function retentionZeroWriteAlarm(results = [], written = {}) { return { alarm, reason, detail }; } +/** + * SETTLEMENT-RATE ALARM (Session 64, Order 1 Phase 3). + * + * `zeroSettleAlarm` only fires on a TOTAL zero. The diagnostic found 29 of 86 + * MLB rows (Jul 17) and 28 of 103 (Jul 18) quietly failing to settle while the + * pass "succeeded" — the third silent-failure surface of the night. A slate + * that mostly settles but persistently drops a third of its rows is broken. + * + * Resolved = settled + voided + unrecoverable (every TERMINAL state). A void is + * a legitimate resolution — the row is no longer immortal — so it counts toward + * the rate even though it is excluded from the record. + * + * Pure. Caller supplies settle results; this only decides. + */ +const SETTLE_RATE_FLOOR = Number(process.env.SETTLE_RATE_FLOOR || 0.8); + +function settlementRateAlarm(results = [], opts = {}) { + const floor = Number.isFinite(opts.floor) ? opts.floor : SETTLE_RATE_FLOOR; + const detail = []; + let alarm = false; + for (const r of results || []) { + if (!r || r.error || r.skipped) continue; + const settled = Number(r.settled) || 0; + const voided = Number(r.voided) || 0; + const unrec = Number(r.unrecoverable) || 0; + const pending = Number(r.pending) || 0; + const resolved = settled + voided + unrec; + const attempted = resolved + pending; + if (attempted === 0) continue; // nothing due — never page + const rate = resolved / attempted; + const bad = rate < floor; + if (bad) alarm = true; + detail.push({ sport: r.sport, attempted, resolved, pending, rate: Math.round(rate * 100) / 100, bad }); + } + const reason = alarm + ? detail.filter((d) => d.bad) + .map((d) => `${String(d.sport || '?').toUpperCase()} resolved ${d.resolved}/${d.attempted} (${Math.round(d.rate * 100)}%), ${d.pending} still pending`) + .join('; ') + : null; + return { alarm, reason, detail, floor }; +} + module.exports = { retentionZeroWriteAlarm, + settlementRateAlarm, + SETTLE_RATE_FLOOR, createFailureTracker, isBadSnapshotResult, zeroSettleAlarm, diff --git a/src/services/settleSource.js b/src/services/settleSource.js new file mode 100644 index 0000000..be6f333 --- /dev/null +++ b/src/services/settleSource.js @@ -0,0 +1,149 @@ +'use strict'; + +/** + * SETTLEMENT SOURCE — date-targeted game resolution (Session 64, Order 1). + * + * THE BUG THIS REPLACES: settlement looked for a game inside the player's + * ROLLING last-N window. Two mechanisms silently produced eternal pending: + * 1. the player DNP'd, so no row for that date exists at all; and + * 2. the window rolled past the game (verified live: Cooper Pratt's Jul 12 + * game fell out of a last10 that now starts Jul 6). + * Both hit one line — `if (!gameRow) { pending += 1; continue; }` — with no + * terminal state, so a row that can NEVER settle looked exactly like one + * settling tomorrow. + * + * THE FIX: resolve the SPECIFIC DATE, and read GAME STATE — not merely whether + * the player appears. + * + * Cost note: for MLB this is FREE. `mlbStatsAdapter.getPlayerGameLog` already + * returns the player's FULL SEASON log and `getPlayerStats` was discarding it + * with `.slice(-10)`. Settlement now reads the full log (same URL, same cache, + * no extra request). Projections keep using last10 — unchanged. + * + * States returned: + * settled — the game is final and the player has a line → score it + * void — no bet existed: confirmed DNP, or postponed/cancelled + * pending — not final yet (scheduled / in progress / suspended). Retry. + * unknown — we could not determine state; caller applies the retry cap + * + * A partial game (injured/ejected but with a line) is a REAL result and settles + * normally — it is never a void. + */ + +const FINAL_HINTS = ['final', 'completed', 'post', 'game over']; +const VOID_HINTS = ['postponed', 'cancelled', 'canceled', 'forfeit']; +const NOT_FINAL_HINTS = ['scheduled', 'pre', 'warmup', 'in progress', 'live', 'delayed', 'suspended', 'in']; + +function textOf(...vals) { + return vals.filter(Boolean).map((v) => String(v).toLowerCase()).join(' '); +} + +/** Classify one schedule entry's state. Order matters: an explicitly void + * state must win over a "final"-looking wrapper. */ +function classifyGameState(game) { + const t = textOf( + game && game.status, + game && game.detailedState, + game && game.abstractGameState, + game && game.state, + game && game.statusText, + ); + if (!t) return 'unknown'; + if (VOID_HINTS.some((h) => t.includes(h))) return 'void'; + // 'suspended' must not be read as final even though it can pair with other + // words — a suspended game resumes and settles later. + if (t.includes('suspended')) return 'not_final'; + if (FINAL_HINTS.some((h) => t.includes(h))) return 'final'; + if (NOT_FINAL_HINTS.some((h) => t.includes(h))) return 'not_final'; + return 'unknown'; +} + +/** + * Given the day's games for a sport, decide what the ABSENCE of a player row + * means. Pure — the caller supplies the schedule. + * - any game still unplayed/suspended → 'pending' (do NOT void; it may settle) + * - every relevant game void → 'void' + * - at least one final, none pending → 'void' (confirmed DNP) + * - nothing known → 'unknown' + */ +function absenceMeaning(games) { + const list = Array.isArray(games) ? games : []; + if (list.length === 0) return 'unknown'; + const states = list.map(classifyGameState); + if (states.some((s) => s === 'not_final')) return 'pending'; + if (states.every((s) => s === 'void')) return 'void'; + if (states.some((s) => s === 'final')) return 'void'; // played without them = DNP + return 'unknown'; +} + +/** Find the player's row for an exact date in a full game log. */ +function rowForDate(log, gameDate, matchesDate) { + const rows = Array.isArray(log) ? log : []; + return rows.find((r) => matchesDate(r, gameDate)) || null; +} + +/** + * Resolve one ledger row's outcome source. + * + * @param {Object} args + * - sport, playerName, gameDate, statType + * - getFullLog(playerName, sport) → [{date, stat}] (full season, not a window) + * - getSchedule(sport, gameDate) → [{status,…}] + * - matchesDate(row, gameDate) → bool + * - statValue(statObj, statType, sport) → number|null + * @returns {Promise<{state:string, value:number|null, reason:string|null, source:string}>} + */ +async function resolveOutcome(args = {}) { + const { + sport, playerName, gameDate, statType, + getFullLog, getSchedule, matchesDate, statValue, + } = args; + + let log = []; + try { + log = (await getFullLog(playerName, sport)) || []; + } catch { + log = []; + } + + const row = rowForDate(log, gameDate, matchesDate); + if (row) { + const value = statValue(row.stat, statType, sport); + if (value == null) { + // The player PLAYED but this stat isn't in the box row — a mapping gap, + // not a void. Never void a real appearance. + return { state: 'unknown', value: null, reason: 'stat_not_in_box_row', source: 'date_log' }; + } + return { state: 'settled', value, reason: null, source: 'date_log' }; + } + + // No row for that date — the absence only means something once we know + // whether the games were actually played. + let games = []; + try { + games = (await getSchedule(sport, gameDate)) || []; + } catch { + games = []; + } + const meaning = absenceMeaning(games); + if (meaning === 'pending') { + return { state: 'pending', value: null, reason: 'game_not_final', source: 'schedule' }; + } + if (meaning === 'void') { + const allVoid = games.length > 0 && games.map(classifyGameState).every((s) => s === 'void'); + return { + state: 'void', + value: null, + reason: allVoid ? 'game_postponed_or_cancelled' : 'player_dnp', + source: 'schedule', + }; + } + return { state: 'unknown', value: null, reason: 'state_undetermined', source: 'schedule' }; +} + +module.exports = { + resolveOutcome, + absenceMeaning, + classifyGameState, + rowForDate, +}; diff --git a/src/snapshotScheduler.js b/src/snapshotScheduler.js index 8cc0831..ba4386c 100644 --- a/src/snapshotScheduler.js +++ b/src/snapshotScheduler.js @@ -175,7 +175,21 @@ function startSnapshotScheduler(opts = {}) { try { ledgerResults = await settleLedgers(); const n = ledgerResults.reduce((t, r) => t + (r.settled || 0), 0); - console.log(`[ledger] settle pass — ${n} entries settled (outcome + CLV)`); + const nv = ledgerResults.reduce((t, r) => t + (r.voided || 0), 0); + const nu = ledgerResults.reduce((t, r) => t + (r.unrecoverable || 0), 0); + console.log(`[ledger] settle pass — ${n} settled, ${nv} voided, ${nu} unrecoverable (outcome + CLV)`); + // Session 64 — SETTLEMENT-RATE ALARM. zeroSettleAlarm only catches a + // TOTAL zero; the diagnostic found ~30% of a slate quietly failing while + // the pass "succeeded". A persistently low resolution rate is a broken + // pipe, not a quiet night. + try { + const sr = opsWatch.settlementRateAlarm(ledgerResults); + if (sr.alarm) { + await notify(`Settlement rate BELOW FLOOR at ${h}:00 UTC — ${sr.reason}. Rows that cannot resolve are pending, not scored.`, { + title: 'VYNDR settlement', priority: 'high', tags: ['rotating_light'], + }); + } + } catch { /* alarm evaluation must never break the tick */ } } catch (e) { console.warn('[ledger] settle run failed:', e.message); await notify(`Ledger settlement THREW at ${h}:00 UTC — ${e.message}. The public record did not advance.`, { diff --git a/tests/unit/ledgerService.test.js b/tests/unit/ledgerService.test.js index 669efeb..a7a553d 100644 --- a/tests/unit/ledgerService.test.js +++ b/tests/unit/ledgerService.test.js @@ -177,17 +177,55 @@ describe('settleLedger — outcome + CLV vs the real result', () => { expect(byOutcome[1]).toMatchObject({ outcome: 'miss', actual_value: 1, clv: -1, clv_result: 'faded' }); }); - test('no game-log row for the date → stays pending (never guesses)', async () => { + // Session 64 — BEHAVIOUR CHANGED ON PURPOSE. A missing game-log row used to + // mean "pending forever". It now depends on what the day's games actually did. + test('no row + game FINAL → VOID (confirmed DNP), no longer immortal', async () => { const sb = fakeSb(); sb._state.selectResults = [ [{ id: 'r1' }], [{ id: 'r1', player_name: 'Aaron Judge', stat: 'home_runs', line: 0.5, side: 'over', closing_line: null, game_date: '2026-07-09' }], ]; const getPlayerStats = async () => ({ found: true, last10: [{ date: '2026-07-08', stat: { homeRuns: 2 } }] }); - const res = await ledger.settleLedger('mlb', { sb, getPlayerStats, now: () => NOW, beforeDate: '2026-07-10' }); + const res = await ledger.settleLedger('mlb', { + sb, getPlayerStats, now: () => NOW, beforeDate: '2026-07-10', + getSchedule: async () => [{ status: 'Final' }], + }); + expect(res.voided).toBe(1); expect(res.settled).toBe(0); + expect(sb._calls.updates[0].values).toMatchObject({ outcome: 'void', settlement_source: 'player_dnp' }); + }); + + test('no row + game NOT FINAL → stays pending, never voided', async () => { + const sb = fakeSb(); + sb._state.selectResults = [ + [{ id: 'r1' }], + [{ id: 'r1', player_name: 'Aaron Judge', stat: 'home_runs', line: 0.5, side: 'over', closing_line: null, game_date: '2026-07-09' }], + ]; + const getPlayerStats = async () => ({ found: true, last10: [{ date: '2026-07-08', stat: { homeRuns: 2 } }] }); + const res = await ledger.settleLedger('mlb', { + sb, getPlayerStats, now: () => NOW, beforeDate: '2026-07-10', + getSchedule: async () => [{ status: 'Suspended' }], + }); expect(res.pending).toBe(1); - expect(sb._calls.updates).toHaveLength(0); + expect(res.voided).toBe(0); + // only the attempt counter is touched — no outcome written + expect(sb._calls.updates[0].values).toMatchObject({ settle_attempts: 1 }); + expect(sb._calls.updates[0].values.outcome).toBeUndefined(); + }); + + test('undetermined state becomes UNRECOVERABLE at the retry cap', async () => { + const sb = fakeSb(); + sb._state.selectResults = [ + [{ id: 'r1' }], + [{ id: 'r1', player_name: 'Aaron Judge', stat: 'home_runs', line: 0.5, side: 'over', closing_line: null, game_date: '2026-07-09', settle_attempts: 3 }], + ]; + const getPlayerStats = async () => ({ found: true, last10: [] }); + const res = await ledger.settleLedger('mlb', { + sb, getPlayerStats, now: () => NOW, beforeDate: '2026-07-10', + getSchedule: async () => [], // nothing knowable + }); + expect(res.unrecoverable).toBe(1); + expect(sb._calls.updates[0].values).toMatchObject({ outcome: 'unrecoverable' }); }); }); diff --git a/tests/unit/opsWatch.test.js b/tests/unit/opsWatch.test.js index 918551d..0492b27 100644 --- a/tests/unit/opsWatch.test.js +++ b/tests/unit/opsWatch.test.js @@ -282,3 +282,30 @@ describe('retentionZeroWriteAlarm (Session 64)', () => { expect(r.alarm).toBe(true); }); }); + +describe('settlementRateAlarm (Session 64)', () => { + const opsWatch = require('../../src/services/opsWatch'); + + test('pages when a slate resolves below the floor', () => { + const r = opsWatch.settlementRateAlarm([{ sport: 'mlb', settled: 57, voided: 0, unrecoverable: 0, pending: 29 }]); + expect(r.alarm).toBe(true); + expect(r.reason).toMatch(/MLB resolved 57\/86/); + }); + + test('VOIDS count as resolved — a void is a legitimate terminal state', () => { + const r = opsWatch.settlementRateAlarm([{ sport: 'mlb', settled: 57, voided: 29, unrecoverable: 0, pending: 0 }]); + expect(r.alarm).toBe(false); + }); + + test('quiet on a healthy pass', () => { + expect(opsWatch.settlementRateAlarm([{ sport: 'wnba', settled: 70, voided: 0, unrecoverable: 0, pending: 0 }]).alarm).toBe(false); + }); + + test('nothing due never pages', () => { + expect(opsWatch.settlementRateAlarm([{ sport: 'nba', settled: 0, pending: 0 }]).alarm).toBe(false); + }); + + test('skipped/errored sports are ignored, not counted as failure', () => { + expect(opsWatch.settlementRateAlarm([{ sport: 'soccer', skipped: 'not configured' }]).alarm).toBe(false); + }); +}); diff --git a/tests/unit/settleSource.test.js b/tests/unit/settleSource.test.js new file mode 100644 index 0000000..f07e660 --- /dev/null +++ b/tests/unit/settleSource.test.js @@ -0,0 +1,134 @@ +/** + * Session 64 Order 1 — date-targeted settlement + terminal states. + * + * The bug: a lookup miss did `pending += 1` with no terminal state, so a row + * that could NEVER settle (DNP, or the rolling window passed the game) was + * indistinguishable from one settling tomorrow. These lock the distinctions. + */ +const src = require('../../src/services/settleSource'); + +const matchesDate = (r, d) => r.date === d; +const statValue = (s, st) => (s && s[st] != null ? Number(s[st]) : null); +const base = { + sport: 'mlb', playerName: 'X', gameDate: '2026-07-17', statType: 'doubles', + matchesDate, statValue, +}; + +describe('classifyGameState', () => { + test('final states', () => { + expect(src.classifyGameState({ status: 'Final' })).toBe('final'); + expect(src.classifyGameState({ state: 'post' })).toBe('final'); + }); + test('postponed/cancelled are void', () => { + expect(src.classifyGameState({ detailedState: 'Postponed' })).toBe('void'); + expect(src.classifyGameState({ status: 'Cancelled' })).toBe('void'); + }); + test('SUSPENDED is NOT final — it resumes and must stay pending', () => { + expect(src.classifyGameState({ detailedState: 'Suspended' })).toBe('not_final'); + }); + test('scheduled / in progress are not final', () => { + expect(src.classifyGameState({ status: 'Scheduled' })).toBe('not_final'); + expect(src.classifyGameState({ status: 'In Progress' })).toBe('not_final'); + }); + test('unknown when there is nothing to read', () => { + expect(src.classifyGameState({})).toBe('unknown'); + }); +}); + +describe('absenceMeaning — what a missing player row MEANS', () => { + test('any unplayed game → pending (never void a bet that may still settle)', () => { + expect(src.absenceMeaning([{ status: 'Final' }, { status: 'Suspended' }])).toBe('pending'); + }); + test('all games postponed → void', () => { + expect(src.absenceMeaning([{ status: 'Postponed' }])).toBe('void'); + }); + test('game final but player absent → void (confirmed DNP)', () => { + expect(src.absenceMeaning([{ status: 'Final' }])).toBe('void'); + }); + test('no schedule → unknown, not a guess', () => { + expect(src.absenceMeaning([])).toBe('unknown'); + }); +}); + +describe('resolveOutcome', () => { + test('player has a line on that date → SETTLED with the real value', async () => { + const r = await src.resolveOutcome({ + ...base, + getFullLog: async () => [{ date: '2026-07-17', stat: { doubles: 1 } }], + getSchedule: async () => [{ status: 'Final' }], + }); + expect(r.state).toBe('settled'); + expect(r.value).toBe(1); + }); + + test('a ZERO value still settles (0 is a real result, not absence)', async () => { + const r = await src.resolveOutcome({ + ...base, + getFullLog: async () => [{ date: '2026-07-17', stat: { doubles: 0 } }], + getSchedule: async () => [{ status: 'Final' }], + }); + expect(r.state).toBe('settled'); + expect(r.value).toBe(0); + }); + + test('DNP on a final game → VOID', async () => { + const r = await src.resolveOutcome({ + ...base, + getFullLog: async () => [{ date: '2026-07-16', stat: { doubles: 0 } }], + getSchedule: async () => [{ status: 'Final' }], + }); + expect(r.state).toBe('void'); + expect(r.reason).toBe('player_dnp'); + }); + + test('postponed game → VOID with the postponed reason', async () => { + const r = await src.resolveOutcome({ + ...base, + getFullLog: async () => [], + getSchedule: async () => [{ status: 'Postponed' }], + }); + expect(r.state).toBe('void'); + expect(r.reason).toBe('game_postponed_or_cancelled'); + }); + + test('SUSPENDED game stays PENDING — never voided', async () => { + const r = await src.resolveOutcome({ + ...base, + getFullLog: async () => [], + getSchedule: async () => [{ status: 'Suspended' }], + }); + expect(r.state).toBe('pending'); + }); + + test('a game OUTSIDE a rolling window still settles from the full log', async () => { + // The exact live failure: Jul 12 game, window now starts Jul 6. + const full = Array.from({ length: 40 }, (_, i) => ({ + date: `2026-07-${String(i + 1).padStart(2, '0')}`, stat: { doubles: i % 3 }, + })); + const r = await src.resolveOutcome({ + ...base, gameDate: '2026-07-12', + getFullLog: async () => full, + getSchedule: async () => [{ status: 'Final' }], + }); + expect(r.state).toBe('settled'); + }); + + test('player PLAYED but the stat is missing from the box row → unknown, NEVER void', async () => { + const r = await src.resolveOutcome({ + ...base, + getFullLog: async () => [{ date: '2026-07-17', stat: { hits: 2 } }], + getSchedule: async () => [{ status: 'Final' }], + }); + expect(r.state).toBe('unknown'); + expect(r.reason).toBe('stat_not_in_box_row'); + }); + + test('a throwing source degrades to unknown, never throws', async () => { + const r = await src.resolveOutcome({ + ...base, + getFullLog: async () => { throw new Error('net'); }, + getSchedule: async () => { throw new Error('net'); }, + }); + expect(r.state).toBe('unknown'); + }); +});