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
+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,
},
};