diff --git a/src/services/gameBinder.js b/src/services/gameBinder.js new file mode 100644 index 0000000..78dd3e3 --- /dev/null +++ b/src/services/gameBinder.js @@ -0,0 +1,150 @@ +'use strict'; + +/** + * GAME BINDER (Session 64, Order 1.5) — bind a prop to its REAL game. + * + * THE ROOT BUG THIS FIXES. `ledgerService` dated a row with + * `dateET(prop.game_time) || dateET(gradedTs)`. Our PRIMARY feed (PropLine) + * emits no `commence_time` at all, so the first term was always null and every + * row was dated by the GRADE timestamp. A 01:00/03:00 UTC snapshot is + * 21:00/23:00 ET the PREVIOUS day, so props for tonight's games were filed + * under yesterday. Settlement then correctly found no game on that date, and + * Order 1's void logic turned the mislabel into 64 destroyed results + * (Freeman/Bellinger/Tucker "DNP" on days they played). + * + * THE FIX: match each prop to a scheduled game by TEAMS across the plausible + * date window and take the GAME'S OWN date. A grade timestamp is never again + * allowed to name a game date. + * + * CONTRACT: if a prop cannot be bound to a real game, it returns NOTHING. The + * caller must treat that as UNRESOLVED — never fall back to a derived date. A + * mis-dated row is fabricated data, and the ledger's standing rule is that it + * holds real market values or nothing. + * + * DOUBLEHEADERS are reported, not guessed. When two games share the same teams + * on one date, a prop with no game id cannot be attributed to one of them; the + * binding is marked `ambiguous` so settlement can decline rather than pick. + */ + +const norm = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]/g, ''); + +/** Does a scheduled game involve both of the prop's teams? */ +function gameMatchesTeams(game, homeTeam, awayTeam) { + const h = norm(homeTeam); + const a = norm(awayTeam); + if (!h && !a) return false; + const gh = norm(game && (game.homeTeam?.abbreviation || game.homeTeam?.name || game.home)); + const ga = norm(game && (game.awayTeam?.abbreviation || game.awayTeam?.name || game.away)); + if (!gh && !ga) return false; + const hit = (x, y) => !!x && !!y && (x === y || x.includes(y) || y.includes(x)); + // Accept either orientation — feeds disagree about which side is "home". + return (hit(gh, h) && hit(ga, a)) || (hit(gh, a) && hit(ga, h)); +} + +/** ET calendar date of an ISO timestamp. */ +function etDate(iso) { + if (!iso) return null; + const t = new Date(iso); + if (Number.isNaN(t.getTime())) return null; + return new Intl.DateTimeFormat('en-CA', { + timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit', + }).format(t); +} + +function addDays(dateStr, n) { + const d = new Date(`${dateStr}T12:00:00Z`); + if (Number.isNaN(d.getTime())) return null; + d.setUTCDate(d.getUTCDate() + n); + return d.toISOString().slice(0, 10); +} + +/** + * Candidate ET dates for a prop graded at `gradedAt`. + * + * A late-UTC snapshot (01:00/03:00 UTC = 21:00/23:00 ET) is grading TONIGHT'S + * or TOMORROW'S slate while the ET clock still reads yesterday, so the real + * game is the grade's ET date or the day after. We also look one day BACK to + * cover an early-UTC slot grading a game already in progress. + */ +function candidateDates(gradedAt) { + const base = etDate(gradedAt); + if (!base) return []; + return [base, addDays(base, 1), addDays(base, -1)].filter(Boolean); +} + +/** + * Bind ONE prop to a real scheduled game. + * @returns {Promise<{game_time, game_date, game_id, ambiguous}|null>} null = unresolved + */ +async function bindGame({ sport, prop, gradedAt, getSchedule }) { + if (!prop) return null; + // Already carries a real game time (odds-api path) — trust the feed. + if (prop.game_time && etDate(prop.game_time)) { + return { + game_time: prop.game_time, + game_date: etDate(prop.game_time), + game_id: prop.game_id || null, + ambiguous: false, + }; + } + for (const date of candidateDates(gradedAt)) { + let games = []; + try { games = (await getSchedule(sport, date)) || []; } catch { games = []; } + const matches = games.filter((g) => gameMatchesTeams(g, prop.home_team, prop.away_team)); + if (matches.length === 0) continue; + const g = matches[0]; + const gt = g.gameTime || g.date || null; + const gd = etDate(gt) || date; + return { + game_time: gt, + game_date: gd, + game_id: g.id ? `${sport}:${gd}:${g.id}` : null, + // Two games, same teams, same day: a prop with no game id cannot be + // attributed to one. Report it; never guess. + ambiguous: matches.length > 1, + }; + } + return null; // UNRESOLVED — caller must not invent a date +} + +/** + * Attach real game times to a slate's props. Returns counts so a silent + * binding collapse is visible instead of quietly re-introducing bad dates. + */ +async function attachGameTimes(sport, props, deps = {}) { + const getSchedule = deps.getSchedule + || ((sp, d) => require('./scheduleService').getSchedule(sp, d)); + const gradedAt = deps.gradedAt || new Date().toISOString(); + const list = Array.isArray(props) ? props : []; + let bound = 0; + let alreadyHad = 0; + let unresolved = 0; + let ambiguous = 0; + + // One schedule fetch per candidate date, reused across every prop. + const cache = new Map(); + const cachedSchedule = async (sp, d) => { + const k = `${sp}:${d}`; + if (!cache.has(k)) { + try { cache.set(k, (await getSchedule(sp, d)) || []); } catch { cache.set(k, []); } + } + return cache.get(k); + }; + + for (const p of list) { + if (!p) continue; + if (p.game_time && etDate(p.game_time)) { alreadyHad += 1; continue; } + const b = await bindGame({ sport, prop: p, gradedAt, getSchedule: cachedSchedule }); + if (!b) { unresolved += 1; continue; } + p.game_time = b.game_time; + p.game_date = b.game_date; + if (b.game_id) p.bound_game_id = b.game_id; + if (b.ambiguous) { p.game_ambiguous = true; ambiguous += 1; } + bound += 1; + } + return { total: list.length, bound, alreadyHad, unresolved, ambiguous }; +} + +module.exports = { + bindGame, attachGameTimes, candidateDates, gameMatchesTeams, etDate, addDays, +}; diff --git a/src/services/ledgerService.js b/src/services/ledgerService.js index 76698a7..f1b1512 100644 --- a/src/services/ledgerService.js +++ b/src/services/ledgerService.js @@ -185,6 +185,7 @@ function oddsForSide(prop, side) { */ function rowsFromSnapshot(sport, grades, oddsProps, nowIso) { const sp = String(sport || '').toLowerCase(); + let skippedUnbound = 0; const byKey = indexProps(oddsProps); const rows = []; for (const g of grades || []) { @@ -199,7 +200,14 @@ function rowsFromSnapshot(sport, grades, oddsProps, nowIso) { if (line == null) continue; // no real captured line → no row const prop = byKey[`${nameKey(player)}|${stat}`] || null; const gradedTs = locked.timestamp || nowIso; - const gameDate = dateET(prop && prop.game_time) || dateET(gradedTs) || todayET(); + // Session 64 (Order 1.5) — a game date comes from the GAME, never from the + // grade clock. The old `|| dateET(gradedTs) || todayET()` fallback is what + // filed tonight's props under yesterday and made settlement impossible. + // gameBinder attaches game_time upstream; if a prop still has none, the + // row is UNRESOLVED and is skipped — the ledger holds real values or + // nothing, and a mis-dated row is fabricated data. + const gameDate = dateET(prop && prop.game_time) || (prop && prop.game_date) || null; + if (!gameDate) { skippedUnbound += 1; continue; } const { team, opponent } = teamOpponentFor(g, prop); rows.push({ team, @@ -226,6 +234,9 @@ function rowsFromSnapshot(sport, grades, oddsProps, nowIso) { game_date: gameDate, }); } + if (skippedUnbound > 0) { + console.warn(`[ledger] ${skippedUnbound} ${sp} rows SKIPPED — no real game time; refusing to date them from the grade clock`); + } return rows; } diff --git a/src/services/retentionService.js b/src/services/retentionService.js index 09f659c..2d53745 100644 --- a/src/services/retentionService.js +++ b/src/services/retentionService.js @@ -28,6 +28,16 @@ const crypto = require('crypto'); const { normalizeName, nameKey } = require('../utils/playerName'); +/** ET calendar date of an ISO timestamp (shares gameBinder's rule). */ +function etDateOf(iso) { + if (!iso) return null; + const t = new Date(iso); + if (Number.isNaN(t.getTime())) return null; + return new Intl.DateTimeFormat('en-CA', { + timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit', + }).format(t); +} + /** * Bump when the grading model changes in a way that makes rows non-comparable. * This is the marker `ledger_entries` never had. @@ -82,7 +92,10 @@ function rowsFromSides(base, sides, ctx = {}) { sport: ctx.sport, game_id: ctx.gameIdFor ? ctx.gameIdFor(base, s) : (base.game_id || `${ctx.sport}:${ctx.gameDate}`), - game_date: ctx.gameDate, + // Session 64 (Order 1.5) — the GAME's date, from the bound game_time, + // never the snapshot clock. ctx.gameDate is only a last resort for props + // the binder could not tie to a real game. + game_date: etDateOf(base && base.game_time) || ctx.gameDate, player_key: nameKey(player), player_name: normalizeName(player).display || player, team: s.team || base.team || null, diff --git a/src/services/snapshotService.js b/src/services/snapshotService.js index 205ed2f..75d2d91 100644 --- a/src/services/snapshotService.js +++ b/src/services/snapshotService.js @@ -264,6 +264,26 @@ async function runSnapshot(sport, opts = {}) { return { sport: sp, status: 'skipped', reason: 'no props', gradeCount: 0 }; } + // Session 64 (Order 1.5) — BIND EVERY PROP TO ITS REAL GAME before anything + // downstream dates it. PropLine emits no commence_time, so ledgerService's + // `dateET(prop.game_time) || dateET(gradedTs)` always fell through to the + // GRADE timestamp — and a 01:00/03:00 UTC snapshot is the previous ET day, + // so props for tonight were filed under yesterday. Everything downstream + // (ledger, retention, settlement) reads prop.game_time, so fixing it here + // fixes all of them at once. + try { + const binder = deps.gameBinder || require('./gameBinder'); + const b = await binder.attachGameTimes(sp, props, { gradedAt: ts }); + console.log(`[snapshot] game binding ${sp}: ${b.bound} bound, ${b.alreadyHad} already had times, ${b.unresolved} UNRESOLVED, ${b.ambiguous} ambiguous(doubleheader)`); + if (b.unresolved > 0 && b.bound === 0 && b.alreadyHad === 0) { + await deps.notify(`Game binding produced NOTHING for ${sp.toUpperCase()} — ${b.unresolved} props could not be tied to a scheduled game. Their rows will be skipped rather than mis-dated.`, { + title: 'VYNDR pipeline', priority: 'high', tags: ['rotating_light'], + }); + } + } catch (e) { + console.warn(`[snapshot] game binding failed for ${sp} (rows without a real game time will be skipped):`, e.message); + } + // Session 63 — REFRESH TEAM STATS BEFORE GRADING. // `refreshTeamStats` is the ONLY writer of `team_stats:{sport}:{abbr}`, which // is the ONLY source of `opp_rank_stat` — and it had zero production callers, diff --git a/tests/unit/gameBinder.test.js b/tests/unit/gameBinder.test.js new file mode 100644 index 0000000..7c384d0 --- /dev/null +++ b/tests/unit/gameBinder.test.js @@ -0,0 +1,88 @@ +/** + * Session 64 Order 1.5 — bind a prop to its REAL game. + * + * The root bug: PropLine emits no commence_time, so ledgerService fell back to + * dating rows by the GRADE timestamp. A 01:00/03:00 UTC snapshot is the + * previous ET day, so tonight's props were filed under yesterday — which made + * settlement impossible and then got 64 rows wrongly voided as DNP. + */ +const binder = require('../../src/services/gameBinder'); + +const GAME = (id, home, away, time) => ({ id, homeTeam: { abbreviation: home }, awayTeam: { abbreviation: away }, gameTime: time }); + +describe('candidateDates', () => { + test('a 03:00 UTC grade (23:00 ET previous day) considers the NEXT ET day', () => { + // 2026-07-19T03:00Z = 2026-07-18 23:00 ET + const c = binder.candidateDates('2026-07-19T03:00:00Z'); + expect(c[0]).toBe('2026-07-18'); + expect(c).toContain('2026-07-19'); // the real game day + }); + test('also looks one day back for an in-progress game', () => { + expect(binder.candidateDates('2026-07-19T18:00:00Z')).toContain('2026-07-18'); + }); +}); + +describe('bindGame', () => { + const prop = { player: 'X', stat_type: 'hits', home_team: 'CLE', away_team: 'PIT' }; + + test('trusts a real game_time when the feed provides one', async () => { + const r = await binder.bindGame({ + sport: 'mlb', prop: { ...prop, game_time: '2026-07-19T23:10:00Z' }, + gradedAt: '2026-07-19T03:00:00Z', getSchedule: async () => [], + }); + expect(r.game_date).toBe('2026-07-19'); + }); + + test('binds by TEAMS to the real game when game_time is missing', async () => { + const r = await binder.bindGame({ + sport: 'mlb', prop, gradedAt: '2026-07-19T03:00:00Z', + getSchedule: async (sp, d) => (d === '2026-07-19' + ? [GAME('401', 'CLE', 'PIT', '2026-07-19T23:10:00Z')] : []), + }); + expect(r.game_date).toBe('2026-07-19'); // NOT the 07-18 grade date + expect(r.game_id).toBe('mlb:2026-07-19:401'); + }); + + test('unbindable prop returns NULL — never a guessed date', async () => { + const r = await binder.bindGame({ + sport: 'mlb', prop, gradedAt: '2026-07-19T03:00:00Z', getSchedule: async () => [], + }); + expect(r).toBeNull(); + }); + + test('DOUBLEHEADER is flagged ambiguous, not silently picked', async () => { + const r = await binder.bindGame({ + sport: 'mlb', prop, gradedAt: '2026-07-19T03:00:00Z', + getSchedule: async (sp, d) => (d === '2026-07-19' ? [ + GAME('401', 'CLE', 'PIT', '2026-07-19T17:10:00Z'), + GAME('402', 'CLE', 'PIT', '2026-07-19T23:10:00Z'), + ] : []), + }); + expect(r.ambiguous).toBe(true); + }); + + test('team orientation is tolerated (feeds disagree on home/away)', async () => { + const r = await binder.bindGame({ + sport: 'mlb', prop, gradedAt: '2026-07-19T03:00:00Z', + getSchedule: async (sp, d) => (d === '2026-07-19' ? [GAME('401', 'PIT', 'CLE', '2026-07-19T23:10:00Z')] : []), + }); + expect(r).not.toBeNull(); + }); +}); + +describe('attachGameTimes', () => { + test('binds a slate and reports counts (a silent collapse must be visible)', async () => { + const props = [ + { player: 'A', home_team: 'CLE', away_team: 'PIT' }, + { player: 'B', home_team: 'ZZZ', away_team: 'YYY' }, // unbindable + { player: 'C', home_team: 'CLE', away_team: 'PIT', game_time: '2026-07-19T23:10:00Z' }, + ]; + const out = await binder.attachGameTimes('mlb', props, { + gradedAt: '2026-07-19T03:00:00Z', + getSchedule: async (sp, d) => (d === '2026-07-19' ? [GAME('401', 'CLE', 'PIT', '2026-07-19T23:10:00Z')] : []), + }); + expect(out).toMatchObject({ total: 3, bound: 1, alreadyHad: 1, unresolved: 1 }); + expect(props[0].game_time).toBe('2026-07-19T23:10:00Z'); + expect(props[1].game_time).toBeUndefined(); // left unresolved, not invented + }); +});