diff --git a/src/services/gradeSlateService.js b/src/services/gradeSlateService.js index 042a48f..3fff476 100644 --- a/src/services/gradeSlateService.js +++ b/src/services/gradeSlateService.js @@ -59,6 +59,15 @@ async function gradeBestSide(grade, prop, sport, opts = {}) { // quarter-Kelly sizing can compute from actual prices (or not at all). over_odds: prop.over_odds ?? null, under_odds: prop.under_odds ?? null, + // Session 64 (Order 1.6) — carry the BOUND game through to grading so + // opponent/home-away features resolve against the RIGHT game. Without this + // the grader fell back to ESPN's dateless "today", which at the late slots + // is the previous day's card — binding yesterday's opponent into + // opp_rank_stat and the home/away factor. + game_date: prop.game_date ?? null, + game_time: prop.game_time ?? null, + home_team: prop.home_team ?? null, + away_team: prop.away_team ?? null, }; const sides = await Promise.all([ Promise.resolve() diff --git a/src/services/intelligence/computeFeatures.js b/src/services/intelligence/computeFeatures.js index 83bde71..2a303ef 100644 --- a/src/services/intelligence/computeFeatures.js +++ b/src/services/intelligence/computeFeatures.js @@ -126,12 +126,39 @@ async function lookupPlayer({ player, sport }) { // Pull today's scoreboard for the sport and find the game the player's // team plays in. Returns { gameId, opponentAbbr, isHome } or null. -async function lookupTodayGame({ sport, teamAbbr }) { +/** + * Session 64 (Order 1.6) — resolve the player's game for a SPECIFIC DATE. + * + * This used to be `lookupTodayGame`, calling the ESPN scoreboard with NO date + * param — it took whatever ESPN calls "today". At the late slots (01:00/03:00 + * UTC = 21:00/23:00 ET) that is the PREVIOUS day's card, so a prop for + * tonight bound `opponentAbbr` and `home_away` to YESTERDAY'S opponent. Those + * feed the ±1.0 opponent-defense factor and the home/away factor, so it is a + * MODEL-OUTPUT bug, not bookkeeping. + * + * `gameDate` (YYYY-MM-DD, ET) comes from the prop's BOUND game — the same game + * the ledger, retention and settlement now use, so all four agree. + * Absent gameDate → we do NOT guess a day; the caller degrades honestly. + */ +/** ET calendar date of an ISO timestamp. */ +function dateETOf(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); +} + +async function lookupGameOnDate({ sport, teamAbbr, gameDate }) { if (!sport || !teamAbbr) return null; let sportCfg; try { sportCfg = getSportConfig(sport); } catch { return null; } try { - const res = await axios.get(sportCfg.espnScoreboard, { timeout: HTTP_TIMEOUT_MS }); + const url = gameDate + ? `${sportCfg.espnScoreboard}${sportCfg.espnScoreboard.includes('?') ? '&' : '?'}dates=${String(gameDate).replace(/-/g, '')}` + : sportCfg.espnScoreboard; + const res = await axios.get(url, { timeout: HTTP_TIMEOUT_MS }); const events = res.data?.events || []; for (const ev of events) { const comp = ev?.competitions?.[0]; @@ -241,7 +268,18 @@ async function computeFeaturesForProp(rawProp = {}) { const teamAbbr = roster?.team_abbr ?? null; const playerId = roster?.espn_id ?? null; - const game = teamAbbr ? await lookupTodayGame({ sport, teamAbbr }) : null; + // Session 64 (Order 1.6) — bind opponent/home-away features to the prop's + // REAL game. `gameBinder` attaches game_date in snapshotService before + // grading, so grading references the same game as the ledger/retention. + // With no bound date we do NOT fall back to a dateless "today" lookup — + // that is exactly what bound the wrong opponent. The features simply stay + // absent, and engine1 omits the factors rather than scoring a wrong matchup. + const boundGameDate = rawProp.game_date + || (rawProp.game_time ? dateETOf(rawProp.game_time) : null); + const game = (teamAbbr && boundGameDate) + ? await lookupGameOnDate({ sport, teamAbbr, gameDate: boundGameDate }) + : null; + if (teamAbbr && !boundGameDate) errors.push('no_bound_game_date'); if (!game) errors.push('no_game_scheduled_today'); // Session 63 — fetch the normalized per-game rows ONCE. They feed three @@ -296,7 +334,11 @@ async function computeFeaturesForProp(rawProp = {}) { // The `t01_*` fields land alongside the ESPN-derived features; // grading + reasoning + trap detection read them when present and // ignore them when absent. - const ymd = new Date().toISOString().slice(0, 10).replace(/-/g, ''); + // Session 64 (Order 1.6) — same class of bug as the scoreboard lookup: this + // was TODAY's UTC date, so a late-slot grade read the wrong day's Tank01 + // cache. Use the prop's BOUND game date; fall back to today only when there + // is no bound game (the t01_* fields are additive and simply stay absent). + const ymd = (boundGameDate || new Date().toISOString().slice(0, 10)).replace(/-/g, ''); try { if (sport === 'nba') { const aug = await tank01Augment.augmentNbaFeatures({ @@ -417,7 +459,7 @@ module.exports = { computeFeaturesForProp, __internals: { lookupPlayer, - lookupTodayGame, + lookupGameOnDate, safeGetFeatures, safeGetTrap, safeGetConsistency, diff --git a/tests/unit/computeFeatures.test.js b/tests/unit/computeFeatures.test.js index dd04498..e29a89f 100644 --- a/tests/unit/computeFeatures.test.js +++ b/tests/unit/computeFeatures.test.js @@ -99,6 +99,9 @@ describe('computeFeaturesForProp — happy path', () => { const out = await computeFeaturesForProp({ player: 'Jalen Brunson', stat_type: 'points', line: 25.5, direction: 'over', sport: 'nba', + // Session 64 — grading binds opponent features to the prop's REAL game. + // Without a bound date it no longer guesses ESPN's "today". + game_date: '2026-07-20', }); expect(out.features.l5_avg).toBe(28.4); @@ -132,7 +135,7 @@ describe('computeFeaturesForProp — graceful degradation', () => { mockSupabaseState.rosterRow = { team_abbr: 'NYK', espn_id: '1', sport: 'nba' }; mockAxiosGet.mockResolvedValue(nbaScoreboard([])); // empty slate const out = await computeFeaturesForProp({ - player: 'Some Player', stat_type: 'points', line: 22, direction: 'over', sport: 'nba', + player: 'Some Player', stat_type: 'points', line: 22, direction: 'over', sport: 'nba', game_date: '2026-07-20', }); expect(out.meta.errors).toContain('no_game_scheduled_today'); expect(out.meta.teamAbbr).toBe('NYK'); @@ -144,7 +147,7 @@ describe('computeFeaturesForProp — graceful degradation', () => { mockAxiosGet.mockResolvedValue(nbaScoreboard([game('e2', 'NYK', 'BOS')])); mockFeatures.throws = true; const out = await computeFeaturesForProp({ - player: 'Brunson', stat_type: 'points', line: 25, direction: 'over', sport: 'nba', + player: 'Brunson', stat_type: 'points', line: 25, direction: 'over', sport: 'nba', game_date: '2026-07-20', }); expect(out.meta.errors).toContain('no_features_computed'); // Session 15 — static lookups (pace factor, park factor) populate @@ -166,7 +169,7 @@ describe('computeFeaturesForProp — graceful degradation', () => { mockFeatures.current = { l5_avg: 25 }; mockTrap.throws = true; const out = await computeFeaturesForProp({ - player: 'Brunson', stat_type: 'points', line: 25, direction: 'over', sport: 'nba', + player: 'Brunson', stat_type: 'points', line: 25, direction: 'over', sport: 'nba', game_date: '2026-07-20', }); expect(out.trap).toMatchObject({ composite: 0, recommendation: 'proceed' }); }); @@ -198,3 +201,31 @@ describe('computeFeaturesForProp — graceful degradation', () => { expect(src).not.toMatch(/UnifiedOddsProvider/); }); }); + +describe('Session 64 — grading binds to the BOUND game, never "today"', () => { + test('no bound game date → opponent features are NOT bound to a guessed game', async () => { + mockSupabaseState.rosterRow = { team_abbr: 'NYK', espn_id: '1', sport: 'nba' }; + // A full scoreboard is available — the old code would have happily bound + // this (possibly yesterday's) game. Without a bound date we must not. + mockAxiosGet.mockResolvedValue(nbaScoreboard([game('ev-9', 'NYK', 'BOS')])); + const out = await computeFeaturesForProp({ + player: 'Brunson', stat_type: 'points', line: 25, direction: 'over', sport: 'nba', + // no game_date / game_time + }); + expect(out.meta.errors).toContain('no_bound_game_date'); + expect(out.meta.gameId).toBeNull(); + expect(out.meta.opponentAbbr).toBeNull(); + expect(out.meta.isHome).toBeNull(); + }); + + test('a bound date resolves the opponent for THAT date', async () => { + mockSupabaseState.rosterRow = { team_abbr: 'NYK', espn_id: '1', sport: 'nba' }; + mockAxiosGet.mockResolvedValue(nbaScoreboard([game('ev-10', 'NYK', 'MIA')])); + const out = await computeFeaturesForProp({ + player: 'Brunson', stat_type: 'points', line: 25, direction: 'over', sport: 'nba', + game_date: '2026-07-20', + }); + expect(out.meta.opponentAbbr).toBe('MIA'); + expect(out.meta.errors).not.toContain('no_bound_game_date'); + }); +});