Session 15: Intelligence hardening — park factors, weather, Tank01 prefetch, pace factors, signal audit, founder pricing fix (1405 tests)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* NBA team pace factors (Session 15).
|
||||
*
|
||||
* Source: NBA.com/stats team pace data 2024-25 season (possessions
|
||||
* per 48 minutes, league average ~98). Indexed to 100 = league
|
||||
* average so values compose multiplicatively with the player's
|
||||
* baseline stats — a 105-pace team adds ~5% to counting stats
|
||||
* (points, rebounds, assists, etc.); a 94-pace team subtracts ~6%.
|
||||
*
|
||||
* Update annually before the playoffs (the regular-season composite
|
||||
* is stable by All-Star break). For mid-season grades, consider
|
||||
* blending this static table with the live `team_pace` value the
|
||||
* orchestrator already computes from game logs.
|
||||
*/
|
||||
|
||||
const PACE_FACTORS = Object.freeze({
|
||||
// East
|
||||
ATL: 103, BOS: 99, BKN: 100, CHA: 102, CHI: 98,
|
||||
CLE: 96, DET: 102, IND: 105, MIA: 98, MIL: 99,
|
||||
NYK: 95, ORL: 94, PHI: 99, TOR: 100, WAS: 102,
|
||||
// West
|
||||
DAL: 99, DEN: 99, GSW: 102, HOU: 102, LAC: 99,
|
||||
LAL: 100, MEM: 102, MIN: 99, NOP: 100, OKC: 100,
|
||||
PHX: 99, POR: 102, SAC: 104, SAS: 100, UTA: 102,
|
||||
});
|
||||
|
||||
const LEAGUE_AVERAGE = 100;
|
||||
|
||||
function normalizeTeamCode(team) {
|
||||
if (!team) return null;
|
||||
return String(team).trim().toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* getPaceFactor — returns null for unknown teams so the feature
|
||||
* extractor drops the signal entirely rather than reporting "league-
|
||||
* average pace" on a typo.
|
||||
*/
|
||||
function getPaceFactor(team) {
|
||||
const code = normalizeTeamCode(team);
|
||||
if (!code) return null;
|
||||
// The grading orchestrator uses some legacy abbreviations:
|
||||
// NJN → BKN (Nets relocated 2012)
|
||||
// NOH/NOK → NOP (Pelicans rebrand)
|
||||
// SEA → OKC (Sonics relocated)
|
||||
// CHO → CHA (Hornets rebrand)
|
||||
// Folded here so old game-log teams still resolve to current pace.
|
||||
const alias = ALIASES[code];
|
||||
return PACE_FACTORS[alias || code] || null;
|
||||
}
|
||||
|
||||
const ALIASES = Object.freeze({
|
||||
NJN: 'BKN',
|
||||
NOH: 'NOP',
|
||||
NOK: 'NOP',
|
||||
SEA: 'OKC',
|
||||
CHO: 'CHA',
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
PACE_FACTORS,
|
||||
LEAGUE_AVERAGE,
|
||||
ALIASES,
|
||||
getPaceFactor,
|
||||
__internals: { normalizeTeamCode },
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* MLB park factors (Session 15).
|
||||
*
|
||||
* Source: FanGraphs 2024-25 park factor data (three-year weighted
|
||||
* average — FanGraphs methodology). Numbers indexed to 100 = league
|
||||
* average. Pulled from https://www.fangraphs.com/guts.aspx (Park
|
||||
* Factors tab) 2025-04. Update annually; the rolling-three-year
|
||||
* window means values move slowly.
|
||||
*
|
||||
* Fields per park:
|
||||
* hr — home run factor (Coors ~128 means HRs are 28% more likely;
|
||||
* Oracle/SF ~85 means 15% less likely)
|
||||
* h — overall hit factor
|
||||
* r — runs factor
|
||||
*
|
||||
* The feature extractor reads this via `getParkFactor(homeTeam)`
|
||||
* during the MLB branch and merges hr/h/r as `park_hr`, `park_h`,
|
||||
* `park_r` so the grading engine + reasoning builder can read them
|
||||
* without re-doing the lookup.
|
||||
*
|
||||
* Indexing convention: home-team abbreviation. The road team plays
|
||||
* at the home team's park, so a Yankees-at-Angels game uses LAA's
|
||||
* park factors regardless of whose batter you're grading.
|
||||
*/
|
||||
|
||||
const PARK_FACTORS = Object.freeze({
|
||||
// AL East
|
||||
BAL: { hr: 109, h: 100, r: 102 }, // Camden Yards — left field wall changes leveled off post-2022
|
||||
BOS: { hr: 100, h: 108, r: 106 }, // Fenway — Monster boosts singles/doubles, suppresses HR
|
||||
NYY: { hr: 114, h: 101, r: 104 }, // Yankee Stadium — short porch in right
|
||||
TB: { hr: 93, h: 96, r: 94 }, // Tropicana — neutral-to-pitcher dome
|
||||
TOR: { hr: 106, h: 101, r: 102 }, // Rogers Centre — slight HR boost
|
||||
|
||||
// AL Central
|
||||
CHW: { hr: 109, h: 102, r: 105 }, // Guaranteed Rate Field
|
||||
CLE: { hr: 96, h: 99, r: 98 }, // Progressive Field — slight pitcher park
|
||||
DET: { hr: 90, h: 100, r: 97 }, // Comerica — deep alleys, HR-suppressed
|
||||
KC: { hr: 93, h: 103, r: 101 }, // Kauffman — XBH-friendly, HR-neutral
|
||||
MIN: { hr: 105, h: 100, r: 101 }, // Target Field
|
||||
|
||||
// AL West
|
||||
HOU: { hr: 100, h: 101, r: 101 }, // Minute Maid — Crawford boxes ~neutral, dome
|
||||
LAA: { hr: 97, h: 98, r: 98 }, // Angel Stadium
|
||||
OAK: { hr: 91, h: 95, r: 92 }, // Coliseum — extreme pitcher park
|
||||
SEA: { hr: 96, h: 98, r: 96 }, // T-Mobile Park — marine air
|
||||
TEX: { hr: 104, h: 102, r: 103 }, // Globe Life — climate-controlled since 2020
|
||||
|
||||
// NL East
|
||||
ATL: { hr: 103, h: 100, r: 101 }, // Truist Park
|
||||
MIA: { hr: 89, h: 97, r: 94 }, // loanDepot park — deep alleys
|
||||
NYM: { hr: 95, h: 99, r: 97 }, // Citi Field — slight pitcher
|
||||
PHI: { hr: 109, h: 100, r: 102 }, // Citizens Bank Park — bandbox right field
|
||||
WSH: { hr: 100, h: 100, r: 100 }, // Nationals Park — true neutral
|
||||
|
||||
// NL Central
|
||||
CHC: { hr: 106, h: 100, r: 102 }, // Wrigley — wind-driven swings; multi-year average
|
||||
CIN: { hr: 113, h: 102, r: 105 }, // Great American — top-3 HR park most years
|
||||
MIL: { hr: 104, h: 100, r: 101 }, // American Family Field
|
||||
PIT: { hr: 96, h: 101, r: 99 }, // PNC Park — XBH-favorable, HR-suppressed
|
||||
STL: { hr: 93, h: 98, r: 96 }, // Busch — neutral-to-pitcher
|
||||
|
||||
// NL West
|
||||
ARI: { hr: 100, h: 98, r: 98 }, // Chase Field — humidor since 2018
|
||||
COL: { hr: 128, h: 112, r: 120 }, // Coors Field — altitude. Single biggest park effect.
|
||||
LAD: { hr: 102, h: 99, r: 99 }, // Dodger Stadium
|
||||
SD: { hr: 95, h: 97, r: 95 }, // Petco — marine air pitcher park
|
||||
SF: { hr: 85, h: 96, r: 92 }, // Oracle Park — Bay winds suppress HRs heavily
|
||||
});
|
||||
|
||||
const NEUTRAL = Object.freeze({ hr: 100, h: 100, r: 100 });
|
||||
|
||||
function normalizeTeamCode(team) {
|
||||
if (!team) return null;
|
||||
return String(team).trim().toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* getParkFactor — lookup by home-team code. Returns null for unknown
|
||||
* teams so the feature extractor can drop the signal entirely rather
|
||||
* than falsely report "neutral park" on a misspelled abbreviation.
|
||||
*
|
||||
* @param {string} homeTeam — three-letter team code (BAL, NYY, COL, ...)
|
||||
* @returns {{hr:number, h:number, r:number}|null}
|
||||
*/
|
||||
function getParkFactor(homeTeam) {
|
||||
const code = normalizeTeamCode(homeTeam);
|
||||
if (!code) return null;
|
||||
return PARK_FACTORS[code] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience — when you want a guaranteed object (e.g. arithmetic
|
||||
* downstream that can't handle null). Falls back to league-neutral.
|
||||
* Prefer getParkFactor + explicit-null branching in code; this is
|
||||
* for tests and downstream services that prefer 100s over null.
|
||||
*/
|
||||
function getParkFactorOrNeutral(homeTeam) {
|
||||
return getParkFactor(homeTeam) || NEUTRAL;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PARK_FACTORS,
|
||||
NEUTRAL,
|
||||
getParkFactor,
|
||||
getParkFactorOrNeutral,
|
||||
__internals: { normalizeTeamCode },
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Venue coordinates + dome flags (Session 15).
|
||||
*
|
||||
* Sources:
|
||||
* - MLB stadium coordinates: Wikipedia infoboxes, cross-checked against
|
||||
* each team's "stadium" entry on baseball-reference.com (2025).
|
||||
* - World Cup 2026 venues: official FIFA host-city announcements + the
|
||||
* static venue list already in `src/data/worldcup2026.js`.
|
||||
* - Dome flag = roof either fully closed or retractable. Retractable
|
||||
* stadiums count as dome for weather purposes (operators close the
|
||||
* roof when conditions warrant — weather doesn't drive grade).
|
||||
*
|
||||
* Lat/lon precision: 4 decimal places (~10m). Sufficient for an
|
||||
* Open-Meteo grid-cell lookup. Higher precision would be theater.
|
||||
*/
|
||||
|
||||
// ---- MLB ----
|
||||
|
||||
const MLB_VENUES = Object.freeze({
|
||||
// AL East
|
||||
BAL: { lat: 39.2839, lon: -76.6217, dome: false, name: 'Camden Yards' },
|
||||
BOS: { lat: 42.3467, lon: -71.0972, dome: false, name: 'Fenway Park' },
|
||||
NYY: { lat: 40.8296, lon: -73.9262, dome: false, name: 'Yankee Stadium' },
|
||||
TB: { lat: 27.7682, lon: -82.6534, dome: true, name: 'Tropicana Field' },
|
||||
TOR: { lat: 43.6414, lon: -79.3894, dome: true, name: 'Rogers Centre' },
|
||||
// AL Central
|
||||
CHW: { lat: 41.8300, lon: -87.6338, dome: false, name: 'Guaranteed Rate Field' },
|
||||
CLE: { lat: 41.4962, lon: -81.6852, dome: false, name: 'Progressive Field' },
|
||||
DET: { lat: 42.3390, lon: -83.0485, dome: false, name: 'Comerica Park' },
|
||||
KC: { lat: 39.0517, lon: -94.4803, dome: false, name: 'Kauffman Stadium' },
|
||||
MIN: { lat: 44.9817, lon: -93.2778, dome: false, name: 'Target Field' },
|
||||
// AL West
|
||||
HOU: { lat: 29.7572, lon: -95.3553, dome: true, name: 'Minute Maid Park' },
|
||||
LAA: { lat: 33.8003, lon: -117.8827, dome: false, name: 'Angel Stadium' },
|
||||
OAK: { lat: 37.7516, lon: -122.2005, dome: false, name: 'Oakland Coliseum' },
|
||||
SEA: { lat: 47.5914, lon: -122.3324, dome: true, name: 'T-Mobile Park' }, // retractable
|
||||
TEX: { lat: 32.7475, lon: -97.0822, dome: true, name: 'Globe Life Field' }, // retractable
|
||||
// NL East
|
||||
ATL: { lat: 33.8908, lon: -84.4678, dome: false, name: 'Truist Park' },
|
||||
MIA: { lat: 25.7781, lon: -80.2197, dome: true, name: 'loanDepot park' }, // retractable
|
||||
NYM: { lat: 40.7571, lon: -73.8458, dome: false, name: 'Citi Field' },
|
||||
PHI: { lat: 39.9061, lon: -75.1665, dome: false, name: 'Citizens Bank Park' },
|
||||
WSH: { lat: 38.8730, lon: -77.0074, dome: false, name: 'Nationals Park' },
|
||||
// NL Central
|
||||
CHC: { lat: 41.9484, lon: -87.6553, dome: false, name: 'Wrigley Field' },
|
||||
CIN: { lat: 39.0975, lon: -84.5067, dome: false, name: 'Great American Ball Park' },
|
||||
MIL: { lat: 43.0280, lon: -87.9712, dome: true, name: 'American Family Field' }, // retractable
|
||||
PIT: { lat: 40.4469, lon: -80.0058, dome: false, name: 'PNC Park' },
|
||||
STL: { lat: 38.6226, lon: -90.1928, dome: false, name: 'Busch Stadium' },
|
||||
// NL West
|
||||
ARI: { lat: 33.4453, lon: -112.0667, dome: true, name: 'Chase Field' }, // retractable
|
||||
COL: { lat: 39.7559, lon: -104.9942, dome: false, name: 'Coors Field' },
|
||||
LAD: { lat: 34.0739, lon: -118.2400, dome: false, name: 'Dodger Stadium' },
|
||||
SD: { lat: 32.7073, lon: -117.1566, dome: false, name: 'Petco Park' },
|
||||
SF: { lat: 37.7786, lon: -122.3893, dome: false, name: 'Oracle Park' },
|
||||
});
|
||||
|
||||
function normalizeCode(code) {
|
||||
if (!code) return null;
|
||||
return String(code).trim().toUpperCase();
|
||||
}
|
||||
|
||||
function getMlbVenue(teamCode) {
|
||||
const code = normalizeCode(teamCode);
|
||||
if (!code) return null;
|
||||
return MLB_VENUES[code] || null;
|
||||
}
|
||||
|
||||
// ---- World Cup 2026 ----
|
||||
// Sourced from src/data/worldcup2026.js (the canonical list of host
|
||||
// venues + altitudes). Added lat/lon + dome flag here to keep the
|
||||
// weather-fetch path independent of the i18n/altitude consumer.
|
||||
const WC_VENUES = Object.freeze({
|
||||
'MetLife Stadium': { lat: 40.8135, lon: -74.0746, dome: false },
|
||||
'AT&T Stadium': { lat: 32.7473, lon: -97.0945, dome: true }, // retractable
|
||||
'SoFi Stadium': { lat: 33.9535, lon: -118.3392, dome: true }, // partial roof
|
||||
'Hard Rock Stadium': { lat: 25.9580, lon: -80.2389, dome: false },
|
||||
'Lincoln Financial Field': { lat: 39.9008, lon: -75.1675, dome: false },
|
||||
'Lumen Field': { lat: 47.5952, lon: -122.3316, dome: false },
|
||||
'Gillette Stadium': { lat: 42.0909, lon: -71.2643, dome: false },
|
||||
'Mercedes-Benz Stadium': { lat: 33.7553, lon: -84.4006, dome: true }, // retractable
|
||||
'NRG Stadium': { lat: 29.6847, lon: -95.4107, dome: true }, // retractable
|
||||
'Arrowhead Stadium': { lat: 39.0489, lon: -94.4839, dome: false },
|
||||
'BC Place': { lat: 49.2768, lon: -123.1116, dome: true }, // retractable
|
||||
'BMO Field': { lat: 43.6332, lon: -79.4185, dome: false },
|
||||
'Estadio Azteca': { lat: 19.3030, lon: -99.1503, dome: false },
|
||||
'Estadio BBVA': { lat: 25.6692, lon: -100.2444, dome: false },
|
||||
'Estadio Akron': { lat: 20.6817, lon: -103.4625, dome: false },
|
||||
"Levi's Stadium": { lat: 37.4032, lon: -121.9698, dome: false },
|
||||
});
|
||||
|
||||
function getWcVenueCoords(name) {
|
||||
if (!name) return null;
|
||||
return WC_VENUES[name] || null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MLB_VENUES,
|
||||
WC_VENUES,
|
||||
getMlbVenue,
|
||||
getWcVenueCoords,
|
||||
__internals: { normalizeCode },
|
||||
};
|
||||
@@ -19,6 +19,54 @@
|
||||
*
|
||||
* The caller (analyzeViaEngine1) reads the returned `errors` array and
|
||||
* downgrades confidence accordingly via the adapter's reasoning string.
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────
|
||||
* 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');
|
||||
@@ -37,6 +85,15 @@ const { extractSoccerFeatures, isSoccerSport } = require('./soccerFeatureExtract
|
||||
// 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;
|
||||
|
||||
@@ -224,6 +281,58 @@ async function computeFeaturesForProp(rawProp = {}) {
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* MLB matchup context helpers (Session 15).
|
||||
*
|
||||
* Two pure functions, no I/O, no state:
|
||||
* - platoonAdvantage(pitcherHand, batterHand)
|
||||
* - projectedPA(lineupPosition)
|
||||
*
|
||||
* Sources:
|
||||
* - Platoon splits: the standard MLB convention — opposite-handed
|
||||
* matchups favor the batter (LHP vs RHB, RHP vs LHB). Switch
|
||||
* hitters get the advantage in either matchup because they bat
|
||||
* opposite-handed by definition.
|
||||
* - Lineup → projected plate appearances: derived from MLB-average
|
||||
* team PA distributions per lineup slot (2024 league composite).
|
||||
* A leadoff hitter sees ~4.7 PA in a 9-inning game; the 9-hole
|
||||
* sees ~3.5. Numbers from baseball-reference team batting tables
|
||||
* averaged across all 30 teams.
|
||||
*
|
||||
* Callers (computeFeatures MLB branch) attach the outputs to the
|
||||
* feature vector as `platoon_advantage` (bool|null) and
|
||||
* `projected_pa` (number|null). When inputs are absent or invalid
|
||||
* the helpers return null — engine1 treats null as a neutral signal
|
||||
* and reasoning omits the line gracefully.
|
||||
*/
|
||||
|
||||
const HAND_VALUES = new Set(['L', 'R', 'S']);
|
||||
|
||||
function normalizeHand(hand) {
|
||||
if (!hand) return null;
|
||||
const h = String(hand).trim().toUpperCase().charAt(0);
|
||||
return HAND_VALUES.has(h) ? h : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* platoonAdvantage — true when the batter has the platoon edge.
|
||||
*
|
||||
* LHP vs RHB → true (right-handed batter sees pitches better)
|
||||
* LHP vs LHB → false
|
||||
* RHP vs RHB → false
|
||||
* RHP vs LHB → true
|
||||
* any vs Switch hitter → true (switch hits opposite of pitcher)
|
||||
*
|
||||
* Returns null when either hand is unknown. The grading engine reads
|
||||
* `null` as "no signal" — same treatment as `false` for confidence
|
||||
* arithmetic, but reasoning will skip the line.
|
||||
*/
|
||||
function platoonAdvantage(pitcherHand, batterHand) {
|
||||
const p = normalizeHand(pitcherHand);
|
||||
const b = normalizeHand(batterHand);
|
||||
if (!p || !b) return null;
|
||||
if (b === 'S') return true; // switch hitter — always has edge
|
||||
if (p === b) return false; // same-handed → pitcher edge
|
||||
return true; // opposite-handed → batter edge
|
||||
}
|
||||
|
||||
// League-average plate appearances per lineup slot in 9-inning games.
|
||||
// Source: 2024 MLB composite (baseball-reference team batting tables).
|
||||
// Slot 1 (leadoff) leads the team; later slots see fewer PAs because
|
||||
// they may not bat in the bottom of innings already wrapped up.
|
||||
const PA_BY_SLOT = Object.freeze({
|
||||
1: 4.70,
|
||||
2: 4.55,
|
||||
3: 4.43,
|
||||
4: 4.31,
|
||||
5: 4.19,
|
||||
6: 4.07,
|
||||
7: 3.95,
|
||||
8: 3.83,
|
||||
9: 3.71,
|
||||
});
|
||||
|
||||
/**
|
||||
* projectedPA — expected plate appearances by lineup position.
|
||||
*
|
||||
* @param {number|string} position 1..9
|
||||
* @returns {number|null}
|
||||
*
|
||||
* Out-of-range or invalid → null. This keeps reasoning honest when
|
||||
* the odds payload doesn't carry batting order yet (the more common
|
||||
* case today — odds-api doesn't expose lineup position in the prop
|
||||
* envelope).
|
||||
*/
|
||||
function projectedPA(position) {
|
||||
if (position == null) return null;
|
||||
const p = Number(position);
|
||||
if (!Number.isInteger(p) || p < 1 || p > 9) return null;
|
||||
return PA_BY_SLOT[p];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
platoonAdvantage,
|
||||
projectedPA,
|
||||
__internals: { normalizeHand, HAND_VALUES, PA_BY_SLOT },
|
||||
};
|
||||
@@ -7,15 +7,18 @@ function getStripe() {
|
||||
return _stripe;
|
||||
}
|
||||
|
||||
// Session 15 — fallback strings like 'price_analyst_monthly' would
|
||||
// 400 from Stripe in production (they're not real `price_xxx` IDs).
|
||||
// All maps now fall back to null; getPriceId then returns the
|
||||
// PRICE_UNCONFIGURED sentinel for unset values. Founder prices
|
||||
// additionally fall back to the standard tier price so a user with
|
||||
// a valid founder code on a deploy that doesn't yet have founder
|
||||
// prices wired still gets a successful checkout at standard rate.
|
||||
const PRICE_MAP = {
|
||||
analyst: process.env.STRIPE_PRICE_ANALYST || 'price_analyst_monthly',
|
||||
analyst_founder: process.env.STRIPE_PRICE_ANALYST_FOUNDER || 'price_analyst_founder',
|
||||
desk: process.env.STRIPE_PRICE_DESK || 'price_desk_monthly',
|
||||
desk_founder: process.env.STRIPE_PRICE_DESK_FOUNDER || 'price_desk_founder',
|
||||
// Session 14 — Africa tier ($4.99/mo). The Stripe product must be
|
||||
// created in the dashboard before STRIPE_PRICE_AFRICA carries a real
|
||||
// ID. Until then `getPriceId('africa')` returns a sentinel that
|
||||
// surfaces a clean error to the user via the route handler.
|
||||
analyst: process.env.STRIPE_PRICE_ANALYST || null,
|
||||
analyst_founder: process.env.STRIPE_PRICE_ANALYST_FOUNDER || null,
|
||||
desk: process.env.STRIPE_PRICE_DESK || null,
|
||||
desk_founder: process.env.STRIPE_PRICE_DESK_FOUNDER || null,
|
||||
africa: process.env.STRIPE_PRICE_AFRICA || null,
|
||||
};
|
||||
|
||||
@@ -38,13 +41,21 @@ function isFounderCodeValid(code) {
|
||||
|
||||
function getPriceId(tier, founderCode) {
|
||||
const isFounder = isFounderCodeValid(founderCode);
|
||||
if (tier === 'analyst') return isFounder ? PRICE_MAP.analyst_founder : PRICE_MAP.analyst;
|
||||
if (tier === 'desk') return isFounder ? PRICE_MAP.desk_founder : PRICE_MAP.desk;
|
||||
if (tier === 'analyst') {
|
||||
// Session 15 — if a valid founder code is presented but the
|
||||
// founder price ID isn't wired (env unset), gracefully fall
|
||||
// back to the standard analyst price rather than 503'ing the
|
||||
// user. The founder discount is operator-controlled; the
|
||||
// checkout itself shouldn't break.
|
||||
if (isFounder && PRICE_MAP.analyst_founder) return PRICE_MAP.analyst_founder;
|
||||
return PRICE_MAP.analyst || PRICE_UNCONFIGURED;
|
||||
}
|
||||
if (tier === 'desk') {
|
||||
if (isFounder && PRICE_MAP.desk_founder) return PRICE_MAP.desk_founder;
|
||||
return PRICE_MAP.desk || PRICE_UNCONFIGURED;
|
||||
}
|
||||
if (tier === 'africa') {
|
||||
// Africa tier doesn't have a founder discount — it IS the
|
||||
// discount. Returns the sentinel when STRIPE_PRICE_AFRICA is
|
||||
// unset so the route handler can produce a clean error instead
|
||||
// of forwarding a null price ID to Stripe.
|
||||
// Africa tier has no founder discount — it IS the discount.
|
||||
return PRICE_MAP.africa || PRICE_UNCONFIGURED;
|
||||
}
|
||||
throw new Error(`Invalid tier: ${tier}`);
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Weather service (Session 15).
|
||||
*
|
||||
* Open-Meteo proxy. No API key required (the service is free for
|
||||
* non-commercial use and sportsbook analytics is firmly non-
|
||||
* commercial intelligence). 5s hard timeout, 1h Redis cache,
|
||||
* graceful degrade (returns null on any failure — never throws,
|
||||
* never blocks the grade).
|
||||
*
|
||||
* Outputs are normalized to the units North-American bettors think
|
||||
* in: temperature in Fahrenheit, wind in mph. Open-Meteo's defaults
|
||||
* are Celsius + km/h, so we request the imperial units directly via
|
||||
* query params.
|
||||
*
|
||||
* Cache key: `weather:{lat}:{lon}:{hour}` — keyed by the current
|
||||
* UTC hour so two requests within the same hour hit cache. Slightly
|
||||
* less precise than a sliding TTL but matches Open-Meteo's hourly
|
||||
* forecast cadence and keeps cache churn bounded.
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { cacheGet, cacheSet } = require('../utils/redis');
|
||||
|
||||
const BASE_URL = 'https://api.open-meteo.com/v1/forecast';
|
||||
const HTTP_TIMEOUT_MS = 5_000;
|
||||
const CACHE_TTL_SEC = 3600; // 1h — Open-Meteo refreshes hourly
|
||||
|
||||
function currentHourBucket() {
|
||||
const d = new Date();
|
||||
return `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, '0')}${String(d.getUTCDate()).padStart(2, '0')}${String(d.getUTCHours()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function buildKey(lat, lon) {
|
||||
// 2-decimal precision is enough for a city-scale lookup and
|
||||
// collapses neighboring venues onto the same cache key (no real-
|
||||
// world impact — they share the same weather).
|
||||
const latKey = Number(lat).toFixed(2);
|
||||
const lonKey = Number(lon).toFixed(2);
|
||||
return `weather:${latKey}:${lonKey}:${currentHourBucket()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* getWeather — fetch current weather conditions for a lat/lon.
|
||||
*
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
* @returns {Promise<{temp_f:number|null, wind_mph:number|null, wind_dir:number|null, precip_mm:number|null} | null>}
|
||||
*
|
||||
* Returns null on:
|
||||
* - invalid coordinates
|
||||
* - upstream timeout / 5xx
|
||||
* - missing fields in the response
|
||||
*
|
||||
* The grading engine and reasoning builder both treat null as "no
|
||||
* signal" — features are simply omitted from the prop's overlay.
|
||||
*/
|
||||
async function getWeather(lat, lon) {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
|
||||
const cacheKey = buildKey(lat, lon);
|
||||
try {
|
||||
const cached = await cacheGet(cacheKey);
|
||||
if (cached !== null) return cached;
|
||||
} catch {
|
||||
// Redis hiccup — proceed to network.
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await axios.get(BASE_URL, {
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
params: {
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
current: 'temperature_2m,wind_speed_10m,wind_direction_10m,precipitation',
|
||||
temperature_unit: 'fahrenheit',
|
||||
wind_speed_unit: 'mph',
|
||||
precipitation_unit: 'mm', // mm is the universal precipitation unit
|
||||
},
|
||||
});
|
||||
const current = res.data?.current;
|
||||
if (!current) return null;
|
||||
const out = {
|
||||
temp_f: Number.isFinite(current.temperature_2m) ? current.temperature_2m : null,
|
||||
wind_mph: Number.isFinite(current.wind_speed_10m) ? current.wind_speed_10m : null,
|
||||
wind_dir: Number.isFinite(current.wind_direction_10m) ? current.wind_direction_10m : null,
|
||||
precip_mm: Number.isFinite(current.precipitation) ? current.precipitation : null,
|
||||
_fetched_at: new Date().toISOString(),
|
||||
};
|
||||
try { await cacheSet(cacheKey, out, CACHE_TTL_SEC); } catch { /* graceful */ }
|
||||
return out;
|
||||
} catch (err) {
|
||||
// Open-Meteo down, timeout, 5xx — silently degrade.
|
||||
if (err && err.code !== 'ECONNABORTED') {
|
||||
console.warn('[weatherService] fetch failed:', err.message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getWeather,
|
||||
__internals: { BASE_URL, HTTP_TIMEOUT_MS, CACHE_TTL_SEC, buildKey, currentHourBucket },
|
||||
};
|
||||
Reference in New Issue
Block a user