Session 7j: Soccer intelligence - 9 leagues, 11 signals, 6 traps, poller, prefetch, 131 new tests (1173 total)

This commit is contained in:
Kev
2026-06-10 14:50:13 -04:00
parent b9084408bf
commit ad5ea8d5a8
28 changed files with 3175 additions and 49 deletions
+236
View File
@@ -0,0 +1,236 @@
/**
* FIFA World Cup 2026 reference data — June 11July 19, 2026.
*
* Static, hand-curated. The poller pulls fixtures/standings/squads from
* APIs (football-data.org + worldcup2026 OSS); this file holds the
* intelligence the APIs don't carry: venue altitudes, host-continent
* teams, designated penalty/set-piece takers, and historical tournament
* performers.
*
* Updated manually as the tournament progresses (squad confirmations,
* penalty-taker shifts, etc.). Not consumed during normal cache misses —
* the feature extractor reads this once per process load.
*
* Venue altitudes sourced from public elevation data (NOAA / Google
* Earth, ft above sea level). Estadio Akron is in Zapopan (Guadalajara
* metro) at ~5,100 ft; Estadio Azteca's stadium floor sits at ~7,349 ft
* — the highest altitude in tournament history for the host venue.
*/
const VENUES = Object.freeze({
// United States (11)
'AT&T Stadium': { city: 'Arlington, TX', altitude_ft: 600, climate: 'hot', country: 'USA' },
'Mercedes-Benz Stadium': { city: 'Atlanta, GA', altitude_ft: 1050, climate: 'hot_humid', country: 'USA' },
'Gillette Stadium': { city: 'Foxborough, MA', altitude_ft: 260, climate: 'temperate', country: 'USA' },
'NRG Stadium': { city: 'Houston, TX', altitude_ft: 80, climate: 'hot_humid', country: 'USA' },
'Arrowhead Stadium': { city: 'Kansas City, MO', altitude_ft: 820, climate: 'temperate', country: 'USA' },
'SoFi Stadium': { city: 'Inglewood, CA', altitude_ft: 100, climate: 'temperate', country: 'USA' },
'Hard Rock Stadium': { city: 'Miami Gardens, FL', altitude_ft: 10, climate: 'hot_humid', country: 'USA' },
'MetLife Stadium': { city: 'East Rutherford, NJ', altitude_ft: 7, climate: 'temperate', country: 'USA' },
'Lincoln Financial Field': { city: 'Philadelphia, PA', altitude_ft: 30, climate: 'temperate', country: 'USA' },
"Levi's Stadium": { city: 'Santa Clara, CA', altitude_ft: 33, climate: 'temperate', country: 'USA' },
'Lumen Field': { city: 'Seattle, WA', altitude_ft: 15, climate: 'cool', country: 'USA' },
// Canada (2)
'BMO Field': { city: 'Toronto, ON', altitude_ft: 250, climate: 'temperate', country: 'Canada' },
'BC Place': { city: 'Vancouver, BC', altitude_ft: 33, climate: 'cool', country: 'Canada' },
// Mexico (3)
'Estadio Azteca': { city: 'Mexico City', altitude_ft: 7349, climate: 'altitude', country: 'Mexico' },
'Estadio BBVA': { city: 'Monterrey', altitude_ft: 1765, climate: 'hot', country: 'Mexico' },
'Estadio Akron': { city: 'Guadalajara (Zapopan)', altitude_ft: 5138, climate: 'altitude', country: 'Mexico' },
});
// Host-continent teams (CONCACAF) — historically benefit from reduced
// travel, fan support, and altitude acclimation. The 2026 hosts (USA,
// Canada, Mexico) auto-qualified; the rest qualified through CONCACAF.
const CONCACAF_TEAMS = Object.freeze([
'USA', 'Canada', 'Mexico', 'Costa Rica', 'Jamaica', 'Honduras',
'Panama', 'El Salvador', 'Haiti', 'Trinidad and Tobago',
'Guatemala', 'Curacao', 'Suriname',
]);
// CONMEBOL teams travel less than European/African squads and are
// historically strong in 2026-style climates. Used as a softer secondary
// modifier (not full home-continent advantage).
const CONMEBOL_TEAMS = Object.freeze([
'Argentina', 'Brazil', 'Uruguay', 'Colombia', 'Ecuador', 'Paraguay',
'Peru', 'Chile', 'Venezuela', 'Bolivia',
]);
// Designated penalty takers — primary plus secondary fallback if primary
// is off the pitch. Sourced from each team's most recent qualifier or
// pre-tournament friendly; updated as confirmations come in.
//
// Penalty-taker status adds ~0.15 goals per 90 to the player's base rate.
// Keys are team names matching football-data.org's `team.name` field;
// values are arrays in preference order.
const PENALTY_TAKERS = Object.freeze({
'Argentina': ['Lionel Messi', 'Lautaro Martínez'],
'Brazil': ['Vinicius Junior', 'Neymar'],
'France': ['Kylian Mbappé', 'Antoine Griezmann'],
'England': ['Harry Kane', 'Bukayo Saka'],
'Portugal': ['Cristiano Ronaldo', 'Bruno Fernandes'],
'Spain': ['Álvaro Morata', 'Mikel Merino'],
'Germany': ['Kai Havertz', 'İlkay Gündoğan'],
'Netherlands': ['Memphis Depay', 'Cody Gakpo'],
'Belgium': ['Romelu Lukaku', 'Kevin De Bruyne'],
'Italy': ['Jorginho', 'Lorenzo Pellegrini'],
'Croatia': ['Luka Modrić', 'Andrej Kramarić'],
'Uruguay': ['Darwin Núñez', 'Federico Valverde'],
'Colombia': ['James Rodríguez', 'Luis Díaz'],
'Mexico': ['Raúl Jiménez', 'Hirving Lozano'],
'USA': ['Christian Pulisic', 'Folarin Balogun'],
'Canada': ['Jonathan David', 'Alphonso Davies'],
'Morocco': ['Hakim Ziyech', 'Achraf Hakimi'],
'Senegal': ['Sadio Mané', 'Ismaïla Sarr'],
'Japan': ['Takefusa Kubo', 'Wataru Endō'],
'South Korea': ['Son Heung-min', 'Hwang Hee-chan'],
'Australia': ['Jackson Irvine', 'Mitchell Duke'],
'Switzerland': ['Granit Xhaka', 'Xherdan Shaqiri'],
'Poland': ['Robert Lewandowski', 'Piotr Zieliński'],
'Denmark': ['Christian Eriksen', 'Pierre-Emile Højbjerg'],
'Serbia': ['Aleksandar Mitrović', 'Dušan Vlahović'],
});
// Designated corner takers (set-piece delivery role). Multi-name arrays
// reflect rotation; the first is the most common deliverer. Corner-taker
// status meaningfully boosts assist probability for headed goals.
const CORNER_TAKERS = Object.freeze({
'Argentina': ['Lionel Messi', 'Ángel Di María'],
'Brazil': ['Lucas Paquetá', 'Bruno Guimarães'],
'France': ['Antoine Griezmann', 'Kylian Mbappé'],
'England': ['Bukayo Saka', 'Phil Foden', 'Trent Alexander-Arnold'],
'Portugal': ['Bruno Fernandes', 'João Cancelo'],
'Spain': ['Dani Olmo', 'Mikel Merino'],
'Germany': ['Joshua Kimmich', 'Toni Kroos'],
'Netherlands': ['Frenkie de Jong', 'Cody Gakpo'],
'Belgium': ['Kevin De Bruyne', 'Yannick Carrasco'],
'Italy': ['Lorenzo Pellegrini', 'Federico Chiesa'],
'Croatia': ['Luka Modrić', 'Mateo Kovačić'],
'Uruguay': ['Federico Valverde', 'Giorgian de Arrascaeta'],
'Mexico': ['Andrés Guardado', 'Hirving Lozano'],
'USA': ['Christian Pulisic', 'Gio Reyna'],
'Canada': ['Stephen Eustáquio', 'Tajon Buchanan'],
});
// Direct free-kick specialists. These players take long-range and
// dangerous-area free kicks. Boosts both goal AND assist probability
// when a foul is drawn in shooting range.
const FREE_KICK_TAKERS = Object.freeze({
'Argentina': ['Lionel Messi'],
'Brazil': ['Neymar', 'Vinicius Junior'],
'France': ['Kylian Mbappé'],
'England': ['Trent Alexander-Arnold', 'Bukayo Saka'],
'Portugal': ['Cristiano Ronaldo', 'Bruno Fernandes'],
'Spain': ['Dani Olmo'],
'Germany': ['Joshua Kimmich'],
'Belgium': ['Kevin De Bruyne'],
'Italy': ['Federico Chiesa'],
'Croatia': ['Luka Modrić'],
'Colombia': ['James Rodríguez'],
'Mexico': ['Raúl Jiménez'],
'USA': ['Christian Pulisic'],
'Morocco': ['Hakim Ziyech'],
'South Korea': ['Son Heung-min'],
'Poland': ['Piotr Zieliński'],
'Serbia': ['Dušan Tadić'],
});
// "Tournament players" — historical World Cup performers with three or
// more career WC goals. These names lift the prior on big-game scoring.
// Threshold: >=3 career WC goals OR >=2 in the most recent WC.
const TOURNAMENT_PLAYERS = Object.freeze({
'Lionel Messi': { wc_goals_career: 13, wc_appearances: 26 },
'Cristiano Ronaldo': { wc_goals_career: 8, wc_appearances: 22 },
'Kylian Mbappé': { wc_goals_career: 12, wc_appearances: 14 },
'Harry Kane': { wc_goals_career: 8, wc_appearances: 11 },
'Neymar': { wc_goals_career: 8, wc_appearances: 16 },
'Olivier Giroud': { wc_goals_career: 5, wc_appearances: 18 },
'Antoine Griezmann': { wc_goals_career: 6, wc_appearances: 17 },
'Romelu Lukaku': { wc_goals_career: 5, wc_appearances: 11 },
'Luka Modrić': { wc_goals_career: 2, wc_appearances: 18 }, // captain bias
'Robert Lewandowski': { wc_goals_career: 2, wc_appearances: 8 },
'Karim Benzema': { wc_goals_career: 3, wc_appearances: 11 },
'Edinson Cavani': { wc_goals_career: 4, wc_appearances: 14 },
'Luis Suárez': { wc_goals_career: 7, wc_appearances: 14 },
'Andrés Guardado': { wc_goals_career: 1, wc_appearances: 20 }, // captain bias
'Thomas Müller': { wc_goals_career: 10, wc_appearances: 16 },
'Eden Hazard': { wc_goals_career: 3, wc_appearances: 11 },
'Hirving Lozano': { wc_goals_career: 2, wc_appearances: 7 },
'Sadio Mané': { wc_goals_career: 1, wc_appearances: 5 },
});
// Lookup helpers — case-insensitive on player names, exact-match on
// team names. Each returns a primitive so the feature extractor can
// drop the result straight into the feature vector.
function isPenaltyTaker(playerName, teamName) {
if (!playerName || !teamName) return false;
const takers = PENALTY_TAKERS[teamName];
if (!Array.isArray(takers)) return false;
const p = String(playerName).toLowerCase();
return takers.some((t) => t.toLowerCase() === p);
}
function isCornerTaker(playerName, teamName) {
if (!playerName || !teamName) return false;
const takers = CORNER_TAKERS[teamName];
if (!Array.isArray(takers)) return false;
const p = String(playerName).toLowerCase();
return takers.some((t) => t.toLowerCase() === p);
}
function isFreeKickTaker(playerName, teamName) {
if (!playerName || !teamName) return false;
const takers = FREE_KICK_TAKERS[teamName];
if (!Array.isArray(takers)) return false;
const p = String(playerName).toLowerCase();
return takers.some((t) => t.toLowerCase() === p);
}
function getTournamentHistory(playerName) {
if (!playerName) return null;
// Exact match first, then case-insensitive scan.
if (TOURNAMENT_PLAYERS[playerName]) return TOURNAMENT_PLAYERS[playerName];
const p = String(playerName).toLowerCase();
for (const [name, history] of Object.entries(TOURNAMENT_PLAYERS)) {
if (name.toLowerCase() === p) return history;
}
return null;
}
function isHomeContinent(teamName) {
if (!teamName) return false;
return CONCACAF_TEAMS.includes(teamName);
}
function getVenue(venueName) {
if (!venueName) return null;
return VENUES[venueName] || null;
}
// Classify altitude impact for non-acclimatized teams. The historical
// goal-output reduction kicks in around 1,500 ft and gets material above
// 4,000 ft (per CSIC studies on player physiology at altitude).
function altitudeImpact(altitudeFt) {
if (!Number.isFinite(altitudeFt)) return 'none';
if (altitudeFt >= 4000) return 'high';
if (altitudeFt >= 1500) return 'moderate';
return 'none';
}
module.exports = {
VENUES,
CONCACAF_TEAMS,
CONMEBOL_TEAMS,
PENALTY_TAKERS,
CORNER_TAKERS,
FREE_KICK_TAKERS,
TOURNAMENT_PLAYERS,
isPenaltyTaker,
isCornerTaker,
isFreeKickTaker,
getTournamentHistory,
isHomeContinent,
getVenue,
altitudeImpact,
};
+5
View File
@@ -48,8 +48,13 @@ async function cachedAnalyze(prop) {
}
const VALID_STAT_TYPES = new Set([
// NBA / WNBA
'points', 'rebounds', 'assists', 'threes', 'blocks',
'steals', 'pra', 'turnovers',
// Soccer (Session 7j — assists already covered above; sport field
// discriminates downstream).
'goals', 'shots_on_target', 'shots', 'tackles', 'cards',
'corners', 'saves', 'goals_conceded', 'passes', 'clean_sheet',
]);
const VALID_DIRECTIONS = new Set(['over', 'under']);
+38
View File
@@ -147,4 +147,42 @@ router.get('/ncaab', async (req, res) => {
}
});
// Session 7j — soccer odds route. League is a path segment so each
// league has its own cache key (`odds:soccer_wc:2026-06-15` etc.) and
// queries don't cross-pollute. Falls through to getOdds → odds-api on
// demand; cached 15min like every other sport.
const { SOCCER_SPORT_KEYS } = require('../services/oddsService');
const SOCCER_KEY_SET = new Set(SOCCER_SPORT_KEYS);
router.get('/soccer/:league', async (req, res) => {
const leagueKey = `soccer_${String(req.params.league || '').toLowerCase()}`;
if (!SOCCER_KEY_SET.has(leagueKey)) {
return res.status(400).json({
error: `Unknown soccer league. Valid: ${SOCCER_SPORT_KEYS.map((k) => k.replace('soccer_', '')).join(', ')}.`,
});
}
const errors = validateQueryParams(req.query);
if (errors.length > 0) {
return res.status(400).json({ error: errors.join('; ') });
}
try {
const result = await getOdds(leagueKey);
const filtered = filterProps(result.props || [], req.query);
const props = groupProps(filtered);
if (result.stale) res.set('X-VYNDR-Stale', 'true');
return res.json({
sport: leagueKey,
updated_at: result.updated_at,
source: result.source,
quota_remaining: result.quota_remaining,
props,
});
} catch (err) {
const status = err.statusCode || 500;
return res.status(status).json({ error: err.message });
}
});
module.exports = router;
+4
View File
@@ -6,8 +6,12 @@ const { scanParlay } = require('../services/parlayScanService');
const router = express.Router();
const VALID_STAT_TYPES = new Set([
// NBA / WNBA
'points', 'rebounds', 'assists', 'threes', 'blocks',
'steals', 'pra', 'turnovers',
// Soccer (Session 7j)
'goals', 'shots_on_target', 'shots', 'tackles', 'cards',
'corners', 'saves', 'goals_conceded', 'passes', 'clean_sheet',
]);
const VALID_DIRECTIONS = new Set(['over', 'under']);
@@ -0,0 +1,232 @@
/**
* football-data.org adapter.
*
* Free tier:
* - 10 requests per minute (HARD rate limit on the API side — 429 on overflow)
* - Fixtures, standings, squads, scorers only (NO per-player game stats)
* - Requires `FOOTBALL_DATA_API_KEY` env var
*
* Design:
* - All responses cached in Redis with tier-appropriate TTLs.
* - Built-in token bucket holds calls at 8 req/min (2-req safety margin).
* - When the bucket is empty, stale-while-revalidate returns whatever
* is in Redis even if the TTL has lapsed — better to serve old data
* than to crash the request path.
* - When the API key is missing, every method returns null without
* touching the network. Callers (feature extractor, poller) treat
* null as "no data available — degrade gracefully".
* - All errors are caught and logged, never thrown. Same contract as
* the existing intelligence services.
*
* Endpoints exposed:
* getWorldCupFixtures(),
* getWorldCupStandings(),
* getWorldCupScorers(),
* getTeamSquad(teamId),
* getLeagueFixtures(competitionCode), // generic — EPL/PD/BL1/...
* getLeagueStandings(competitionCode),
* getLeagueScorers(competitionCode).
*
* Competition codes: WC (World Cup), PL (Premier League),
* PD (La Liga), BL1 (Bundesliga), SA (Serie A), FL1 (Ligue 1),
* CL (Champions League), MLS (MLS), LIGA (Liga MX).
*/
const axios = require('axios');
const { cacheGet, cacheSet } = require('../../utils/redis');
const BASE_URL = 'https://api.football-data.org/v4';
const HTTP_TIMEOUT_MS = 8_000;
// Cache TTLs (seconds) — tier matched to data volatility.
const TTL = Object.freeze({
fixtures: 6 * 3600, // 6h — drifts as match status changes
standings: 12 * 3600, // 12h — moves once per matchday at most
squad: 24 * 3600, // 24h — only changes between matchdays
scorers: 6 * 3600, // 6h — moves only on goal events
});
// Token bucket — refills 8 tokens per 60-second window. We hold 2 tokens
// below the 10 req/min ceiling so a burst from the poller can't 429 the
// adapter on the user request path.
const BUCKET_MAX = 8;
const BUCKET_REFILL_MS = 60_000;
let _tokens = BUCKET_MAX;
let _lastRefill = 0;
function nowMs() {
// jest.fakeTimers compatible — process.uptime is monotonic.
return Math.floor(process.uptime() * 1000);
}
function refillBucket() {
const now = nowMs();
if (_lastRefill === 0) _lastRefill = now;
const elapsed = now - _lastRefill;
if (elapsed >= BUCKET_REFILL_MS) {
// Full refill on window boundary — simpler than fractional refills,
// and matches how the API's own per-minute window resets.
_tokens = BUCKET_MAX;
_lastRefill = now;
}
}
function tryConsumeToken() {
refillBucket();
if (_tokens <= 0) return false;
_tokens -= 1;
return true;
}
function hasApiKey() {
return !!process.env.FOOTBALL_DATA_API_KEY;
}
// One central HTTP wrapper — applies key, timeout, rate-limit check, and
// stale-while-revalidate fallback. Returns parsed JSON or null. Never throws.
async function fetchWithCache(path, cacheKey, ttl) {
// 1. Try fresh cache (within TTL).
const fresh = await cacheGet(cacheKey);
if (fresh !== null) return fresh;
// 2. No key → can't fetch. Return null (callers degrade).
if (!hasApiKey()) return null;
// 3. Token bucket — if we're rate-limited, try the stale-while-revalidate
// key. If THAT misses too, give up rather than 429'ing the upstream API.
if (!tryConsumeToken()) {
const stale = await cacheGet(`${cacheKey}:stale`);
if (stale !== null) return stale;
return null;
}
// 4. Hit the network.
try {
const res = await axios.get(`${BASE_URL}${path}`, {
headers: { 'X-Auth-Token': process.env.FOOTBALL_DATA_API_KEY },
timeout: HTTP_TIMEOUT_MS,
});
const body = res.data;
if (body && typeof body === 'object') {
// Write to BOTH the live and stale keys. Stale key has a much
// longer TTL so stale-while-revalidate always finds something.
await cacheSet(cacheKey, body, ttl);
await cacheSet(`${cacheKey}:stale`, body, ttl * 4);
}
return body;
} catch (err) {
console.warn('[footballData] fetch failed:', path, err.message);
// Network failure — fall back to stale if we have it.
const stale = await cacheGet(`${cacheKey}:stale`);
if (stale !== null) return stale;
return null;
}
}
// ---- Public surface ----
async function getLeagueFixtures(competitionCode) {
if (!competitionCode) return null;
const code = String(competitionCode).toUpperCase();
const data = await fetchWithCache(
`/competitions/${code}/matches`,
`soccer:${code.toLowerCase()}:fixtures`,
TTL.fixtures,
);
// null → API unavailable (no key, fetch failure, drained bucket+no stale)
if (data === null) return null;
// Object present but no matches array → API returned nothing meaningful.
if (!Array.isArray(data.matches)) return [];
// Project to a stable shape so callers don't depend on API field names.
return data.matches.map((m) => ({
id: m.id,
homeTeam: m.homeTeam?.name || m.homeTeam?.shortName || null,
awayTeam: m.awayTeam?.name || m.awayTeam?.shortName || null,
utcDate: m.utcDate || null,
status: m.status || null,
score: m.score || null,
matchday: m.matchday ?? null,
venue: m.venue || null,
competition: code,
}));
}
async function getLeagueStandings(competitionCode) {
if (!competitionCode) return null;
const code = String(competitionCode).toUpperCase();
const data = await fetchWithCache(
`/competitions/${code}/standings`,
`soccer:${code.toLowerCase()}:standings`,
TTL.standings,
);
if (data === null) return null;
if (!Array.isArray(data.standings)) return [];
return data.standings;
}
async function getLeagueScorers(competitionCode) {
if (!competitionCode) return null;
const code = String(competitionCode).toUpperCase();
const data = await fetchWithCache(
`/competitions/${code}/scorers`,
`soccer:${code.toLowerCase()}:scorers`,
TTL.scorers,
);
if (data === null) return null;
if (!Array.isArray(data.scorers)) return [];
// Project: { player: {name, position, nationality}, team, goals, assists, playedMatches, ... }
return data.scorers.map((s) => ({
name: s.player?.name || null,
position: s.player?.position || null,
nationality: s.player?.nationality || null,
team: s.team?.name || null,
goals: s.goals ?? 0,
assists: s.assists ?? 0,
playedMatches: s.playedMatches ?? 0,
minutesPlayed: s.minutesPlayed ?? null,
}));
}
async function getTeamSquad(teamId) {
if (!teamId) return null;
const data = await fetchWithCache(
`/teams/${teamId}`,
`soccer:team:${teamId}:squad`,
TTL.squad,
);
if (data === null) return null;
if (!Array.isArray(data.squad)) return [];
return data.squad.map((p) => ({
id: p.id,
name: p.name,
position: p.position || null,
nationality: p.nationality || null,
shirtNumber: p.shirtNumber ?? null,
dateOfBirth: p.dateOfBirth || null,
}));
}
// Convenience wrappers for the World Cup — most-used competition code.
async function getWorldCupFixtures() { return getLeagueFixtures('WC'); }
async function getWorldCupStandings() { return getLeagueStandings('WC'); }
async function getWorldCupScorers() { return getLeagueScorers('WC'); }
module.exports = {
getLeagueFixtures,
getLeagueStandings,
getLeagueScorers,
getTeamSquad,
getWorldCupFixtures,
getWorldCupStandings,
getWorldCupScorers,
hasApiKey,
__internals: {
BASE_URL,
TTL,
BUCKET_MAX,
tryConsumeToken,
refillBucket,
resetBucketForTests: () => { _tokens = BUCKET_MAX; _lastRefill = 0; },
},
};
+116 -41
View File
@@ -30,57 +30,132 @@ function explainErrors(errors) {
return errors.map((e) => ERROR_EXPLANATIONS[e] || `Data gap: ${e}.`).join(' ');
}
// Soccer reasoning — different signals than NBA (xG, penalty role,
// altitude, referee, minutes). Concrete sentences from real values;
// nothing fires unless the underlying feature is non-null.
function buildSoccerReasoningLines(features = {}, meta = {}, prop = {}) {
const lines = [];
const statType = prop.stat_type || '';
if (Number.isFinite(features.goals_per_90)) {
lines.push(`${prop.player || 'Player'} scores ${features.goals_per_90.toFixed(2)} goals per 90 minutes.`);
} else if (Number.isFinite(features.l5_avg)) {
lines.push(`${prop.player || 'Player'} is averaging ${features.l5_avg.toFixed(2)} ${statType} over his last 5 matches.`);
}
if (Number.isFinite(features.xg_per_90)) {
const delta = features.xg_delta;
let trend = 'tracking expectations';
if (Number.isFinite(delta)) {
if (delta > 0.2) trend = 'overperforming — regression risk';
else if (delta < -0.2) trend = 'underperforming — breakout candidate';
}
lines.push(`Expected goals (xG): ${features.xg_per_90.toFixed(2)} per 90 — ${trend}.`);
}
if (features.is_penalty_taker) {
lines.push('Designated penalty taker — adds ~0.15 goals per 90 to base rate.');
}
if (features.takes_free_kicks && (statType === 'goals' || statType === 'shots' || statType === 'shots_on_target')) {
lines.push('Direct free-kick specialist — boosts shot/goal probability on fouls drawn.');
}
if (features.takes_corners && statType === 'assists') {
lines.push('Designated corner taker — meaningfully lifts assist probability.');
}
if (features.altitude_impact === 'high') {
lines.push(`Match at ${features.venue_altitude_ft || 'high'}ft altitude. ${features.home_continent ? 'Acclimated host team.' : 'Non-acclimatized side — historical goal reduction.'}`);
} else if (features.altitude_impact === 'moderate' && !features.home_continent) {
lines.push(`Moderate altitude at ${features.venue_altitude_ft || 'venue'}ft — minor stamina impact.`);
}
if (Number.isFinite(features.referee_cards_per_game)) {
const refName = features.referee_name || 'Referee';
lines.push(`${refName} averages ${features.referee_cards_per_game.toFixed(1)} cards per match.`);
}
if (Number.isFinite(features.minutes_per_game) && features.minutes_per_game < 75) {
lines.push(`Averaging only ${features.minutes_per_game.toFixed(0)} minutes per match — line may assume full 90.`);
}
if (Number.isFinite(features.opp_goals_conceded_per_game)) {
lines.push(`${meta.opponentAbbr || 'Opponent'} concedes ${features.opp_goals_conceded_per_game.toFixed(2)} goals per game.`);
}
if (features.tournament_player && Number.isFinite(features.wc_goals_career)) {
lines.push(`Tournament pedigree: ${features.wc_goals_career} career World Cup goals.`);
}
if (features.home_away === 1.0) lines.push('Playing at home.');
else if (features.home_away === 0.0) lines.push('Playing on the road.');
return lines;
}
// Build a human-readable reasoning summary + steps from the actual
// features (which carry real numbers) and engine1's grade.
function buildConcreteReasoning(features = {}, engine1Result = {}, meta = {}, prop = {}) {
const lines = [];
// Soccer (Session 7j) routes to a sport-specific line builder and
// returns before the NBA-flavored sentences would fire. The closer
// logic (trap, engine1 verdict, error gaps, steps shape) is shared
// between sports and lives below this branch.
const sportLc = String(meta.sport || '').toLowerCase();
const isSoccer = sportLc === 'soccer' || sportLc === 'football';
// Recent form vs the line — L5 and L20 are the orchestrator's
// canonical season-trend signals.
if (Number.isFinite(features.l5_avg)) {
lines.push(`${prop.player || 'Player'} is averaging ${features.l5_avg.toFixed(1)} ${prop.stat_type || ''} over his last 5 games.`);
}
if (Number.isFinite(features.l20_avg)) {
lines.push(`Last 20 games average: ${features.l20_avg.toFixed(1)}.`);
}
const lines = isSoccer
? buildSoccerReasoningLines(features, meta, prop)
: [];
// Trend direction relative to the line.
if (Number.isFinite(features.l5_avg) && Number.isFinite(prop.line)) {
const diff = features.l5_avg - prop.line;
if (Math.abs(diff) >= 0.5) {
const dir = diff > 0 ? 'above' : 'below';
lines.push(`That's ${Math.abs(diff).toFixed(1)} ${dir} the line of ${prop.line}.`);
if (!isSoccer) {
// Recent form vs the line — L5 and L20 are the orchestrator's
// canonical season-trend signals.
if (Number.isFinite(features.l5_avg)) {
lines.push(`${prop.player || 'Player'} is averaging ${features.l5_avg.toFixed(1)} ${prop.stat_type || ''} over his last 5 games.`);
}
}
// Home / away.
if (features.home_away === 1.0) lines.push('Playing at home tonight.');
else if (features.home_away === 0.0) lines.push('Playing on the road tonight.');
// Opponent matchup. opp_rank_stat is 0..1 normalized
// (0 = best D, 1 = worst D) — translate to friendlier language.
if (Number.isFinite(features.opp_rank_stat) && meta.opponentAbbr) {
if (features.opp_rank_stat >= 0.7) {
lines.push(`${meta.opponentAbbr} is a bottom-tier defense vs this stat.`);
} else if (features.opp_rank_stat <= 0.3) {
lines.push(`${meta.opponentAbbr} is a top-tier defense vs this stat.`);
} else {
lines.push(`${meta.opponentAbbr} is a middling defense vs this stat.`);
if (Number.isFinite(features.l20_avg)) {
lines.push(`Last 20 games average: ${features.l20_avg.toFixed(1)}.`);
}
// Trend direction relative to the line.
if (Number.isFinite(features.l5_avg) && Number.isFinite(prop.line)) {
const diff = features.l5_avg - prop.line;
if (Math.abs(diff) >= 0.5) {
const dir = diff > 0 ? 'above' : 'below';
lines.push(`That's ${Math.abs(diff).toFixed(1)} ${dir} the line of ${prop.line}.`);
}
}
// Home / away.
if (features.home_away === 1.0) lines.push('Playing at home tonight.');
else if (features.home_away === 0.0) lines.push('Playing on the road tonight.');
}
// Rest / fatigue context.
if (features.rest_days === 0) lines.push('Back-to-back — fatigue concern.');
else if (Number.isFinite(features.rest_days) && features.rest_days >= 2) {
lines.push(`${features.rest_days} days of rest.`);
}
if (Number.isFinite(features.game_count_in_7d) && features.game_count_in_7d >= 4) {
lines.push(`Heavy workload — ${features.game_count_in_7d} games in the last week.`);
}
if (!isSoccer) {
// Opponent matchup. opp_rank_stat is 0..1 normalized
// (0 = best D, 1 = worst D) — translate to friendlier language.
if (Number.isFinite(features.opp_rank_stat) && meta.opponentAbbr) {
if (features.opp_rank_stat >= 0.7) {
lines.push(`${meta.opponentAbbr} is a bottom-tier defense vs this stat.`);
} else if (features.opp_rank_stat <= 0.3) {
lines.push(`${meta.opponentAbbr} is a top-tier defense vs this stat.`);
} else {
lines.push(`${meta.opponentAbbr} is a middling defense vs this stat.`);
}
}
// Injury context.
if (Number.isFinite(features.injury_severity_score) && features.injury_severity_score > 0) {
lines.push(`${features.injury_severity_score} opponent starter(s) on the injury report.`);
// Rest / fatigue context.
if (features.rest_days === 0) lines.push('Back-to-back — fatigue concern.');
else if (Number.isFinite(features.rest_days) && features.rest_days >= 2) {
lines.push(`${features.rest_days} days of rest.`);
}
if (Number.isFinite(features.game_count_in_7d) && features.game_count_in_7d >= 4) {
lines.push(`Heavy workload — ${features.game_count_in_7d} games in the last week.`);
}
// Injury context.
if (Number.isFinite(features.injury_severity_score) && features.injury_severity_score > 0) {
lines.push(`${features.injury_severity_score} opponent starter(s) on the injury report.`);
}
}
// Trap composite — surfaced when meaningful.
+28 -2
View File
@@ -29,6 +29,9 @@ 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');
const HTTP_TIMEOUT_MS = 8_000;
@@ -121,14 +124,37 @@ async function safeGetConsistency({ playerName, sport, statType }) {
}
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';
// Default to NBA when caller omits — matches what legacy analyzeProp does.
const sport = (rawProp.sport || 'nba').toLowerCase();
if (!player || !statType || !Number.isFinite(line)) {
errors.push('missing required fields (player, stat_type, or line)');
@@ -0,0 +1,245 @@
/**
* Soccer feature extractor — soccer's answer to the NBA feature stack.
*
* Reads from prefetch-populated Redis cache (NEVER hits external APIs on
* the user-request path) and shapes the result into engine1's feature
* vector plus a soccer-specific overlay. Engine1 ignores unknown keys
* so the overlay is read by:
* - trapDetection (soccer traps)
* - analyzeViaEngine1 (soccer reasoning sentences)
* - downstream UI rendering
*
* Cache contract — keys written by `scripts/soccer-data-prefetch.js`
* and `poller/soccer.js`:
* soccer:player:{normalizedName} → per-player season aggregate
* soccer:nextmatch:{teamName} → next fixture (opp, venue, ref, daysUntil)
* soccer:lastfixture:{teamName} → most recent finished fixture (rest_days)
* soccer:referee:{refereeName} → referee cards/penalties per game
* soccer:teamdefense:{league}:{teamName} → opp defensive aggregates
*
* Any cache miss → that field stays null. Engine1 + reasoning handle
* nulls gracefully (the trap, consistency, and grading pipeline all
* default-skip missing signals rather than penalizing).
*
* No external HTTP. No throws. Every step independently fault-tolerant.
*/
const { cacheGet } = require('../../utils/redis');
const { normalizeName } = require('../../utils/normalize');
const wc = require('../../data/worldcup2026');
const SOCCER_SPORTS = new Set(['soccer', 'football']);
async function safeCacheGet(key) {
try {
return await cacheGet(key);
} catch (err) {
console.warn('[soccerFeatures] cache read failed:', key, err.message);
return null;
}
}
// Read per-player season aggregate. The prefetch writes a flat shape
// that already collapses played + minutes into per-90 rates so we don't
// recompute on every request.
async function loadPlayerProfile(playerName) {
if (!playerName) return null;
return safeCacheGet(`soccer:player:${normalizeName(playerName)}`);
}
async function loadNextMatch(teamName) {
if (!teamName) return null;
return safeCacheGet(`soccer:nextmatch:${teamName}`);
}
async function loadLastFixture(teamName) {
if (!teamName) return null;
return safeCacheGet(`soccer:lastfixture:${teamName}`);
}
async function loadRefereeProfile(refName) {
if (!refName) return null;
return safeCacheGet(`soccer:referee:${refName}`);
}
async function loadTeamDefense(league, teamName) {
if (!league || !teamName) return null;
return safeCacheGet(`soccer:teamdefense:${String(league).toLowerCase()}:${teamName}`);
}
// Compute rest days from a `lastfixture` payload. Returns null if the
// payload is absent or malformed — engine1 reads null as "unknown" and
// neither rewards nor penalizes.
function computeRestDays(lastFixture) {
if (!lastFixture || !lastFixture.utcDate) return null;
const last = Date.parse(lastFixture.utcDate);
if (!Number.isFinite(last)) return null;
// Use Date.now() so tests can fake the clock via jest.useFakeTimers().
const diffMs = Date.now() - last;
if (diffMs < 0) return null; // future date — malformed
return Math.floor(diffMs / (24 * 3600 * 1000));
}
// xG regression risk fires when actual goals significantly outpace
// expected goals — historically these regress to the mean within ~10
// matches. The 0.3 threshold is the standard analytics-community cutoff.
function xgRegressionRisk(xgDelta) {
if (!Number.isFinite(xgDelta)) return false;
return xgDelta > 0.3;
}
/**
* extractSoccerFeatures — the public entry. Async (cache reads), never
* throws, always returns the engine1-compatible shape even when every
* lookup misses. Errors land in `meta.errors` so the route layer can
* downgrade confidence and explain.
*
* @param {Object} input { player, stat_type, line, direction, sport,
* team?, opponent?, venue?, league? }
* @returns {Object} { features, trap, consistency, prop, meta }
*/
async function extractSoccerFeatures(input = {}) {
const errors = [];
const player = input.player;
const statType = input.stat_type || input.statType;
const line = Number(input.line);
const direction = input.direction || 'over';
const league = input.league || 'WC';
if (!player || !statType || !Number.isFinite(line)) {
errors.push('missing required fields (player, stat_type, or line)');
}
// Player profile — drives base stats, xG.
const profile = await loadPlayerProfile(player);
if (!profile) errors.push('player_not_found_in_cache');
// Team — explicit if provided, otherwise inferred from the profile.
const team = input.team || profile?.team || null;
if (!team) errors.push('team_not_resolved');
// Next match context — drives opponent, venue, referee.
const nextMatch = team ? await loadNextMatch(team) : null;
if (!nextMatch) errors.push('no_match_scheduled');
const opponent = input.opponent || nextMatch?.opponent || null;
const venueName = input.venue || nextMatch?.venue || null;
const refereeName = nextMatch?.referee || null;
const isHome = nextMatch?.isHome ?? null;
// Rest days — from last finished fixture.
const lastFixture = team ? await loadLastFixture(team) : null;
const restDays = computeRestDays(lastFixture);
// Opponent defensive aggregate.
const oppDefense = opponent ? await loadTeamDefense(league, opponent) : null;
// Referee profile (cards + penalties per game).
const refProfile = refereeName ? await loadRefereeProfile(refereeName) : null;
// Venue → altitude impact.
const venue = wc.getVenue(venueName);
const altitudeFt = venue?.altitude_ft ?? null;
const climate = venue?.climate ?? null;
const homeContinent = wc.isHomeContinent(team);
const altImpact = wc.altitudeImpact(altitudeFt);
// Set-piece + penalty roles (static data — no async).
const isPK = wc.isPenaltyTaker(player, team);
const isCorner = wc.isCornerTaker(player, team);
const isFK = wc.isFreeKickTaker(player, team);
const tournamentHistory = wc.getTournamentHistory(player);
// ---- Feature vector ----
// The engine1-known keys (l5_avg, l20_avg, home_away, opp_rank_stat,
// rest_days) are filled where we have data so the legacy grading
// logic still produces a grade. Soccer-specific fields are passed
// through (engine1 ignores unknown keys).
const features = {
// engine1-canonical
l5_avg: profile?.recent_form_per_90 ?? null, // last 5 matches of THIS stat type, per 90
l20_avg: profile?.season_per_90 ?? profile?.goals_per_90 ?? null,
l10_stddev: null, // Day 1: no rolling stddev
home_away: isHome === true ? 1.0 : (isHome === false ? 0.0 : null),
opp_rank_stat: oppDefense?.defensive_rank_norm ?? null, // 0..1, 1=worst D
rest_days: restDays,
injury_severity_score: 0, // soccer Day 1 — injuries surface differently
game_count_in_7d: null,
// soccer-specific overlay (engine1 passes through; trap + reasoning read)
goals_per_90: profile?.goals_per_90 ?? null,
assists_per_90: profile?.assists_per_90 ?? null,
minutes_per_game: profile?.minutes_per_game ?? null,
start_rate: profile?.start_rate ?? null,
xg_per_90: profile?.xg_per_90 ?? null,
xa_per_90: profile?.xa_per_90 ?? null,
xg_delta: profile?.xg_delta ?? null,
xg_regression_risk: xgRegressionRisk(profile?.xg_delta),
is_penalty_taker: isPK,
takes_corners: isCorner,
takes_free_kicks: isFK,
home_continent: homeContinent,
venue_altitude_ft: altitudeFt,
altitude_impact: altImpact,
climate,
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,
referee_name: refereeName,
referee_cards_per_game: refProfile?.cards_per_game ?? null,
referee_penalties_per_game: refProfile?.penalties_per_game ?? null,
wc_goals_career: tournamentHistory?.wc_goals_career ?? null,
wc_appearances: tournamentHistory?.wc_appearances ?? null,
tournament_player: !!(tournamentHistory && (tournamentHistory.wc_goals_career || 0) >= 3),
stat_type: statType, // trap detection peeks at this
};
// ---- Trap / consistency placeholders ----
// Soccer trap detection runs in trapDetection.js (Fix 4). For now,
// pass a neutral default — analyzeViaEngine1 calls trap detection
// explicitly via the same path NBA uses.
const trap = { composite: 0, signals: {}, active_count: 0, recommendation: 'proceed' };
const consistency = { consistency: 'unknown', score: null, games: 0 };
return {
features,
trap,
consistency,
prop: { line, direction },
meta: {
player,
statType,
line,
direction,
book: input.book || 'unknown',
sport: 'soccer',
league,
teamAbbr: team,
opponentAbbr: opponent,
venue: venueName,
referee: refereeName,
isHome,
gameLogs: [],
errors,
},
};
}
function isSoccerSport(sport) {
return SOCCER_SPORTS.has(String(sport || '').toLowerCase());
}
module.exports = {
extractSoccerFeatures,
isSoccerSport,
__internals: {
SOCCER_SPORTS,
computeRestDays,
xgRegressionRisk,
loadPlayerProfile,
loadNextMatch,
loadLastFixture,
loadRefereeProfile,
loadTeamDefense,
},
};
+125 -2
View File
@@ -200,6 +200,115 @@ const SIGNALS = [
['line_consensus_divergence', signalLineConsensusDivergence],
];
// ---------------------------------------------------------------
// Soccer trap signals (Session 7j).
//
// All soccer signals are synchronous — they read pre-computed feature
// values straight off `input.features`. The feature extractor and the
// daily prefetch are responsible for filling those fields; nothing
// here touches the network. Each signal returns the same
// `{score, active, explanation}` shape as the NBA path.
//
// `positive: true` signals (e.g. referee_card_heavy on a CARDS over)
// are visible in the signals map but DO NOT contribute to the
// composite — they're favorable to the bet, not a trap reason.
// ---------------------------------------------------------------
function signalXgRegression(input) {
const xgDelta = input?.features?.xg_delta;
if (!Number.isFinite(xgDelta)) return inactive('no xG data');
if (xgDelta > 0.3) {
return {
score: Math.min(1, xgDelta),
active: true,
explanation: `scoring ${(xgDelta * 100).toFixed(0)}% above expected goals — regression risk`,
};
}
return { score: 0, active: true, explanation: 'xG tracks actual goals' };
}
function signalAltitudeRisk(input) {
const f = input?.features || {};
if (f.altitude_impact !== 'high') return inactive('not high altitude');
if (f.home_continent) return inactive('host-continent team — assumed acclimated');
return {
score: 0.6,
active: true,
explanation: `non-acclimatized team at ${f.venue_altitude_ft || 'high'}ft altitude — historical goal reduction`,
};
}
function signalRotationRisk(input) {
const f = input?.features || {};
if (!Number.isFinite(f.start_rate) || !Number.isFinite(f.rest_days)) {
return inactive('missing start_rate or rest_days');
}
if (f.start_rate < 0.7 && f.rest_days <= 2) {
return {
score: 0.7,
active: true,
explanation: `${(f.start_rate * 100).toFixed(0)}% start rate on ${f.rest_days}-day rest — rotation candidate`,
};
}
return { score: 0, active: true, explanation: 'start rate / rest acceptable' };
}
function signalMinuteDiscount(input) {
const mpg = input?.features?.minutes_per_game;
if (!Number.isFinite(mpg)) return inactive('no minutes-per-game');
if (mpg < 70) {
return {
score: 0.5,
active: true,
explanation: `averages ${mpg.toFixed(0)} minutes/match — line assumes full 90`,
};
}
return { score: 0, active: true, explanation: 'plays full matches' };
}
function signalRefereeCardBias(input) {
const f = input?.features || {};
const cpg = f.referee_cards_per_game;
if (!Number.isFinite(cpg)) return inactive('no referee data');
// Positive signal — applies only when the prop is about CARDS and the
// referee is card-heavy. Surface but exclude from composite.
const statType = f.stat_type || input?.statType;
if (cpg > 5 && statType === 'cards') {
return {
score: 0, active: false, positive: true,
explanation: `${f.referee_name || 'referee'} averages ${cpg.toFixed(1)} cards/match — favorable for card over`,
};
}
return inactive('referee card rate not a positive signal for this stat type');
}
function signalStrongDefense(input) {
const f = input?.features || {};
const statType = f.stat_type || input?.statType;
if (!['goals', 'shots_on_target', 'shots'].includes(statType)) {
return inactive('only applies to scoring/shot stats');
}
const rank = f.opp_defensive_rank;
if (!Number.isFinite(rank)) return inactive('no opponent defensive rank');
if (rank <= 5) {
return {
score: 0.6,
active: true,
explanation: `top-${rank} defense — scoring/shot props face headwinds`,
};
}
return { score: 0, active: true, explanation: 'opponent defense not elite' };
}
const SOCCER_SIGNALS = [
['xg_regression', signalXgRegression],
['altitude_risk', signalAltitudeRisk],
['rotation_risk', signalRotationRisk],
['minute_discount', signalMinuteDiscount],
['referee_card_bias', signalRefereeCardBias], // positive — excluded from composite
['strong_defense', signalStrongDefense],
];
function recommend(composite) {
if (composite >= 0.5) return 'avoid';
if (composite >= 0.25) return 'caution';
@@ -207,8 +316,14 @@ function recommend(composite) {
}
async function getTrapScore(input = {}) {
// Soccer runs a different signal set (xG regression, altitude, rotation,
// referee bias). NBA/WNBA/MLB run the line-movement-centric set.
const sport = String(input?.sport || '').toLowerCase();
const isSoccer = sport === 'soccer' || sport === 'football';
const signalList = isSoccer ? SOCCER_SIGNALS : SIGNALS;
const signals = {};
for (const [name, fn] of SIGNALS) {
for (const [name, fn] of signalList) {
try {
const result = await fn(input);
signals[name] = result;
@@ -216,8 +331,10 @@ async function getTrapScore(input = {}) {
signals[name] = { score: 0, active: false, explanation: `error: ${err?.message || 'unknown'}` };
}
}
// Composite excludes signals flagged `positive: true` — those are
// favorable to the bet, not trap reasons.
const activeScores = Object.values(signals)
.filter((s) => s.active)
.filter((s) => s.active && !s.positive)
.map((s) => s.score);
const composite = activeScores.length === 0
? 0
@@ -242,6 +359,12 @@ module.exports = {
signalJuiceDegradation,
signalTeammateReturnTrap,
signalLineConsensusDivergence,
signalXgRegression,
signalAltitudeRisk,
signalRotationRisk,
signalMinuteDiscount,
signalRefereeCardBias,
signalStrongDefense,
recommend,
},
};
+25 -1
View File
@@ -4,7 +4,29 @@ const { normalizeProps, extractSpreads, MARKET_MAP } = require('../utils/oddsNor
const ODDS_API_BASE = 'https://api.the-odds-api.com/v4/sports';
const CACHE_TTL = 900; // 15 minutes in seconds
const SPORT_KEYS = { nba: 'basketball_nba', ncaab: 'basketball_ncaab' };
// Sport identifiers consumed by getOdds → mapped to the odds-api.com
// sport key. Soccer leagues are listed individually so the route layer
// can fetch per-league without changing the upstream contract. Only
// fetched on user demand (on-demand cache with 15-min TTL); leagues
// nobody queries don't consume odds-api quota.
const SPORT_KEYS = {
nba: 'basketball_nba',
ncaab: 'basketball_ncaab',
// Soccer (Session 7j) — odds-api sport keys verified against
// https://the-odds-api.com/sports-odds-data/sports-apis.html
soccer_wc: 'soccer_fifa_world_cup',
soccer_epl: 'soccer_epl',
soccer_laliga: 'soccer_spain_la_liga',
soccer_bundesliga: 'soccer_germany_bundesliga',
soccer_seriea: 'soccer_italy_serie_a',
soccer_ligue1: 'soccer_france_ligue_one',
soccer_ucl: 'soccer_uefa_champs_league',
soccer_mls: 'soccer_usa_mls',
soccer_ligamx: 'soccer_mexico_ligamx',
};
const SOCCER_SPORT_KEYS = Object.freeze(
Object.keys(SPORT_KEYS).filter((k) => k.startsWith('soccer_'))
);
const ALL_MARKETS = Object.keys(MARKET_MAP).join(',') + ',spreads';
const BOOKMAKERS = 'draftkings,fanduel,betmgm,caesars,fanatics,bet365,hardrockbet,pointsbet,betrivers';
@@ -201,6 +223,8 @@ module.exports = {
fetchEventsFromApi,
fetchEventOddsFromApi,
getCacheKey,
SPORT_KEYS,
SOCCER_SPORT_KEYS,
getQuotaKey,
updateQuota,
getQuotaRemaining,
+14
View File
@@ -3,6 +3,7 @@ const { getAbbreviation } = require('./teamMap');
const ALLOWED_BOOKS = new Set(['draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365', 'hardrockbet', 'pointsbet', 'betrivers']);
const MARKET_MAP = {
// NBA / WNBA props
player_points: 'points',
player_rebounds: 'rebounds',
player_assists: 'assists',
@@ -11,6 +12,19 @@ const MARKET_MAP = {
player_steals: 'steals',
player_points_rebounds_assists: 'pra',
player_turnovers: 'turnovers',
// Soccer props — World Cup 2026 + permanent league support.
// odds-api keys verified against soccer_fifa_world_cup market list.
// 'assists' is shared with NBA — sport context discriminates downstream.
player_goals: 'goals',
player_shots_on_target: 'shots_on_target',
player_shots: 'shots',
player_tackles: 'tackles',
player_cards: 'cards',
player_corners: 'corners',
player_saves: 'saves',
player_goals_conceded: 'goals_conceded',
player_passes: 'passes',
team_clean_sheet: 'clean_sheet',
};
function normalizeProps(eventsWithOdds) {