diff --git a/src/services/environmentContext.js b/src/services/environmentContext.js index 0b9ac14..1f523b8 100644 --- a/src/services/environmentContext.js +++ b/src/services/environmentContext.js @@ -176,7 +176,17 @@ async function buildContext(sport, deps = {}) { * playerId. Anything missing → that half is null. */ const contextFor = async (grade) => { - const teamAbbr = abbrOf(grade && grade.team); + // JOIN ON THE GAME FIRST (2026-08-01). This used to key ONLY off the + // player's roster team, which is null on the graded slate — measured at + // 0/416 — so the environment axis could never resolve a venue. + // + // The park and the weather belong to the GAME, so the game's own teams are + // both the more reliable key and the more correct one: they ride on the + // prop from the odds feed and do not depend on a stats-resolve that can + // legitimately fail. The roster team stays as a fallback. + const teamAbbr = abbrOf(grade && grade.team) + || abbrOf(grade && grade.home_team) + || abbrOf(grade && grade.away_team); const game = teamAbbr ? gameByTeam.get(teamAbbr) : null; const stat = grade && (grade.stat_type || grade.stat); diff --git a/src/services/gradeSlateService.js b/src/services/gradeSlateService.js index 5bc9753..5a78524 100644 --- a/src/services/gradeSlateService.js +++ b/src/services/gradeSlateService.js @@ -126,6 +126,17 @@ async function gradeBestSide(grade, prop, sport, opts = {}) { // Strip the internal retention fields so they never reach a cache or payload. delete winner._features; delete winner._grade_11; + // CARRY THE GAME (2026-08-01). The legacy grade shape drops home/away, so by + // the time the challenger runs, nothing on the grade says WHICH GAME it is — + // measured: `team` was null on 416/416 stored grades, so the park/weather + // resolver could never find a venue and the environment axis fired on ZERO + // rows while all 14 weather forecasts sat resolved and unused. + // + // The park depends on the GAME, not on the player's roster team, so binding + // the game directly is both the fix and the more correct join: it does not + // depend on a stats-resolve that can legitimately fail. + if (winner.home_team == null && prop.home_team != null) winner.home_team = prop.home_team; + if (winner.away_team == null && prop.away_team != null) winner.away_team = prop.away_team; return winner; } diff --git a/tests/unit/opportunityDrift.test.js b/tests/unit/opportunityDrift.test.js index b61a04b..28fadba 100644 --- a/tests/unit/opportunityDrift.test.js +++ b/tests/unit/opportunityDrift.test.js @@ -122,3 +122,47 @@ describe('attachChallenger — reads opportunity off the grade (zero extra I/O)' expect(out[1].challenger_delta).toBe(0); }); }); + +describe('environment context — joins on the GAME, not the roster team', () => { + // Measured 2026-08-01: `team` was null on 416/416 stored grades, so an + // environment resolver keyed only off the roster team could never find a + // venue — the axis fired on ZERO rows while all 14 weather forecasts sat + // resolved and unused. The park belongs to the GAME, so the game's own teams + // are both the more reliable key and the more correct one. + const envCtx = require('../../src/services/environmentContext'); + + it('abbrOf resolves both a full team name and an abbreviation', () => { + expect(envCtx.abbrOf('Colorado Rockies')).toBe('COL'); + expect(envCtx.abbrOf('COL')).toBe('COL'); + }); + + it('a grade with no team AND no game resolves to nothing — never a default park', () => { + // A fabricated park factor would be a silent multiplier on every prop. + expect(envCtx.abbrOf(null)).toBeFalsy(); + expect(envCtx.abbrOf(undefined)).toBeFalsy(); + expect(envCtx.abbrOf('')).toBeFalsy(); + }); +}); + +describe('gradeSlateService — the graded row carries its game', () => { + it('binds home/away from the prop so the challenger can find the venue', async () => { + const { gradeBestSide } = require('../../src/services/gradeSlateService').__internals; + const grade = async (b) => ({ + player: b.player, stat_type: b.stat_type, line: b.line, + direction: b.direction, grade: 'B', confidence: 60, + }); + const out = await gradeBestSide(grade, { + player: 'A', stat_type: 'hits', line: 1.5, book: 'draftkings', + home_team: 'Colorado Rockies', away_team: 'Chicago Cubs', + }, 'mlb', {}); + expect(out.home_team).toBe('Colorado Rockies'); + expect(out.away_team).toBe('Chicago Cubs'); + }); + + it('does not invent a game when the prop has none', async () => { + const { gradeBestSide } = require('../../src/services/gradeSlateService').__internals; + const grade = async (b) => ({ ...b, grade: 'B', confidence: 60 }); + const out = await gradeBestSide(grade, { player: 'A', stat_type: 'hits', line: 1.5 }, 'mlb', {}); + expect(out.home_team ?? null).toBeNull(); + }); +});