'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, };