Fix the ROOT: bind props to their real game, never date by the grade clock

Order 1.5 Phase 1. PropLine emits NO commence_time (grep-verified: zero
hits in proplineAdapter), 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 21:00/23:00 ET the
PREVIOUS day. Tonight's props were filed under yesterday, settlement
correctly found no game there, and Order 1's void logic turned that into
64 destroyed results.

gameBinder.attachGameTimes() now matches every prop to a scheduled game by
TEAMS across the plausible ET window (grade date, +1, -1) and attaches the
GAME'S OWN time/date/id. It runs in snapshotService before grading and
before the ledger write, so ledger, retention and settlement all inherit
the correct date from one place.

HARD CONTRACT: an unbindable prop returns NOTHING. ledgerService no longer
has a grade-clock fallback — a row with no real game time is SKIPPED and
counted, because a mis-dated row is fabricated data and the ledger holds
real values or nothing. A slate that binds nothing pages.

DOUBLEHEADERS are reported, never guessed: two games with the same teams
on one date mark the binding `ambiguous` so settlement can decline rather
than attribute a prop to the wrong game. (Real example already in the
data: mlb:2026-07-11:MilwaukeeBrewers@PittsburghPirates(Game1).)

Also fixes retention, which had the SAME bug from last night — I had
dated model_snapshots rows with the snapshot clock. Rows now take the ET
date of the bound game_time.

Suite 282/3381 green, build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
This commit is contained in:
Kev
2026-07-20 03:25:01 -04:00
parent 270c4db47a
commit 1bcdd8b305
5 changed files with 284 additions and 2 deletions
+150
View File
@@ -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,
};
+12 -1
View File
@@ -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;
}
+14 -1
View File
@@ -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,
+20
View File
@@ -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,
+88
View File
@@ -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
});
});