Session 7j: Soccer intelligence - 9 leagues, 11 signals, 6 traps, poller, prefetch, 131 new tests (1173 total)
This commit is contained in:
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user