Session 16: Live hero prop, sport-specific markets fix, soccer weather, Sentry CSP (1429 tests)
This commit is contained in:
@@ -27,6 +27,12 @@
|
||||
const { cacheGet } = require('../../utils/redis');
|
||||
const { normalizeName } = require('../../utils/normalize');
|
||||
const wc = require('../../data/worldcup2026');
|
||||
// Session 16 — World Cup venue weather. Open-Meteo lookup is cached
|
||||
// 1h, has a 5s timeout, and degrades silently on failure. Dome
|
||||
// venues skip the fetch entirely (operators close the roof when
|
||||
// conditions warrant — weather doesn't drive grade in that case).
|
||||
const { getWcVenueCoords } = require('../../data/venueCoordinates');
|
||||
const weatherService = require('../weatherService');
|
||||
|
||||
const SOCCER_SPORTS = new Set(['soccer', 'football']);
|
||||
|
||||
@@ -183,6 +189,22 @@ async function extractSoccerFeatures(input = {}) {
|
||||
const homeContinent = wc.isHomeContinent(team);
|
||||
const altImpact = wc.altitudeImpact(altitudeFt);
|
||||
|
||||
// Session 16 — weather for outdoor World Cup venues. The venue
|
||||
// coords file is keyed by venue name; dome venues (BC Place,
|
||||
// AT&T, etc.) are skipped via the `dome` flag rather than the
|
||||
// network call so we don't burn the 5s timeout on stadiums that
|
||||
// will close the roof anyway.
|
||||
let weather = null;
|
||||
const coords = venueName ? getWcVenueCoords(venueName) : null;
|
||||
if (coords && !coords.dome && Number.isFinite(coords.lat) && Number.isFinite(coords.lon)) {
|
||||
try {
|
||||
weather = await weatherService.getWeather(coords.lat, coords.lon);
|
||||
} catch (err) {
|
||||
// Best-effort — never block the grade on the weather fetch.
|
||||
console.warn('[soccerFeatureExtractor] weather skipped:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Set-piece + penalty roles (static data — no async).
|
||||
const isPK = wc.isPenaltyTaker(player, team);
|
||||
const isCorner = wc.isCornerTaker(player, team);
|
||||
@@ -221,6 +243,13 @@ async function extractSoccerFeatures(input = {}) {
|
||||
venue_altitude_ft: altitudeFt,
|
||||
altitude_impact: altImpact,
|
||||
climate,
|
||||
// Session 16 — weather. Null when venue is a dome / not in the
|
||||
// WC venue index / Open-Meteo fetch failed. Trap detection +
|
||||
// reasoning surface these signals when present.
|
||||
weather_temp_f: weather?.temp_f ?? null,
|
||||
weather_wind_mph: weather?.wind_mph ?? null,
|
||||
weather_wind_dir: weather?.wind_dir ?? null,
|
||||
weather_precip_mm: weather?.precip_mm ?? null,
|
||||
opp_goals_conceded_per_game: oppDefense?.goals_conceded_per_game ?? null,
|
||||
opp_clean_sheet_rate: oppDefense?.clean_sheet_rate ?? null,
|
||||
opp_defensive_rank: oppDefense?.defensive_rank ?? null,
|
||||
|
||||
@@ -32,7 +32,89 @@ const SPORT_KEYS = {
|
||||
const SOCCER_SPORT_KEYS = Object.freeze(
|
||||
Object.keys(SPORT_KEYS).filter((k) => k.startsWith('soccer_'))
|
||||
);
|
||||
// Session 16 — per-sport market lists.
|
||||
//
|
||||
// The old `ALL_MARKETS = every key in MARKET_MAP` would send
|
||||
// soccer markets (player_goals, player_shots_on_target, etc.) to
|
||||
// basketball + baseball endpoints, triggering odds-api 422 errors.
|
||||
// Production briefly worked around this with a runtime axios
|
||||
// interceptor injected via `NODE_OPTIONS=--require /app/data/patch.js`;
|
||||
// the proper fix is to scope the markets list to the sport before
|
||||
// the request leaves the process.
|
||||
//
|
||||
// After this lands, the operator can drop the NODE_OPTIONS env var
|
||||
// from Coolify and delete /app/data/patch.js.
|
||||
const NBA_MARKETS = [
|
||||
'player_points',
|
||||
'player_rebounds',
|
||||
'player_assists',
|
||||
'player_threes',
|
||||
'player_blocks',
|
||||
'player_steals',
|
||||
'player_turnovers',
|
||||
'player_points_rebounds_assists',
|
||||
];
|
||||
const WNBA_MARKETS = [
|
||||
'player_points',
|
||||
'player_rebounds',
|
||||
'player_assists',
|
||||
'player_threes',
|
||||
'player_blocks',
|
||||
'player_steals',
|
||||
'player_turnovers',
|
||||
];
|
||||
const MLB_MARKETS = [
|
||||
'batter_home_runs',
|
||||
'batter_hits',
|
||||
'batter_total_bases',
|
||||
'batter_rbis',
|
||||
'batter_runs_scored',
|
||||
'batter_stolen_bases',
|
||||
'pitcher_strikeouts',
|
||||
'pitcher_outs',
|
||||
];
|
||||
const SOCCER_MARKETS = [
|
||||
'player_goals',
|
||||
'player_shots_on_target',
|
||||
'player_shots',
|
||||
'player_tackles',
|
||||
'player_cards',
|
||||
'player_corners',
|
||||
'player_saves',
|
||||
'player_goals_conceded',
|
||||
'player_passes',
|
||||
'team_clean_sheet',
|
||||
];
|
||||
|
||||
function buildMarketString(markets) {
|
||||
return [...markets, 'spreads'].join(',');
|
||||
}
|
||||
|
||||
// Indexed by the local sport key (the keys in SPORT_KEYS, not the
|
||||
// odds-api keys). Soccer leagues all share the same market list.
|
||||
const SPORT_MARKETS = Object.freeze({
|
||||
nba: buildMarketString(NBA_MARKETS),
|
||||
wnba: buildMarketString(WNBA_MARKETS),
|
||||
mlb: buildMarketString(MLB_MARKETS),
|
||||
ncaab: buildMarketString(NBA_MARKETS), // NCAAB markets mirror NBA
|
||||
// Every soccer league code shares the same market set.
|
||||
...Object.fromEntries(
|
||||
Object.keys(SPORT_KEYS)
|
||||
.filter((k) => k.startsWith('soccer_'))
|
||||
.map((k) => [k, buildMarketString(SOCCER_MARKETS)]),
|
||||
),
|
||||
});
|
||||
|
||||
// Kept for backward-compat with any caller that still imports it,
|
||||
// but the call site (`fetchEventOddsFromApi`) now uses the sport-
|
||||
// specific lookup. Composed once on module load.
|
||||
const ALL_MARKETS = Object.keys(MARKET_MAP).join(',') + ',spreads';
|
||||
|
||||
function getMarketsForSport(sport) {
|
||||
if (!sport) return SPORT_MARKETS.nba; // safe default (basketball)
|
||||
return SPORT_MARKETS[sport] || SPORT_MARKETS.nba;
|
||||
}
|
||||
|
||||
const BOOKMAKERS = 'draftkings,fanduel,betmgm,caesars,fanatics,bet365,hardrockbet,pointsbet,betrivers';
|
||||
|
||||
function getCacheKey(sport) {
|
||||
@@ -75,13 +157,17 @@ async function fetchEventsFromApi(sportKey, apiKey) {
|
||||
return { data: response.data, headers: response.headers };
|
||||
}
|
||||
|
||||
async function fetchEventOddsFromApi(sportKey, eventId, apiKey) {
|
||||
// Session 16 — third arg is now a local sport key (nba, mlb,
|
||||
// soccer_wc, ...) so we can scope the markets list. Backwards-
|
||||
// compatible: if `sport` is omitted, falls back to the basketball
|
||||
// market set, which is what every legacy caller assumed.
|
||||
async function fetchEventOddsFromApi(sportKey, eventId, apiKey, sport) {
|
||||
const url = `${ODDS_API_BASE}/${sportKey}/events/${eventId}/odds`;
|
||||
const response = await axios.get(url, {
|
||||
params: {
|
||||
apiKey,
|
||||
regions: 'us',
|
||||
markets: ALL_MARKETS,
|
||||
markets: getMarketsForSport(sport),
|
||||
bookmakers: BOOKMAKERS,
|
||||
oddsFormat: 'american',
|
||||
},
|
||||
@@ -112,7 +198,7 @@ async function fetchAllOdds(sport, apiKey) {
|
||||
break;
|
||||
}
|
||||
|
||||
const oddsResult = await fetchEventOddsFromApi(sportKey, event.id, apiKey);
|
||||
const oddsResult = await fetchEventOddsFromApi(sportKey, event.id, apiKey, sport);
|
||||
eventsWithOdds.push(oddsResult.data);
|
||||
lastHeaders = oddsResult.headers;
|
||||
}
|
||||
@@ -230,6 +316,9 @@ module.exports = {
|
||||
getCacheKey,
|
||||
SPORT_KEYS,
|
||||
SOCCER_SPORT_KEYS,
|
||||
// Session 16 — per-sport market scoping.
|
||||
SPORT_MARKETS,
|
||||
getMarketsForSport,
|
||||
getQuotaKey,
|
||||
updateQuota,
|
||||
getQuotaRemaining,
|
||||
|
||||
Reference in New Issue
Block a user