Session 7j: Soccer intelligence - 9 leagues, 11 signals, 6 traps, poller, prefetch, 131 new tests (1173 total)
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* FIFA World Cup 2026 reference data — June 11–July 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,
|
||||
};
|
||||
@@ -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']);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; },
|
||||
},
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user