6415751f2e
Order 1.6 Phase 1. This is a MODEL-OUTPUT fix, not bookkeeping. computeFeatures.lookupTodayGame called the ESPN scoreboard with NO date param and took whatever ESPN calls "today". Renamed to lookupGameOnDate and now sends ?dates=YYYYMMDD from the prop's BOUND game — the same game the ledger, retention and settlement use, so all four finally agree. PROVEN against live ESPN (before/after, same instant): dateless "today" CLE->PIT NYY->LAD LAD->NYY (Jul 19 card) bound to 2026-07-20 CLE->MIN NYY->PIT LAD->PHI (the real games) bound to 2026-07-19 CLE->PIT NYY->LAD LAD->NYY (reproduces OLD) Every opponent was wrong. opponentAbbr feeds opp_rank_stat (a +/-1.0 factor) and isHome feeds home_away (+0.5), so late-slot grades were scored against the wrong matchup. Note the window is WIDER than the 01:00/03:00 UTC slots: this ran at 07:5x UTC = 03:5x ET and ESPN's dateless scoreboard was STILL returning the previous day's card. HONEST DEGRADATION: with no bound game date the grader does NOT fall back to a dateless lookup — it records 'no_bound_game_date' and leaves opponentAbbr/isHome/gameId null, so engine1 simply omits the opponent and home/away factors rather than scoring a wrong matchup. Tests assert both directions. Same class of bug fixed alongside: the Tank01 augmentation used TODAY's UTC date for its cache key; it now uses the bound game date. gradeSlateService threads game_date/game_time/home_team/away_team into the grader so the binding reaches computeFeatures at all. Audited the rest of the feature path for dateless/"today" lookups — none remain (weather is current-conditions by venue, park/pace are static). Suite 282/3383 green, build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
468 lines
20 KiB
JavaScript
468 lines
20 KiB
JavaScript
/**
|
|
* computeFeaturesForProp — the ONE permitted architectural addition of
|
|
* Session 7f. Bridges raw single-prop input (`{player, stat_type, line,
|
|
* direction, book, sport}`) to the feature-vector shape `engine1.gradeProp()`
|
|
* expects.
|
|
*
|
|
* The orchestrator does this same work inline, tied to its batch loop +
|
|
* grade_history persistence. This module lifts only the per-prop logic
|
|
* so single-prop callers (`/api/analyze/prop`, batch entries,
|
|
* `/api/scan/parlay` legs, `/api/bets/*`) can produce engine1 input
|
|
* without re-implementing the resolution chain.
|
|
*
|
|
* Never throws. Every step is independently fault-tolerant:
|
|
* - player_id_map miss → team/opponent unknown, features still partial
|
|
* - no game tonight → no gameId, gameId-dependent features omitted
|
|
* - feature fetch fails → features {} returned, engine1 lands C
|
|
* - trap fetch fails → trap defaults to no signals firing
|
|
* - game logs unavailable → consistency defaults to 'unknown'
|
|
*
|
|
* The caller (analyzeViaEngine1) reads the returned `errors` array and
|
|
* surfaces them in the reasoning string. NOTE (Session 63): this comment used
|
|
* to claim confidence is "downgraded accordingly" — it never was. No
|
|
* data-sufficiency penalty exists in the live path; confidence is a pure
|
|
* function of the grade letter (see gradeAdapter `confidence_basis`). The one
|
|
* real penalty lived in the dead `mlbGrader.js`, now removed. Insufficient data
|
|
* produces a REFUSAL (grade null + insufficient_data), not a softened grade.
|
|
*
|
|
* ─────────────────────────────────────────────────────────────────────
|
|
* Signal provenance (Session 15 audit)
|
|
* ─────────────────────────────────────────────────────────────────────
|
|
* Every signal the engine reads has a documented source. Phantom
|
|
* signals — referenced in reasoning but populated by nothing — would
|
|
* be a trust failure. As of Session 15 there are none.
|
|
*
|
|
* • injury_severity_score (engine1.js:126 reads it; analyzeViaEngine1
|
|
* surfaces it in reasoning at line 156)
|
|
* ← `src/services/intelligence/injuryParser.js` (ESPN injury feed)
|
|
* Populated by the grading orchestrator in batch mode; in the
|
|
* single-prop path it lives in the `featureCache` payload.
|
|
*
|
|
* • coach_pace_delta + coach_player_interaction
|
|
* ← `src/services/intelligence/coachSignals.js` reads the
|
|
* `coach_profiles` Supabase table (migration 017), with a
|
|
* `src/config/coaches.json` seed file as the cold-start fallback.
|
|
*
|
|
* • consistency (boom_bust / reliable / elite labels + numeric score)
|
|
* ← `src/services/intelligence/consistencyScore.js` operating on
|
|
* game logs from `gameLogService` (ESPN). When game logs are
|
|
* unavailable, defaults to `{consistency:'unknown', score:null}`
|
|
* which engine1 treats as neutral (does not penalize).
|
|
*
|
|
* • Tank01 t01_* fields (added Session 14)
|
|
* ← `src/services/intelligence/tank01Augment.js` reads cache keys
|
|
* written by `scripts/tank01-prefetch.js` (Session 15 — added
|
|
* this session) which calls the Tank01 NBA/MLB RapidAPI adapters.
|
|
*
|
|
* • Soccer features (10 of them — goals_per_90, xG, altitude, etc.)
|
|
* ← `src/services/intelligence/soccerFeatureExtractor.js` cascade
|
|
* across api-football → footapi → football-data cache keys.
|
|
*
|
|
* • Park factors (Session 15 — MLB)
|
|
* ← `src/data/parkFactors.js` — static FanGraphs 2024-25 data.
|
|
*
|
|
* • Weather (Session 15 — MLB + soccer)
|
|
* ← `src/services/weatherService.js` calls Open-Meteo (no key),
|
|
* cached 1h in Redis. Skipped for dome stadiums.
|
|
*
|
|
* • Pace factors (Session 15 — NBA)
|
|
* ← `src/data/paceFactors.js` — static NBA team pace data.
|
|
*
|
|
* No signal currently surfaces in user-facing reasoning that isn't
|
|
* populated by one of the sources above. When a source is down, the
|
|
* signal returns null and reasoning omits it gracefully — never
|
|
* fabricated.
|
|
*/
|
|
|
|
const axios = require('axios');
|
|
const { getSportConfig } = require('../../config/sports');
|
|
const { getSupabaseServiceClient } = require('../../utils/supabase');
|
|
const { normalizeName } = require('../../utils/normalize');
|
|
const featureCache = require('./featureCache');
|
|
const trapDetection = require('./trapDetection');
|
|
const consistencyScore = require('./consistencyScore');
|
|
const gameLogService = require('./gameLogService');
|
|
// Session 7j — soccer branch. The extractor reads from prefetched
|
|
// Redis cache; no external HTTP on the user request path.
|
|
const { extractSoccerFeatures, isSoccerSport } = require('./soccerFeatureExtractor');
|
|
// Session 14 — Tank01 augmentor. Reads cache keys the Tank01
|
|
// adapters write; no network from this path. Daily prefetch (future)
|
|
// populates the cache. Until that lands, the augmentor returns
|
|
// empty objects and the existing ESPN-derived features stand alone.
|
|
const tank01Augment = require('./tank01Augment');
|
|
// Session 15 — static lookup tables (MLB park factors, NBA pace
|
|
// factors). Pure synchronous reads, no network, no cache. Merged
|
|
// into the feature map alongside the per-sport ESPN payload.
|
|
const { getParkFactor } = require('../../data/parkFactors');
|
|
const { getPaceFactor } = require('../../data/paceFactors');
|
|
// Session 15 — Open-Meteo weather fetch. 1h Redis cache, 5s timeout,
|
|
// silent on failure. Skipped for dome stadiums via the venue index.
|
|
const weatherService = require('../weatherService');
|
|
const { getMlbVenue, getWcVenueCoords } = require('../../data/venueCoordinates');
|
|
|
|
const HTTP_TIMEOUT_MS = 8_000;
|
|
|
|
// Resolve a free-form player + sport to a roster row. Returns null on
|
|
// any failure so callers can still proceed with partial features.
|
|
async function lookupPlayer({ player, sport }) {
|
|
if (!player || !sport) return null;
|
|
try {
|
|
const supabase = getSupabaseServiceClient();
|
|
const norm = normalizeName(player);
|
|
const { data, error } = await supabase
|
|
.from('player_id_map')
|
|
.select('display_name, normalized_name, espn_id, team_abbr, sport')
|
|
.eq('sport', sport)
|
|
.eq('normalized_name', norm)
|
|
.limit(1)
|
|
.maybeSingle();
|
|
if (error || !data) return null;
|
|
return data;
|
|
} catch (err) {
|
|
console.warn('[computeFeatures] player lookup failed:', err.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Pull today's scoreboard for the sport and find the game the player's
|
|
// team plays in. Returns { gameId, opponentAbbr, isHome } or null.
|
|
/**
|
|
* 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 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];
|
|
if (!comp) continue;
|
|
const competitors = comp.competitors || [];
|
|
const home = competitors.find((c) => c.homeAway === 'home');
|
|
const away = competitors.find((c) => c.homeAway === 'away');
|
|
const homeAbbr = home?.team?.abbreviation;
|
|
const awayAbbr = away?.team?.abbreviation;
|
|
if (homeAbbr === teamAbbr) {
|
|
return { gameId: String(ev.id), opponentAbbr: awayAbbr, isHome: true };
|
|
}
|
|
if (awayAbbr === teamAbbr) {
|
|
return { gameId: String(ev.id), opponentAbbr: homeAbbr, isHome: false };
|
|
}
|
|
}
|
|
return null;
|
|
} catch (err) {
|
|
console.warn('[computeFeatures] scoreboard fetch failed:', err.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function safeGetFeatures(input) {
|
|
try {
|
|
const payload = await featureCache.getFeatures(input);
|
|
return payload?.features || {};
|
|
} catch (err) {
|
|
console.warn('[computeFeatures] feature fetch failed:', err.message);
|
|
return {};
|
|
}
|
|
}
|
|
|
|
async function safeGetTrap(input) {
|
|
const fallback = { composite: 0, signals: {}, active_count: 0, recommendation: 'proceed' };
|
|
try {
|
|
return (await trapDetection.getTrapScore(input)) || fallback;
|
|
} catch (err) {
|
|
console.warn('[computeFeatures] trap detection failed:', err.message);
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
async function safeGetConsistency({ playerName, sport, statType, statRows }) {
|
|
const fallback = { consistency: 'unknown', score: null, games: 0 };
|
|
try {
|
|
// Session 63 — normalized rows from the REAL per-sport sources (MLB
|
|
// statsapi / ESPN gamelog), not the NBA-WNBA-only Python service. This one
|
|
// call feeds BOTH the consistency factor and (via meta.gameLogs) the
|
|
// probability estimator, which had no rows at all in production.
|
|
// `statRows` is passed in by computeFeaturesForProp so the fetch happens
|
|
// ONCE per prop (it also powers game_count_in_7d, built before features).
|
|
const logs = Array.isArray(statRows)
|
|
? statRows
|
|
: await featureCache.getStatRows(playerName, sport, statType);
|
|
if (!logs || logs.length === 0) return { result: fallback, gameLogs: [] };
|
|
const result = await consistencyScore.getConsistency({
|
|
playerName, sport, statType, gameLogs: logs,
|
|
});
|
|
return { result: result || fallback, gameLogs: logs };
|
|
} catch (err) {
|
|
console.warn('[computeFeatures] consistency failed:', err.message);
|
|
return { result: fallback, gameLogs: [] };
|
|
}
|
|
}
|
|
|
|
async function computeFeaturesForProp(rawProp = {}) {
|
|
// Default to NBA when caller omits — matches what legacy analyzeProp does.
|
|
const sport = String(rawProp.sport || 'nba').toLowerCase();
|
|
|
|
// Soccer routes to a different extractor — different data sources
|
|
// (football-data.org + cache vs ESPN scoreboard), different feature
|
|
// set (xG, altitude, referee, set-piece role). The extractor returns
|
|
// the same {features, trap, consistency, prop, meta} shape engine1
|
|
// consumes, so analyzeViaEngine1 is sport-agnostic downstream.
|
|
if (isSoccerSport(sport)) {
|
|
const soccerResult = await extractSoccerFeatures(rawProp);
|
|
// Soccer extractor returns a placeholder trap object. Run the real
|
|
// soccer-branch trap detection here using the freshly computed
|
|
// features so analyzeViaEngine1 sees a populated trap composite.
|
|
const soccerTrap = await safeGetTrap({
|
|
sport: 'soccer',
|
|
playerName: rawProp.player,
|
|
statType: soccerResult.meta?.statType,
|
|
gameId: null,
|
|
gameContext: { home_away: soccerResult.features?.home_away === 1.0 ? 'home' : (soccerResult.features?.home_away === 0.0 ? 'away' : null) },
|
|
features: soccerResult.features,
|
|
odds: { playerLine: soccerResult.prop?.line, consensus: null },
|
|
});
|
|
return { ...soccerResult, trap: soccerTrap };
|
|
}
|
|
|
|
const errors = [];
|
|
const player = rawProp.player;
|
|
const statType = rawProp.stat_type || rawProp.statType;
|
|
const line = Number(rawProp.line);
|
|
const direction = rawProp.direction || 'over';
|
|
const book = rawProp.book || 'unknown';
|
|
|
|
if (!player || !statType || !Number.isFinite(line)) {
|
|
errors.push('missing required fields (player, stat_type, or line)');
|
|
}
|
|
|
|
const roster = await lookupPlayer({ player, sport });
|
|
if (!roster) errors.push('player_not_found_in_id_map');
|
|
|
|
const teamAbbr = roster?.team_abbr ?? null;
|
|
const playerId = roster?.espn_id ?? 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
|
|
// consumers that were all starving: the consistency factor, the probability
|
|
// estimator (via meta.gameLogs), and game_count_in_7d below.
|
|
const statRows = await featureCache.getStatRows(player, sport, statType);
|
|
|
|
const gameContext = {
|
|
home_away: game ? (game.isHome ? 'home' : 'away') : null,
|
|
// `game_count_in_7d` gates engine1's heavy_workload_7d (-0.5). Nothing ever
|
|
// populated it, so that factor could not fire. Derived from real logged
|
|
// game dates; null (omitted) when we have no dated rows.
|
|
game_count_in_7d: featureCache.gameCountInWindow(statRows, 7),
|
|
// DELIBERATELY NOT SET: `teamId`. It was tempting to thread it here to
|
|
// unlock injuryFeatures, but that would be dead code dressed as a fix —
|
|
// three things block that factor and none is solved by a teamId here:
|
|
// 1. getFeatures reads `teamId` as a TOP-LEVEL input, not off gameContext;
|
|
// 2. `player_id_map` has no team_id column (lookupPlayer selects
|
|
// espn_id/team_abbr only), so there is no id to pass;
|
|
// 3. injury_severity_score counts MISSING KNOWN STARTERS and no starter-id
|
|
// list exists, so it resolves to 0 and engine1's factor (needs >= 2)
|
|
// still cannot fire.
|
|
// There is also an unresolved semantic: the factor is documented as
|
|
// OPPONENT injuries but getFeatures passes `teamId`, with `opponentTeamId`
|
|
// sitting unused beside it. Left alone on purpose — see
|
|
// specs/audit-data/grade-collapse.md.
|
|
// DELIBERATELY NOT SET: `season_type`. engine1's playoff factors gate on
|
|
// `season_type >= 2`, but ESPN's season_type 2 means REGULAR season — so
|
|
// threading it raw would fire "veteran_in_playoffs" in July. The factor also
|
|
// needs career_playoff_games, which only the offline Python service
|
|
// provides. Left unset on purpose; see specs/audit-data/grade-collapse.md.
|
|
};
|
|
|
|
const features = await safeGetFeatures({
|
|
playerId,
|
|
playerName: player,
|
|
statType,
|
|
sport,
|
|
teamAbbr,
|
|
opponentAbbr: game?.opponentAbbr ?? null,
|
|
gameId: game?.gameId ?? null,
|
|
gameContext,
|
|
});
|
|
|
|
if (!features || Object.keys(features).length === 0) {
|
|
errors.push('no_features_computed');
|
|
}
|
|
|
|
// Session 14 — Tank01 augmentation. Sport-specific. Both calls are
|
|
// cache-only (no network), Promise.allSettled-style isolated so a
|
|
// Redis hiccup on the Tank01 read doesn't fail the whole grade.
|
|
// The `t01_*` fields land alongside the ESPN-derived features;
|
|
// grading + reasoning + trap detection read them when present and
|
|
// ignore them when absent.
|
|
// 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({
|
|
gameId: game?.gameId ?? null,
|
|
playerName: player,
|
|
ymd,
|
|
});
|
|
Object.assign(features, aug);
|
|
} else if (sport === 'mlb') {
|
|
const aug = await tank01Augment.augmentMlbFeatures({
|
|
gameId: game?.gameId ?? null,
|
|
batterName: player,
|
|
// batterId/pitcherId/pitcherName not yet plumbed through
|
|
// computeFeatures — the augmentor returns name-only markers
|
|
// when IDs are absent.
|
|
ymd,
|
|
});
|
|
Object.assign(features, aug);
|
|
}
|
|
} catch (err) {
|
|
// Never let augmentation failure poison the grade.
|
|
console.warn('[computeFeatures] Tank01 augmentation skipped:', err.message);
|
|
}
|
|
|
|
// Session 15 — static context augmentation. Park factors (MLB),
|
|
// pace factors (NBA). Synchronous, can't fail; the lookups return
|
|
// null on miss, which we treat as "no signal — drop the field".
|
|
try {
|
|
if (sport === 'mlb') {
|
|
// Home team in this matchup hosts the game; if the player's
|
|
// team is home, use their abbr — otherwise use the opponent's.
|
|
const homeAbbr = game?.isHome ? teamAbbr : game?.opponentAbbr;
|
|
const park = getParkFactor(homeAbbr);
|
|
if (park) {
|
|
features.park_hr = park.hr;
|
|
features.park_h = park.h;
|
|
features.park_r = park.r;
|
|
features.park_home = homeAbbr;
|
|
}
|
|
} else if (sport === 'nba') {
|
|
// Pace factors are per-team — use the player's own team (fast
|
|
// teams up the count regardless of opponent, slow teams
|
|
// compress). Opponent pace effect is a separate signal we
|
|
// could layer in a follow-up.
|
|
const pace = getPaceFactor(teamAbbr);
|
|
if (pace != null) features.pace_factor = pace;
|
|
const oppPace = getPaceFactor(game?.opponentAbbr);
|
|
if (oppPace != null) features.opp_pace_factor = oppPace;
|
|
}
|
|
} catch (err) {
|
|
console.warn('[computeFeatures] static context augmentation skipped:', err.message);
|
|
}
|
|
|
|
// Session 15 — weather. Open-Meteo via weatherService. 5s timeout,
|
|
// 1h Redis cache, dome-aware skip. Outdoor MLB + soccer benefit;
|
|
// basketball indoor venues skip entirely.
|
|
try {
|
|
if (sport === 'mlb') {
|
|
const homeAbbr = game?.isHome ? teamAbbr : game?.opponentAbbr;
|
|
const venue = homeAbbr ? getMlbVenue(homeAbbr) : null;
|
|
if (venue && !venue.dome && Number.isFinite(venue.lat) && Number.isFinite(venue.lon)) {
|
|
const w = await weatherService.getWeather(venue.lat, venue.lon);
|
|
if (w) {
|
|
features.weather_temp_f = w.temp_f ?? null;
|
|
features.weather_wind_mph = w.wind_mph ?? null;
|
|
features.weather_wind_dir = w.wind_dir ?? null;
|
|
features.weather_precip = w.precip_mm ?? null;
|
|
}
|
|
}
|
|
}
|
|
// Soccer weather slots in via the soccer branch (handled earlier
|
|
// for the soccer sport — the venue is part of the cascade).
|
|
} catch (err) {
|
|
console.warn('[computeFeatures] weather lookup skipped:', err.message);
|
|
}
|
|
|
|
const trap = await safeGetTrap({
|
|
playerName: player,
|
|
statType,
|
|
sport,
|
|
gameId: game?.gameId ?? null,
|
|
gameContext,
|
|
features,
|
|
odds: { playerLine: line, consensus: null },
|
|
});
|
|
|
|
const { result: consistency, gameLogs } = await safeGetConsistency({
|
|
playerName: player, sport, statType, statRows,
|
|
});
|
|
|
|
return {
|
|
// Shape engine1.gradeProp() consumes.
|
|
features,
|
|
trap,
|
|
consistency,
|
|
prop: { line, direction },
|
|
// Extra context the wiring helper (Fix 2) uses to build human-readable
|
|
// reasoning sentences. Not consumed by engine1 itself.
|
|
meta: {
|
|
player,
|
|
statType,
|
|
line,
|
|
direction,
|
|
book,
|
|
sport,
|
|
teamAbbr,
|
|
playerId,
|
|
opponentAbbr: game?.opponentAbbr ?? null,
|
|
gameId: game?.gameId ?? null,
|
|
isHome: game?.isHome ?? null,
|
|
gameLogs,
|
|
errors,
|
|
},
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
computeFeaturesForProp,
|
|
__internals: {
|
|
lookupPlayer,
|
|
lookupGameOnDate,
|
|
safeGetFeatures,
|
|
safeGetTrap,
|
|
safeGetConsistency,
|
|
},
|
|
};
|