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
+89 -1
View File
@@ -4,7 +4,95 @@
2026-06-10 2026-06-10
## Current Phase ## Current Phase
SHIP BUILD v7.1 — Stripe Route + Webhook Verification (Session 7i) SHIP BUILD v7.2 — Soccer Intelligence + World Cup 2026 (Session 7j)
## Session 7j (2026-06-10) — SHIPPED
Permanent soccer sport vertical, launching with FIFA World Cup 2026
(opens June 11). League-agnostic architecture supports WC, EPL, La Liga,
Bundesliga, Serie A, Ligue 1, UCL, MLS, Liga MX from the same code paths.
### Files created
- `src/data/worldcup2026.js` — 16 venues + altitudes + climate, CONCACAF
+ CONMEBOL teams, penalty/corner/free-kick takers (top 25 teams),
tournament players (≥3 career WC goals). All frozen. Helpers:
`isPenaltyTaker`, `isCornerTaker`, `isFreeKickTaker`,
`getTournamentHistory`, `isHomeContinent`, `getVenue`, `altitudeImpact`.
- `src/services/adapters/footballDataAdapter.js` — football-data.org v4
REST adapter. 8/min token bucket (2-req safety margin vs the 10/min
upstream cap). Tier-matched Redis TTLs (fixtures 6h, standings 12h,
squads 24h, scorers 6h). Stale-while-revalidate fallback when the
bucket is drained or the API 5xx's. Returns null when no API key —
callers degrade gracefully.
- `src/services/intelligence/soccerFeatureExtractor.js` — reads from
prefetch-populated Redis cache (NEVER hits external APIs on the
user request path). Builds the engine1 feature vector + a soccer
overlay (goals_per_90, xG, penalty/corner/FK role, altitude,
referee, tournament history, rest_days).
- `poller/soccer.js` — league-agnostic fixture poller. WC pulls from
the rezarahiminia/worldcup2026 OSS API (no rate limit) and falls
back to football-data.org. Other leagues use the adapter directly.
Writes `soccer:nextmatch:{team}` (24h TTL) + `soccer:lastfixture:{team}`
(7d TTL) per fixture. Self-rescheduling: 5-min ticks during live
matches, 30-min otherwise. PM2-managed.
- `scripts/soccer-data-prefetch.js` — daily batch job. Pulls standings
+ scorers per configured league, computes per-team defensive
aggregate (`goals_conceded_per_game`, `defensive_rank_norm` on a 0..1
scale that slots into engine1's `opp_rank_stat`) and per-player
per-90 rates. Writes `soccer:teamdefense:{league}:{team}` and
`soccer:player:{normalizedName}`. `--leagues=WC,PL --dry-run` flags
supported. xG fields left null on Day 1 (soccerdata-Python bridge is
a follow-up; engine handles nulls gracefully).
- `tests/unit/worldcup2026.test.js` (20 tests)
- `tests/unit/footballDataAdapter.test.js` (15 tests)
- `tests/unit/soccerFeatureExtractor.test.js` (17 tests)
- `tests/unit/trapDetectionSoccer.test.js` (21 tests)
- `tests/unit/computeFeaturesSoccerBranch.test.js` (4 tests)
- `tests/unit/analyzeViaEngine1Soccer.test.js` (8 tests)
- `tests/unit/soccerPoller.test.js` (22 tests)
- `tests/unit/soccerDataPrefetch.test.js` (14 tests)
- `tests/integration/oddsSoccer.test.js` (6 tests)
### Files modified
- `src/utils/oddsNormalizer.js``MARKET_MAP` gains 10 soccer market
keys (`player_goals`, `player_shots_on_target`, etc → `goals`,
`shots_on_target`, etc). Existing NBA mappings untouched.
- `src/routes/analyze.js`, `src/routes/scan.js``VALID_STAT_TYPES`
set extended with 10 soccer stat types. `'assists'` is shared with
NBA; `sport` field discriminates downstream.
- `src/routes/odds.js` — new `GET /api/odds/soccer/:league` route.
Validates league against `SOCCER_SPORT_KEYS` (9 leagues), surfaces
405 valid-list hint on miss.
- `src/services/oddsService.js``SPORT_KEYS` gains 9 soccer entries
mapping `soccer_wc``soccer_fifa_world_cup`, `soccer_epl`
`soccer_epl`, etc. `SOCCER_SPORT_KEYS` exported as a frozen list.
- `src/services/intelligence/computeFeatures.js` — `sport ∈
{'soccer','football'}` dispatches to `extractSoccerFeatures`. NBA
path unchanged.
- `src/services/intelligence/trapDetection.js` — six soccer signals
(xg_regression, altitude_risk, rotation_risk, minute_discount,
referee_card_bias [positive — excluded from composite],
strong_defense). `getTrapScore` branches on `input.sport`.
- `src/services/intelligence/analyzeViaEngine1.js` — soccer reasoning
branch (`buildSoccerReasoningLines`). Uses "matches" not "games",
surfaces xG / penalty taker / altitude / referee / minutes / WC
pedigree. NBA-specific sentences (back-to-back, injury report)
guarded by `!isSoccer`.
- `poller/ecosystem.config.js` — `poller-soccer` PM2 app added. Same
restart policy as box-score pollers; `SOCCER_LEAGUES` env wired.
- `.env.example` — soccer block (`FOOTBALL_DATA_API_KEY`,
`SOCCER_LEAGUES`, `WORLDCUP_API_URL`, `RAPID_API_KEY`).
- `docs/SYSTEM-MANIFEST.md` — `/api/odds/soccer/:league` row in §2,
Soccer env block in §3, soccer poller in poller-set, four new
external API rows in §6, `[ARCH-3]` soccer-pipeline note in §8.
### Quality gates (all green)
- `npm test`: **1173 / 1173 passing** (1042 baseline + 131 new soccer
tests across 9 new suites), 91 suites, 0 failures
- `web/npm run build`: clean
- License audit: only permissive third-party licenses
---
## Session 7i (2026-06-10) — SHIPPED ## Session 7i (2026-06-10) — SHIPPED
+14
View File
@@ -425,3 +425,17 @@
{"ts":"2026-06-10T17:38:50.409Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"} {"ts":"2026-06-10T17:38:50.409Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-10T17:38:50.501Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"} {"ts":"2026-06-10T17:38:50.501Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-10T17:38:51.619Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"} {"ts":"2026-06-10T17:38:51.619Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T18:07:38.189Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T18:07:38.205Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-10T18:07:38.205Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-10T18:07:38.205Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-10T18:07:38.297Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-10T18:07:38.341Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-10T18:07:38.570Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T18:29:14.051Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T18:29:14.081Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-10T18:29:14.081Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-10T18:29:14.081Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-10T18:29:14.213Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-10T18:29:14.229Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-10T18:29:14.240Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
+31
View File
@@ -60,6 +60,7 @@ Mounted in `src/app.js`. Auth column meanings:
| GET | /api/health | public | n/a | `app.js` (inline) | | GET | /api/health | public | n/a | `app.js` (inline) |
| GET | /api/odds/nba | public | 10mb | `routes/odds.js` | | GET | /api/odds/nba | public | 10mb | `routes/odds.js` |
| GET | /api/odds/ncaab | public | 10mb | `routes/odds.js` | | GET | /api/odds/ncaab | public | 10mb | `routes/odds.js` |
| GET | /api/odds/soccer/:league | public | 10mb | `routes/odds.js` (Session 7j) |
| POST | /api/analyze/prop | public + 10/min IP | 10mb | `routes/analyze.js` (cached 60s) | | POST | /api/analyze/prop | public + 10/min IP | 10mb | `routes/analyze.js` (cached 60s) |
| POST | /api/analyze/batch | public + 10/min IP | 10mb | `routes/analyze.js` (cached 60s) | | POST | /api/analyze/batch | public + 10/min IP | 10mb | `routes/analyze.js` (cached 60s) |
| POST | /api/scan/parlay | user | 10mb | `routes/scan.js` | | POST | /api/scan/parlay | user | 10mb | `routes/scan.js` |
@@ -190,6 +191,14 @@ back). Updated this session in Section 1 of Session 7c.
| `PINNACLE_API_BASE` | ✓ commented (legacy) | | `PINNACLE_API_BASE` | ✓ commented (legacy) |
| `ODDS_API_KEY` | ✓ commented (legacy) | | `ODDS_API_KEY` | ✓ commented (legacy) |
### Soccer / World Cup 2026 (Session 7j)
| Var | Required | Default | Used By | Doc? |
| ------------------------- | -------- | ------------------------------------------------ | ------------------------------------------------------- | ---- |
| `FOOTBALL_DATA_API_KEY` | no | (none) | `footballDataAdapter`, `soccer-data-prefetch` | ✓ |
| `SOCCER_LEAGUES` | no | `WC` | `poller/soccer.js`, `soccer-data-prefetch` | ✓ |
| `WORLDCUP_API_URL` | no | `https://worldcup2026-api.up.railway.app/api/...` | `poller/soccer.js` | ✓ |
| `RAPID_API_KEY` | no | (none) | reserved for `soccer-data-prefetch` referee enrichment | ✓ |
### Engine 2 ### Engine 2
| Var | Doc? | | Var | Doc? |
| ---------------------------- | ---- | | ---------------------------- | ---- |
@@ -235,6 +244,11 @@ back). Updated this session in Section 1 of Session 7c.
| `VYNDR_API_URL` | `http://localhost:3001` | ✓ commented | | `VYNDR_API_URL` | `http://localhost:3001` | ✓ commented |
| `OFF_HOURS_POLL_MS` | hardcoded 5min | not env | | `OFF_HOURS_POLL_MS` | hardcoded 5min | not env |
PM2 ecosystem (Session 7j) — four poller processes per container:
- `poller-nba`, `poller-wnba`, `poller-mlb` (box-score resolution path via `poller/poller.js`)
- `poller-soccer` (fixture indexing via `poller/soccer.js` — different
data sources and cache shape; honors `SOCCER_LEAGUES` env)
### Backup + Ops ### Backup + Ops
| Var | Doc? | | Var | Doc? |
| ------------------------- | ---- | | ------------------------- | ---- |
@@ -347,6 +361,10 @@ Source: `grep -rn "cacheSet\|cacheGet\|redis\.set"`.
| Resend (email) | `web/src/services/email.ts` | `RESEND_API_KEY`, `RESEND_FROM_EMAIL` | n/a | transactional email | | Resend (email) | `web/src/services/email.ts` | `RESEND_API_KEY`, `RESEND_FROM_EMAIL` | n/a | transactional email |
| NexaPay | `web/src/services/nexapay.ts` | `NEXAPAY_*` | n/a | checkout fallback | | NexaPay | `web/src/services/nexapay.ts` | `NEXAPAY_*` | n/a | checkout fallback |
| PostHog | `web/src/lib/analytics.ts` | `NEXT_PUBLIC_POSTHOG_KEY/HOST` | n/a | browser analytics | | PostHog | `web/src/lib/analytics.ts` | `NEXT_PUBLIC_POSTHOG_KEY/HOST` | n/a | browser analytics |
| football-data.org | `footballDataAdapter.js` | `FOOTBALL_DATA_API_KEY` | 10/min (8 enforced) | poller-soccer, prefetch |
| Stripe | `services/stripeService.js` | `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_*` | n/a | checkout + webhook |
| The Odds API | `services/oddsService.js` | `ODDS_API_KEY` | quota tracked | per-sport odds endpoints |
| worldcup2026 OSS | `poller/soccer.js` | `WORLDCUP_API_URL` | none (free) | WC fixture poll |
--- ---
@@ -484,6 +502,19 @@ No circular imports detected.
Full removal blocks on ARCH-1 Step 6 — the legacy book adapters Full removal blocks on ARCH-1 Step 6 — the legacy book adapters
retire together with the legacy grading path. retire together with the legacy grading path.
- **[ARCH-3] Soccer pipeline added as a parallel branch.** Severity:
Info. Status: **SHIPPED in Session 7j.** Soccer routes off
`computeFeaturesForProp` to `soccerFeatureExtractor` when
`sport ∈ {'soccer','football'}`; trap detection branches on the same
in `getTrapScore`; reasoning branches in `buildConcreteReasoning`.
Engine1 is sport-agnostic (passes unknown feature keys through).
Data flow: `poller/soccer.js` writes per-team `nextmatch` /
`lastfixture` pointers; `scripts/soccer-data-prefetch.js` writes
per-player + per-team-defense aggregates. The feature extractor
reads ONLY from cache — no external HTTP on the user request path.
Day-1 gap: xG fields (`xg_per_90`, `xg_delta`) are null until the
soccerdata-Python bridge ships; engine handles the nulls gracefully.
### SEC — Security ### SEC — Security
- **[SEC-1] `/api/analyze/batch` has no auth or rate limit.** Severity: - **[SEC-1] `/api/analyze/batch` has no auth or rate limit.** Severity:
+24
View File
@@ -33,11 +33,35 @@ function poller(sport, env = {}) {
}; };
} }
// Soccer poller (Session 7j) — own script because the data sources
// (worldcup2026 OSS + football-data.org) and cache shape (per-team
// next/last match pointers) differ from the box-score resolution path.
// SOCCER_LEAGUES env controls which competitions get polled; default 'WC'.
function soccerPoller(env = {}) {
return {
name: 'poller-soccer',
script: require('path').join(__dirname, 'soccer.js'),
cwd: ROOT,
env: {
...baseEnv,
...env,
SOCCER_LEAGUES: env.SOCCER_LEAGUES || process.env.SOCCER_LEAGUES || 'WC',
},
max_memory_restart: '256M',
log_type: 'json',
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
autorestart: true,
max_restarts: 10,
min_uptime: '30s',
};
}
module.exports = { module.exports = {
apps: [ apps: [
poller('nba'), poller('nba'),
poller('wnba'), poller('wnba'),
poller('mlb'), poller('mlb'),
soccerPoller(),
// Uncomment when in-season — keeping commented to save memory off-season. // Uncomment when in-season — keeping commented to save memory off-season.
// poller('nfl'), // poller('nfl'),
// poller('ncaafb'), // poller('ncaafb'),
+216
View File
@@ -0,0 +1,216 @@
/**
* Soccer fixture poller — one process under PM2.
*
* Polls the configured leagues (SOCCER_LEAGUES env, default 'WC') and
* writes per-team `soccer:nextmatch:{team}` and `soccer:lastfixture:{team}`
* keys to Redis. The feature extractor reads those keys on the user
* request path; this poller is the ONLY thing that hits external APIs
* during normal operation (the daily prefetch is the other; it owns
* player/squad/scorer data).
*
* Sources per league:
* WC → worldcup2026 OSS API (no key, no rate limit) — `WORLDCUP_API_URL`
* anything else → football-data.org via the in-tree adapter
*
* Poll frequency:
* no live matches: 30 min (POLL_INTERVAL_OFF_MS)
* live matches: 5 min (POLL_INTERVAL_LIVE_MS)
*
* On missing API key or upstream failure: log + continue. The next tick
* picks up where this one left off. We do not throw out of tick().
*/
const axios = require('axios');
const { cacheSet } = require('../src/utils/redis');
const fbd = require('../src/services/adapters/footballDataAdapter');
const HTTP_TIMEOUT_MS = 10_000;
const POLL_INTERVAL_OFF_MS = 30 * 60_000;
const POLL_INTERVAL_LIVE_MS = 5 * 60_000;
// 24h TTL on fixture pointers so a stalled poller doesn't poison reads
// with old data forever. The poller refreshes on every tick.
const NEXT_MATCH_TTL_SEC = 24 * 3600;
const LAST_FIXTURE_TTL_SEC = 7 * 24 * 3600;
const WORLDCUP_API_URL = process.env.WORLDCUP_API_URL
|| 'https://worldcup2026-api.up.railway.app/api/matches';
function parseLeagues() {
const raw = process.env.SOCCER_LEAGUES || 'WC';
return raw.split(',').map((s) => s.trim().toUpperCase()).filter(Boolean);
}
// Status normalization across upstream variants.
function classifyStatus(status) {
const s = String(status || '').toUpperCase();
if (s.includes('IN_PLAY') || s.includes('LIVE') || s.includes('PAUSED')) return 'live';
if (s.includes('FINISHED') || s.includes('FINAL') || s.includes('COMPLETED')) return 'finished';
return 'scheduled';
}
// Fetch WC fixtures from the OSS API. Returns the same projected shape
// as football-data adapter: { id, homeTeam, awayTeam, utcDate, status,
// score, matchday, venue, competition }.
async function fetchWorldCupFixtures() {
try {
const res = await axios.get(WORLDCUP_API_URL, { timeout: HTTP_TIMEOUT_MS });
const matches = Array.isArray(res.data) ? res.data
: Array.isArray(res.data?.matches) ? res.data.matches
: [];
return matches.map((m) => ({
id: m.id ?? m.match_id ?? null,
homeTeam: m.home_team || m.homeTeam || m.home?.name || null,
awayTeam: m.away_team || m.awayTeam || m.away?.name || null,
utcDate: m.utc_date || m.utcDate || m.date || null,
status: m.status || m.match_status || 'SCHEDULED',
score: m.score || null,
matchday: m.matchday ?? m.round ?? null,
venue: m.venue || m.stadium || null,
competition: 'WC',
}));
} catch (err) {
console.warn('[poller-soccer] worldcup API fetch failed:', err.message);
return null;
}
}
// Fetch via league code through the football-data adapter (NULL when
// no key configured — the adapter handles that). For WC we prefer the
// OSS API to save football-data quota.
async function fetchLeagueFixtures(league) {
if (league === 'WC') {
const wc = await fetchWorldCupFixtures();
if (wc !== null) return wc;
// OSS down → fall back to football-data if a key is configured.
return fbd.getWorldCupFixtures();
}
return fbd.getLeagueFixtures(league);
}
// Index fixtures into per-team `nextmatch` / `lastfixture` keys. Returns
// { scheduled, live, finished } counts for the tick summary.
async function indexFixturesForLeague(league, fixtures) {
const counts = { scheduled: 0, live: 0, finished: 0 };
if (!Array.isArray(fixtures)) return counts;
// Sort by date so the FIRST scheduled fixture per team is "next",
// and the LATEST finished one is "last".
const sorted = fixtures.slice().sort((a, b) => {
const da = Date.parse(a.utcDate || '') || 0;
const db = Date.parse(b.utcDate || '') || 0;
return da - db;
});
const now = Date.now();
const nextByTeam = new Map();
const lastByTeam = new Map();
for (const f of sorted) {
if (!f.homeTeam || !f.awayTeam) continue;
const cls = classifyStatus(f.status);
counts[cls] = (counts[cls] || 0) + 1;
const ts = Date.parse(f.utcDate || '') || 0;
if (cls === 'scheduled' && ts >= now) {
// First-seen wins (sorted ascending → earliest).
if (!nextByTeam.has(f.homeTeam)) {
nextByTeam.set(f.homeTeam, {
opponent: f.awayTeam, venue: f.venue, isHome: true,
utcDate: f.utcDate, status: f.status, league,
daysUntil: Math.max(0, Math.round((ts - now) / 86_400_000)),
referee: f.referee || null,
});
}
if (!nextByTeam.has(f.awayTeam)) {
nextByTeam.set(f.awayTeam, {
opponent: f.homeTeam, venue: f.venue, isHome: false,
utcDate: f.utcDate, status: f.status, league,
daysUntil: Math.max(0, Math.round((ts - now) / 86_400_000)),
referee: f.referee || null,
});
}
} else if (cls === 'finished') {
// Latest-seen wins → overwrite on each iteration since sorted asc.
lastByTeam.set(f.homeTeam, { utcDate: f.utcDate, opponent: f.awayTeam, isHome: true, score: f.score, league });
lastByTeam.set(f.awayTeam, { utcDate: f.utcDate, opponent: f.homeTeam, isHome: false, score: f.score, league });
}
}
// Persist. Don't block on individual failures — Redis errors fail
// gracefully inside cacheSet.
const writes = [];
for (const [team, payload] of nextByTeam) {
writes.push(cacheSet(`soccer:nextmatch:${team}`, payload, NEXT_MATCH_TTL_SEC));
}
for (const [team, payload] of lastByTeam) {
writes.push(cacheSet(`soccer:lastfixture:${team}`, payload, LAST_FIXTURE_TTL_SEC));
}
await Promise.all(writes);
return counts;
}
async function tick() {
const leagues = parseLeagues();
const summary = [];
let liveSeen = false;
for (const league of leagues) {
const fixtures = await fetchLeagueFixtures(league);
if (fixtures === null) {
summary.push(`${league}: no_data`);
continue;
}
const counts = await indexFixturesForLeague(league, fixtures);
summary.push(`${league}: ${fixtures.length} matches (scheduled=${counts.scheduled} live=${counts.live} finished=${counts.finished})`);
if (counts.live > 0) liveSeen = true;
}
console.log(`[poller-soccer] tick — ${summary.join(', ') || 'no leagues configured'}`);
return { liveSeen, summary };
}
// Production run loop. Self-rescheduling — interval depends on whether
// any league has a live match.
async function run() {
let stopped = false;
process.on('SIGTERM', () => { stopped = true; });
process.on('SIGINT', () => { stopped = true; });
while (!stopped) {
let liveSeen = false;
try {
const result = await tick();
liveSeen = !!result?.liveSeen;
} catch (err) {
console.warn('[poller-soccer] tick error (continuing):', err.message);
}
const interval = liveSeen ? POLL_INTERVAL_LIVE_MS : POLL_INTERVAL_OFF_MS;
await new Promise((resolve) => setTimeout(resolve, interval));
}
console.log('[poller-soccer] shutting down');
}
if (require.main === module) {
// Only run the loop when invoked directly (PM2). Importing the module
// from tests must NOT start the loop.
run().catch((err) => {
console.error('[poller-soccer] fatal:', err);
process.exit(1);
});
}
module.exports = {
tick,
__internals: {
parseLeagues,
classifyStatus,
fetchWorldCupFixtures,
fetchLeagueFixtures,
indexFixturesForLeague,
POLL_INTERVAL_OFF_MS,
POLL_INTERVAL_LIVE_MS,
NEXT_MATCH_TTL_SEC,
LAST_FIXTURE_TTL_SEC,
},
};
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env node
/**
* Daily soccer intelligence prefetch — run once per day.
*
* cron: 0 5 * * * (5am UTC, ~midnight ET — before US fixtures)
* call: node scripts/soccer-data-prefetch.js [--leagues=WC,PL] [--dry-run]
*
* Why: football-data.org caps at 10 req/min and ~10/day for some
* endpoints. We can't read these on the user request path. This script
* batches the reads, transforms them into the per-player / per-team
* aggregates the feature extractor consumes, and persists them to
* Redis with conservative TTLs.
*
* Writes:
* soccer:{league}:standings — raw standings from API
* soccer:{league}:scorers — top-scorers list (projected)
* soccer:player:{normalizedName} — per-player aggregate (per-90 rates)
* soccer:teamdefense:{league}:{team} — team defensive aggregate + normalized rank
*
* Does NOT write next-match / last-fixture pointers — those are the
* job of the poller (poller/soccer.js), which runs more frequently
* since fixture state changes faster.
*
* xG data (`xg_per_90`, `xg_delta`) is left null on Day 1 — sourcing
* it requires a soccerdata-Python bridge that's a follow-up. The
* downstream feature extractor handles null xG gracefully.
*
* No DB writes. Graceful exit (code 0) when API keys are missing — the
* script logs "skipped" and the feature extractor continues with the
* static-data-only path.
*/
const fbd = require('../src/services/adapters/footballDataAdapter');
const { cacheSet } = require('../src/utils/redis');
const { normalizeName } = require('../src/utils/normalize');
const PLAYER_TTL_SEC = 24 * 3600;
const STANDINGS_TTL_SEC = 12 * 3600;
const SCORERS_TTL_SEC = 6 * 3600;
const DEFENSE_TTL_SEC = 12 * 3600;
function parseArgs(argv) {
const args = { leagues: ['WC'], dryRun: false };
for (const a of argv.slice(2)) {
if (a.startsWith('--leagues=')) {
args.leagues = a.slice('--leagues='.length).split(',').map((s) => s.trim().toUpperCase()).filter(Boolean);
} else if (a === '--dry-run') {
args.dryRun = true;
}
}
// env override falls through if no CLI value was given.
if (!process.argv.some((a) => a.startsWith('--leagues='))) {
const env = process.env.SOCCER_LEAGUES;
if (env) args.leagues = env.split(',').map((s) => s.trim().toUpperCase()).filter(Boolean);
}
return args;
}
// Project a single team's standings row into the defensive aggregate
// the feature extractor reads. defensive_rank_norm is on a 0..1 scale
// (0 = best defense, 1 = worst) so it slots into engine1's opp_rank_stat.
function aggregateTeamDefense(standingsRow, allRows) {
const playedGames = standingsRow.playedGames || standingsRow.played || 0;
const goalsAgainst = standingsRow.goalsAgainst ?? null;
if (!playedGames || goalsAgainst == null) return null;
const goalsConcededPerGame = goalsAgainst / playedGames;
// Normalize against the rest of the table — defensive_rank_norm = the
// team's goals-conceded percentile (0 best, 1 worst).
const allRates = allRows
.map((r) => {
const pg = r.playedGames || r.played || 0;
if (!pg) return null;
return (r.goalsAgainst ?? 0) / pg;
})
.filter((v) => Number.isFinite(v))
.sort((a, b) => a - b);
let rank = allRates.findIndex((v) => v >= goalsConcededPerGame);
if (rank === -1) rank = allRates.length - 1;
const rankNorm = allRates.length > 1 ? rank / (allRates.length - 1) : 0;
// Clean sheets (not on the football-data row in the free tier — null is OK).
const cleanSheets = standingsRow.cleanSheets ?? null;
const cleanSheetRate = cleanSheets != null && playedGames > 0
? cleanSheets / playedGames
: null;
return {
goals_conceded_per_game: Math.round(goalsConcededPerGame * 1000) / 1000,
clean_sheet_rate: cleanSheetRate,
defensive_rank: rank + 1, // 1-indexed for human reasoning
defensive_rank_norm: rankNorm, // 0..1 for engine1
played_games: playedGames,
};
}
// Project a single scorer row into the per-player aggregate.
function aggregatePlayerFromScorer(scorerRow) {
// Number(null) is 0 — explicit null check so a missing minutes field
// doesn't pretend the player played 0 minutes (which would still
// satisfy Number.isFinite and break the per-90 fallback).
const minutes = scorerRow.minutesPlayed == null ? null : Number(scorerRow.minutesPlayed);
const goals = Number(scorerRow.goals) || 0;
const assists = Number(scorerRow.assists) || 0;
const played = Number(scorerRow.playedMatches) || 0;
// Per-90 rates need minutes. The free tier sometimes omits minutes —
// fall back to (goals / played) when missing.
const goalsPer90 = Number.isFinite(minutes) && minutes > 0
? Math.round((goals / (minutes / 90)) * 1000) / 1000
: (played > 0 ? Math.round((goals / played) * 1000) / 1000 : null);
const assistsPer90 = Number.isFinite(minutes) && minutes > 0
? Math.round((assists / (minutes / 90)) * 1000) / 1000
: (played > 0 ? Math.round((assists / played) * 1000) / 1000 : null);
const minutesPerGame = Number.isFinite(minutes) && played > 0
? Math.round(minutes / played)
: null;
return {
team: scorerRow.team,
position: scorerRow.position,
nationality: scorerRow.nationality,
goals,
assists,
played,
minutes: Number.isFinite(minutes) ? minutes : null,
goals_per_90: goalsPer90,
assists_per_90: assistsPer90,
minutes_per_game: minutesPerGame,
// Day 1 — no rolling 5-match form, no xG. The feature extractor
// falls back to season_per_90 when recent_form_per_90 is null.
recent_form_per_90: null,
season_per_90: goalsPer90,
start_rate: null,
xg_per_90: null,
xa_per_90: null,
xg_delta: null,
};
}
async function processLeague(league, { dryRun }) {
const summary = { league, standings: 0, scorers: 0, players: 0, teamDefense: 0, skipped: false };
const [standings, scorers] = await Promise.all([
fbd.getLeagueStandings(league),
fbd.getLeagueScorers(league),
]);
// Either null means "API unavailable" — log + bail for this league.
if (standings === null && scorers === null) {
summary.skipped = true;
return summary;
}
// ---- Standings → team defensive aggregates ----
// football-data wraps standings in groups (type === 'TOTAL' has the
// table). Flatten all `table` rows so a competition with multiple
// groups (e.g. World Cup group stage) feeds one combined rank table.
if (Array.isArray(standings)) {
const allRows = [];
for (const group of standings) {
if (Array.isArray(group?.table)) {
for (const row of group.table) {
if (row?.team?.name) allRows.push({ ...row, teamName: row.team.name });
}
}
}
summary.standings = allRows.length;
for (const row of allRows) {
const agg = aggregateTeamDefense(row, allRows);
if (!agg) continue;
const key = `soccer:teamdefense:${league.toLowerCase()}:${row.teamName}`;
if (!dryRun) await cacheSet(key, agg, DEFENSE_TTL_SEC);
summary.teamDefense += 1;
}
if (!dryRun) await cacheSet(`soccer:${league.toLowerCase()}:standings`, standings, STANDINGS_TTL_SEC);
}
// ---- Scorers → per-player aggregates ----
if (Array.isArray(scorers)) {
summary.scorers = scorers.length;
for (const s of scorers) {
if (!s?.name) continue;
const profile = aggregatePlayerFromScorer(s);
const key = `soccer:player:${normalizeName(s.name)}`;
if (!dryRun) await cacheSet(key, profile, PLAYER_TTL_SEC);
summary.players += 1;
}
if (!dryRun) await cacheSet(`soccer:${league.toLowerCase()}:scorers`, scorers, SCORERS_TTL_SEC);
}
return summary;
}
async function main(argv = process.argv) {
const args = parseArgs(argv);
const startTs = Date.now();
console.log(`[soccer-prefetch] starting — leagues=${args.leagues.join(',')} dry_run=${args.dryRun}`);
if (!fbd.hasApiKey()) {
console.warn('[soccer-prefetch] FOOTBALL_DATA_API_KEY not set — skipping. WC fixtures still flow via the OSS API in poller/soccer.js; non-WC leagues are no-ops until the key is configured.');
return { skipped: true };
}
const results = [];
for (const league of args.leagues) {
try {
const r = await processLeague(league, args);
results.push(r);
console.log(`[soccer-prefetch] ${league}: standings=${r.standings} scorers=${r.scorers} players=${r.players} teamDefense=${r.teamDefense} ${r.skipped ? '(skipped: no_data)' : ''}`);
} catch (err) {
console.warn(`[soccer-prefetch] ${league} failed:`, err.message);
results.push({ league, error: err.message });
}
}
const elapsed = Math.round((Date.now() - startTs) / 1000);
console.log(`[soccer-prefetch] done in ${elapsed}s — ${results.length} leagues processed`);
return { results, elapsedSec: elapsed };
}
if (require.main === module) {
main().then(() => process.exit(0)).catch((err) => {
console.error('[soccer-prefetch] fatal:', err);
process.exit(1);
});
}
module.exports = {
main,
__internals: {
parseArgs,
aggregateTeamDefense,
aggregatePlayerFromScorer,
processLeague,
PLAYER_TTL_SEC,
STANDINGS_TTL_SEC,
SCORERS_TTL_SEC,
DEFENSE_TTL_SEC,
},
};
+236
View File
@@ -0,0 +1,236 @@
/**
* FIFA World Cup 2026 reference data — June 11July 19, 2026.
*
* Static, hand-curated. The poller pulls fixtures/standings/squads from
* APIs (football-data.org + worldcup2026 OSS); this file holds the
* intelligence the APIs don't carry: venue altitudes, host-continent
* teams, designated penalty/set-piece takers, and historical tournament
* performers.
*
* Updated manually as the tournament progresses (squad confirmations,
* penalty-taker shifts, etc.). Not consumed during normal cache misses —
* the feature extractor reads this once per process load.
*
* Venue altitudes sourced from public elevation data (NOAA / Google
* Earth, ft above sea level). Estadio Akron is in Zapopan (Guadalajara
* metro) at ~5,100 ft; Estadio Azteca's stadium floor sits at ~7,349 ft
* — the highest altitude in tournament history for the host venue.
*/
const VENUES = Object.freeze({
// United States (11)
'AT&T Stadium': { city: 'Arlington, TX', altitude_ft: 600, climate: 'hot', country: 'USA' },
'Mercedes-Benz Stadium': { city: 'Atlanta, GA', altitude_ft: 1050, climate: 'hot_humid', country: 'USA' },
'Gillette Stadium': { city: 'Foxborough, MA', altitude_ft: 260, climate: 'temperate', country: 'USA' },
'NRG Stadium': { city: 'Houston, TX', altitude_ft: 80, climate: 'hot_humid', country: 'USA' },
'Arrowhead Stadium': { city: 'Kansas City, MO', altitude_ft: 820, climate: 'temperate', country: 'USA' },
'SoFi Stadium': { city: 'Inglewood, CA', altitude_ft: 100, climate: 'temperate', country: 'USA' },
'Hard Rock Stadium': { city: 'Miami Gardens, FL', altitude_ft: 10, climate: 'hot_humid', country: 'USA' },
'MetLife Stadium': { city: 'East Rutherford, NJ', altitude_ft: 7, climate: 'temperate', country: 'USA' },
'Lincoln Financial Field': { city: 'Philadelphia, PA', altitude_ft: 30, climate: 'temperate', country: 'USA' },
"Levi's Stadium": { city: 'Santa Clara, CA', altitude_ft: 33, climate: 'temperate', country: 'USA' },
'Lumen Field': { city: 'Seattle, WA', altitude_ft: 15, climate: 'cool', country: 'USA' },
// Canada (2)
'BMO Field': { city: 'Toronto, ON', altitude_ft: 250, climate: 'temperate', country: 'Canada' },
'BC Place': { city: 'Vancouver, BC', altitude_ft: 33, climate: 'cool', country: 'Canada' },
// Mexico (3)
'Estadio Azteca': { city: 'Mexico City', altitude_ft: 7349, climate: 'altitude', country: 'Mexico' },
'Estadio BBVA': { city: 'Monterrey', altitude_ft: 1765, climate: 'hot', country: 'Mexico' },
'Estadio Akron': { city: 'Guadalajara (Zapopan)', altitude_ft: 5138, climate: 'altitude', country: 'Mexico' },
});
// Host-continent teams (CONCACAF) — historically benefit from reduced
// travel, fan support, and altitude acclimation. The 2026 hosts (USA,
// Canada, Mexico) auto-qualified; the rest qualified through CONCACAF.
const CONCACAF_TEAMS = Object.freeze([
'USA', 'Canada', 'Mexico', 'Costa Rica', 'Jamaica', 'Honduras',
'Panama', 'El Salvador', 'Haiti', 'Trinidad and Tobago',
'Guatemala', 'Curacao', 'Suriname',
]);
// CONMEBOL teams travel less than European/African squads and are
// historically strong in 2026-style climates. Used as a softer secondary
// modifier (not full home-continent advantage).
const CONMEBOL_TEAMS = Object.freeze([
'Argentina', 'Brazil', 'Uruguay', 'Colombia', 'Ecuador', 'Paraguay',
'Peru', 'Chile', 'Venezuela', 'Bolivia',
]);
// Designated penalty takers — primary plus secondary fallback if primary
// is off the pitch. Sourced from each team's most recent qualifier or
// pre-tournament friendly; updated as confirmations come in.
//
// Penalty-taker status adds ~0.15 goals per 90 to the player's base rate.
// Keys are team names matching football-data.org's `team.name` field;
// values are arrays in preference order.
const PENALTY_TAKERS = Object.freeze({
'Argentina': ['Lionel Messi', 'Lautaro Martínez'],
'Brazil': ['Vinicius Junior', 'Neymar'],
'France': ['Kylian Mbappé', 'Antoine Griezmann'],
'England': ['Harry Kane', 'Bukayo Saka'],
'Portugal': ['Cristiano Ronaldo', 'Bruno Fernandes'],
'Spain': ['Álvaro Morata', 'Mikel Merino'],
'Germany': ['Kai Havertz', 'İlkay Gündoğan'],
'Netherlands': ['Memphis Depay', 'Cody Gakpo'],
'Belgium': ['Romelu Lukaku', 'Kevin De Bruyne'],
'Italy': ['Jorginho', 'Lorenzo Pellegrini'],
'Croatia': ['Luka Modrić', 'Andrej Kramarić'],
'Uruguay': ['Darwin Núñez', 'Federico Valverde'],
'Colombia': ['James Rodríguez', 'Luis Díaz'],
'Mexico': ['Raúl Jiménez', 'Hirving Lozano'],
'USA': ['Christian Pulisic', 'Folarin Balogun'],
'Canada': ['Jonathan David', 'Alphonso Davies'],
'Morocco': ['Hakim Ziyech', 'Achraf Hakimi'],
'Senegal': ['Sadio Mané', 'Ismaïla Sarr'],
'Japan': ['Takefusa Kubo', 'Wataru Endō'],
'South Korea': ['Son Heung-min', 'Hwang Hee-chan'],
'Australia': ['Jackson Irvine', 'Mitchell Duke'],
'Switzerland': ['Granit Xhaka', 'Xherdan Shaqiri'],
'Poland': ['Robert Lewandowski', 'Piotr Zieliński'],
'Denmark': ['Christian Eriksen', 'Pierre-Emile Højbjerg'],
'Serbia': ['Aleksandar Mitrović', 'Dušan Vlahović'],
});
// Designated corner takers (set-piece delivery role). Multi-name arrays
// reflect rotation; the first is the most common deliverer. Corner-taker
// status meaningfully boosts assist probability for headed goals.
const CORNER_TAKERS = Object.freeze({
'Argentina': ['Lionel Messi', 'Ángel Di María'],
'Brazil': ['Lucas Paquetá', 'Bruno Guimarães'],
'France': ['Antoine Griezmann', 'Kylian Mbappé'],
'England': ['Bukayo Saka', 'Phil Foden', 'Trent Alexander-Arnold'],
'Portugal': ['Bruno Fernandes', 'João Cancelo'],
'Spain': ['Dani Olmo', 'Mikel Merino'],
'Germany': ['Joshua Kimmich', 'Toni Kroos'],
'Netherlands': ['Frenkie de Jong', 'Cody Gakpo'],
'Belgium': ['Kevin De Bruyne', 'Yannick Carrasco'],
'Italy': ['Lorenzo Pellegrini', 'Federico Chiesa'],
'Croatia': ['Luka Modrić', 'Mateo Kovačić'],
'Uruguay': ['Federico Valverde', 'Giorgian de Arrascaeta'],
'Mexico': ['Andrés Guardado', 'Hirving Lozano'],
'USA': ['Christian Pulisic', 'Gio Reyna'],
'Canada': ['Stephen Eustáquio', 'Tajon Buchanan'],
});
// Direct free-kick specialists. These players take long-range and
// dangerous-area free kicks. Boosts both goal AND assist probability
// when a foul is drawn in shooting range.
const FREE_KICK_TAKERS = Object.freeze({
'Argentina': ['Lionel Messi'],
'Brazil': ['Neymar', 'Vinicius Junior'],
'France': ['Kylian Mbappé'],
'England': ['Trent Alexander-Arnold', 'Bukayo Saka'],
'Portugal': ['Cristiano Ronaldo', 'Bruno Fernandes'],
'Spain': ['Dani Olmo'],
'Germany': ['Joshua Kimmich'],
'Belgium': ['Kevin De Bruyne'],
'Italy': ['Federico Chiesa'],
'Croatia': ['Luka Modrić'],
'Colombia': ['James Rodríguez'],
'Mexico': ['Raúl Jiménez'],
'USA': ['Christian Pulisic'],
'Morocco': ['Hakim Ziyech'],
'South Korea': ['Son Heung-min'],
'Poland': ['Piotr Zieliński'],
'Serbia': ['Dušan Tadić'],
});
// "Tournament players" — historical World Cup performers with three or
// more career WC goals. These names lift the prior on big-game scoring.
// Threshold: >=3 career WC goals OR >=2 in the most recent WC.
const TOURNAMENT_PLAYERS = Object.freeze({
'Lionel Messi': { wc_goals_career: 13, wc_appearances: 26 },
'Cristiano Ronaldo': { wc_goals_career: 8, wc_appearances: 22 },
'Kylian Mbappé': { wc_goals_career: 12, wc_appearances: 14 },
'Harry Kane': { wc_goals_career: 8, wc_appearances: 11 },
'Neymar': { wc_goals_career: 8, wc_appearances: 16 },
'Olivier Giroud': { wc_goals_career: 5, wc_appearances: 18 },
'Antoine Griezmann': { wc_goals_career: 6, wc_appearances: 17 },
'Romelu Lukaku': { wc_goals_career: 5, wc_appearances: 11 },
'Luka Modrić': { wc_goals_career: 2, wc_appearances: 18 }, // captain bias
'Robert Lewandowski': { wc_goals_career: 2, wc_appearances: 8 },
'Karim Benzema': { wc_goals_career: 3, wc_appearances: 11 },
'Edinson Cavani': { wc_goals_career: 4, wc_appearances: 14 },
'Luis Suárez': { wc_goals_career: 7, wc_appearances: 14 },
'Andrés Guardado': { wc_goals_career: 1, wc_appearances: 20 }, // captain bias
'Thomas Müller': { wc_goals_career: 10, wc_appearances: 16 },
'Eden Hazard': { wc_goals_career: 3, wc_appearances: 11 },
'Hirving Lozano': { wc_goals_career: 2, wc_appearances: 7 },
'Sadio Mané': { wc_goals_career: 1, wc_appearances: 5 },
});
// Lookup helpers — case-insensitive on player names, exact-match on
// team names. Each returns a primitive so the feature extractor can
// drop the result straight into the feature vector.
function isPenaltyTaker(playerName, teamName) {
if (!playerName || !teamName) return false;
const takers = PENALTY_TAKERS[teamName];
if (!Array.isArray(takers)) return false;
const p = String(playerName).toLowerCase();
return takers.some((t) => t.toLowerCase() === p);
}
function isCornerTaker(playerName, teamName) {
if (!playerName || !teamName) return false;
const takers = CORNER_TAKERS[teamName];
if (!Array.isArray(takers)) return false;
const p = String(playerName).toLowerCase();
return takers.some((t) => t.toLowerCase() === p);
}
function isFreeKickTaker(playerName, teamName) {
if (!playerName || !teamName) return false;
const takers = FREE_KICK_TAKERS[teamName];
if (!Array.isArray(takers)) return false;
const p = String(playerName).toLowerCase();
return takers.some((t) => t.toLowerCase() === p);
}
function getTournamentHistory(playerName) {
if (!playerName) return null;
// Exact match first, then case-insensitive scan.
if (TOURNAMENT_PLAYERS[playerName]) return TOURNAMENT_PLAYERS[playerName];
const p = String(playerName).toLowerCase();
for (const [name, history] of Object.entries(TOURNAMENT_PLAYERS)) {
if (name.toLowerCase() === p) return history;
}
return null;
}
function isHomeContinent(teamName) {
if (!teamName) return false;
return CONCACAF_TEAMS.includes(teamName);
}
function getVenue(venueName) {
if (!venueName) return null;
return VENUES[venueName] || null;
}
// Classify altitude impact for non-acclimatized teams. The historical
// goal-output reduction kicks in around 1,500 ft and gets material above
// 4,000 ft (per CSIC studies on player physiology at altitude).
function altitudeImpact(altitudeFt) {
if (!Number.isFinite(altitudeFt)) return 'none';
if (altitudeFt >= 4000) return 'high';
if (altitudeFt >= 1500) return 'moderate';
return 'none';
}
module.exports = {
VENUES,
CONCACAF_TEAMS,
CONMEBOL_TEAMS,
PENALTY_TAKERS,
CORNER_TAKERS,
FREE_KICK_TAKERS,
TOURNAMENT_PLAYERS,
isPenaltyTaker,
isCornerTaker,
isFreeKickTaker,
getTournamentHistory,
isHomeContinent,
getVenue,
altitudeImpact,
};
+5
View File
@@ -48,8 +48,13 @@ async function cachedAnalyze(prop) {
} }
const VALID_STAT_TYPES = new Set([ const VALID_STAT_TYPES = new Set([
// NBA / WNBA
'points', 'rebounds', 'assists', 'threes', 'blocks', 'points', 'rebounds', 'assists', 'threes', 'blocks',
'steals', 'pra', 'turnovers', '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']); const VALID_DIRECTIONS = new Set(['over', 'under']);
+38
View File
@@ -147,4 +147,42 @@ router.get('/ncaab', async (req, res) => {
} }
}); });
// Session 7j — soccer odds route. League is a path segment so each
// league has its own cache key (`odds:soccer_wc:2026-06-15` etc.) and
// queries don't cross-pollute. Falls through to getOdds → odds-api on
// demand; cached 15min like every other sport.
const { SOCCER_SPORT_KEYS } = require('../services/oddsService');
const SOCCER_KEY_SET = new Set(SOCCER_SPORT_KEYS);
router.get('/soccer/:league', async (req, res) => {
const leagueKey = `soccer_${String(req.params.league || '').toLowerCase()}`;
if (!SOCCER_KEY_SET.has(leagueKey)) {
return res.status(400).json({
error: `Unknown soccer league. Valid: ${SOCCER_SPORT_KEYS.map((k) => k.replace('soccer_', '')).join(', ')}.`,
});
}
const errors = validateQueryParams(req.query);
if (errors.length > 0) {
return res.status(400).json({ error: errors.join('; ') });
}
try {
const result = await getOdds(leagueKey);
const filtered = filterProps(result.props || [], req.query);
const props = groupProps(filtered);
if (result.stale) res.set('X-VYNDR-Stale', 'true');
return res.json({
sport: leagueKey,
updated_at: result.updated_at,
source: result.source,
quota_remaining: result.quota_remaining,
props,
});
} catch (err) {
const status = err.statusCode || 500;
return res.status(status).json({ error: err.message });
}
});
module.exports = router; module.exports = router;
+4
View File
@@ -6,8 +6,12 @@ const { scanParlay } = require('../services/parlayScanService');
const router = express.Router(); const router = express.Router();
const VALID_STAT_TYPES = new Set([ const VALID_STAT_TYPES = new Set([
// NBA / WNBA
'points', 'rebounds', 'assists', 'threes', 'blocks', 'points', 'rebounds', 'assists', 'threes', 'blocks',
'steals', 'pra', 'turnovers', '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']); 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; },
},
};
+76 -1
View File
@@ -30,11 +30,83 @@ function explainErrors(errors) {
return errors.map((e) => ERROR_EXPLANATIONS[e] || `Data gap: ${e}.`).join(' '); 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 // Build a human-readable reasoning summary + steps from the actual
// features (which carry real numbers) and engine1's grade. // features (which carry real numbers) and engine1's grade.
function buildConcreteReasoning(features = {}, engine1Result = {}, meta = {}, prop = {}) { 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';
const lines = isSoccer
? buildSoccerReasoningLines(features, meta, prop)
: [];
if (!isSoccer) {
// Recent form vs the line — L5 and L20 are the orchestrator's // Recent form vs the line — L5 and L20 are the orchestrator's
// canonical season-trend signals. // canonical season-trend signals.
if (Number.isFinite(features.l5_avg)) { if (Number.isFinite(features.l5_avg)) {
@@ -56,7 +128,9 @@ function buildConcreteReasoning(features = {}, engine1Result = {}, meta = {}, pr
// Home / away. // Home / away.
if (features.home_away === 1.0) lines.push('Playing at home tonight.'); 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.'); else if (features.home_away === 0.0) lines.push('Playing on the road tonight.');
}
if (!isSoccer) {
// Opponent matchup. opp_rank_stat is 0..1 normalized // Opponent matchup. opp_rank_stat is 0..1 normalized
// (0 = best D, 1 = worst D) — translate to friendlier language. // (0 = best D, 1 = worst D) — translate to friendlier language.
if (Number.isFinite(features.opp_rank_stat) && meta.opponentAbbr) { if (Number.isFinite(features.opp_rank_stat) && meta.opponentAbbr) {
@@ -82,6 +156,7 @@ function buildConcreteReasoning(features = {}, engine1Result = {}, meta = {}, pr
if (Number.isFinite(features.injury_severity_score) && features.injury_severity_score > 0) { 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.`); lines.push(`${features.injury_severity_score} opponent starter(s) on the injury report.`);
} }
}
// Trap composite — surfaced when meaningful. // Trap composite — surfaced when meaningful.
// (Adapter handles the per-factor kill_conditions chips; this line // (Adapter handles the per-factor kill_conditions chips; this line
+28 -2
View File
@@ -29,6 +29,9 @@ const featureCache = require('./featureCache');
const trapDetection = require('./trapDetection'); const trapDetection = require('./trapDetection');
const consistencyScore = require('./consistencyScore'); const consistencyScore = require('./consistencyScore');
const gameLogService = require('./gameLogService'); 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; const HTTP_TIMEOUT_MS = 8_000;
@@ -121,14 +124,37 @@ async function safeGetConsistency({ playerName, sport, statType }) {
} }
async function computeFeaturesForProp(rawProp = {}) { 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 errors = [];
const player = rawProp.player; const player = rawProp.player;
const statType = rawProp.stat_type || rawProp.statType; const statType = rawProp.stat_type || rawProp.statType;
const line = Number(rawProp.line); const line = Number(rawProp.line);
const direction = rawProp.direction || 'over'; const direction = rawProp.direction || 'over';
const book = rawProp.book || 'unknown'; 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)) { if (!player || !statType || !Number.isFinite(line)) {
errors.push('missing required fields (player, stat_type, or 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], ['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) { function recommend(composite) {
if (composite >= 0.5) return 'avoid'; if (composite >= 0.5) return 'avoid';
if (composite >= 0.25) return 'caution'; if (composite >= 0.25) return 'caution';
@@ -207,8 +316,14 @@ function recommend(composite) {
} }
async function getTrapScore(input = {}) { 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 = {}; const signals = {};
for (const [name, fn] of SIGNALS) { for (const [name, fn] of signalList) {
try { try {
const result = await fn(input); const result = await fn(input);
signals[name] = result; signals[name] = result;
@@ -216,8 +331,10 @@ async function getTrapScore(input = {}) {
signals[name] = { score: 0, active: false, explanation: `error: ${err?.message || 'unknown'}` }; 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) const activeScores = Object.values(signals)
.filter((s) => s.active) .filter((s) => s.active && !s.positive)
.map((s) => s.score); .map((s) => s.score);
const composite = activeScores.length === 0 const composite = activeScores.length === 0
? 0 ? 0
@@ -242,6 +359,12 @@ module.exports = {
signalJuiceDegradation, signalJuiceDegradation,
signalTeammateReturnTrap, signalTeammateReturnTrap,
signalLineConsensusDivergence, signalLineConsensusDivergence,
signalXgRegression,
signalAltitudeRisk,
signalRotationRisk,
signalMinuteDiscount,
signalRefereeCardBias,
signalStrongDefense,
recommend, recommend,
}, },
}; };
+25 -1
View File
@@ -4,7 +4,29 @@ const { normalizeProps, extractSpreads, MARKET_MAP } = require('../utils/oddsNor
const ODDS_API_BASE = 'https://api.the-odds-api.com/v4/sports'; const ODDS_API_BASE = 'https://api.the-odds-api.com/v4/sports';
const CACHE_TTL = 900; // 15 minutes in seconds 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 ALL_MARKETS = Object.keys(MARKET_MAP).join(',') + ',spreads';
const BOOKMAKERS = 'draftkings,fanduel,betmgm,caesars,fanatics,bet365,hardrockbet,pointsbet,betrivers'; const BOOKMAKERS = 'draftkings,fanduel,betmgm,caesars,fanatics,bet365,hardrockbet,pointsbet,betrivers';
@@ -201,6 +223,8 @@ module.exports = {
fetchEventsFromApi, fetchEventsFromApi,
fetchEventOddsFromApi, fetchEventOddsFromApi,
getCacheKey, getCacheKey,
SPORT_KEYS,
SOCCER_SPORT_KEYS,
getQuotaKey, getQuotaKey,
updateQuota, updateQuota,
getQuotaRemaining, getQuotaRemaining,
+14
View File
@@ -3,6 +3,7 @@ const { getAbbreviation } = require('./teamMap');
const ALLOWED_BOOKS = new Set(['draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365', 'hardrockbet', 'pointsbet', 'betrivers']); const ALLOWED_BOOKS = new Set(['draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365', 'hardrockbet', 'pointsbet', 'betrivers']);
const MARKET_MAP = { const MARKET_MAP = {
// NBA / WNBA props
player_points: 'points', player_points: 'points',
player_rebounds: 'rebounds', player_rebounds: 'rebounds',
player_assists: 'assists', player_assists: 'assists',
@@ -11,6 +12,19 @@ const MARKET_MAP = {
player_steals: 'steals', player_steals: 'steals',
player_points_rebounds_assists: 'pra', player_points_rebounds_assists: 'pra',
player_turnovers: 'turnovers', 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) { function normalizeProps(eventsWithOdds) {
+97
View File
@@ -0,0 +1,97 @@
const request = require('supertest');
const mockGetOdds = jest.fn();
jest.mock('../../src/services/oddsService', () => {
const actual = jest.requireActual('../../src/services/oddsService');
return {
...actual,
getOdds: (...args) => mockGetOdds(...args),
};
});
const mockRedis = { get: jest.fn(), set: jest.fn(), hset: jest.fn(), hgetall: jest.fn(), expire: jest.fn() };
jest.mock('../../src/utils/redis', () => ({
getRedisClient: () => mockRedis,
cacheGet: async () => null,
cacheSet: async () => true,
cacheDel: async () => true,
isDegraded: () => false,
}));
const { SOCCER_SPORT_KEYS } = require('../../src/services/oddsService');
const app = require('../../src/app');
beforeEach(() => {
mockGetOdds.mockReset();
mockRedis.get.mockResolvedValue(null);
});
describe('SOCCER_SPORT_KEYS export', () => {
test('contains all 9 launch leagues', () => {
expect(SOCCER_SPORT_KEYS).toEqual(expect.arrayContaining([
'soccer_wc',
'soccer_epl',
'soccer_laliga',
'soccer_bundesliga',
'soccer_seriea',
'soccer_ligue1',
'soccer_ucl',
'soccer_mls',
'soccer_ligamx',
]));
expect(SOCCER_SPORT_KEYS).toHaveLength(9);
});
});
describe('GET /api/odds/soccer/:league', () => {
test('valid league reaches getOdds with the prefixed key', async () => {
mockGetOdds.mockResolvedValueOnce({
props: [], updated_at: '2026-06-15T00:00:00Z', source: 'live', quota_remaining: 4000,
});
const res = await request(app).get('/api/odds/soccer/wc').expect(200);
expect(mockGetOdds).toHaveBeenCalledWith('soccer_wc');
expect(res.body.sport).toBe('soccer_wc');
expect(Array.isArray(res.body.props)).toBe(true);
});
test('unknown league returns 400 with valid-list hint', async () => {
const res = await request(app).get('/api/odds/soccer/spaceleague').expect(400);
expect(res.body.error).toMatch(/Unknown soccer league/);
expect(res.body.error).toMatch(/wc/);
expect(mockGetOdds).not.toHaveBeenCalled();
});
test('EPL route works (proves it is not WC-only)', async () => {
mockGetOdds.mockResolvedValueOnce({
props: [{ player: 'X', stat_type: 'goals', line: 0.5 }], updated_at: '2026-06-15T00:00:00Z', source: 'cache',
});
const res = await request(app).get('/api/odds/soccer/epl').expect(200);
expect(mockGetOdds).toHaveBeenCalledWith('soccer_epl');
expect(res.body.sport).toBe('soccer_epl');
});
test('case-insensitive league path', async () => {
mockGetOdds.mockResolvedValueOnce({ props: [], updated_at: 't', source: 'cache' });
await request(app).get('/api/odds/soccer/LIGAMX').expect(200);
expect(mockGetOdds).toHaveBeenCalledWith('soccer_ligamx');
});
test('getOdds throwing surfaces as a status code, not a 500 leak', async () => {
const err = new Error('Odds data temporarily unavailable.');
err.statusCode = 429;
mockGetOdds.mockRejectedValueOnce(err);
const res = await request(app).get('/api/odds/soccer/wc').expect(429);
expect(res.body.error).toMatch(/temporarily unavailable/);
});
});
describe('existing NBA/NCAAB routes still work (no regression)', () => {
test('/api/odds/nba still returns the NBA shape', async () => {
mockGetOdds.mockResolvedValueOnce({
props: [], updated_at: 't', source: 'cache', quota_remaining: 4000,
});
const res = await request(app).get('/api/odds/nba').expect(200);
expect(res.body.sport).toBe('nba');
expect(mockGetOdds).toHaveBeenCalledWith('nba');
});
});
+131
View File
@@ -0,0 +1,131 @@
// Soccer reasoning tests. We mock computeFeaturesForProp so the test
// only exercises buildConcreteReasoning's soccer branch + the
// downstream toLegacyShape adapter; data layer is out of scope here.
const mockComputeFeaturesForProp = jest.fn();
jest.mock('../../src/services/intelligence/computeFeatures', () => ({
computeFeaturesForProp: (...args) => mockComputeFeaturesForProp(...args),
}));
const { analyzeViaEngine1 } = require('../../src/services/intelligence/analyzeViaEngine1');
function soccerFeatureResult(features = {}, meta = {}) {
return {
features: {
l5_avg: null,
l20_avg: null,
home_away: null,
opp_rank_stat: null,
rest_days: null,
...features,
},
trap: { composite: 0, signals: {}, active_count: 0, recommendation: 'proceed' },
consistency: { consistency: 'unknown', score: null, games: 0 },
prop: { line: 0.5, direction: 'over' },
meta: {
player: 'Test Player', statType: 'goals', line: 0.5, direction: 'over',
book: 'unknown', sport: 'soccer', league: 'WC',
teamAbbr: 'England', opponentAbbr: 'Brazil',
venue: 'MetLife Stadium', referee: null,
isHome: true, gameLogs: [], errors: [],
...meta,
},
};
}
beforeEach(() => {
mockComputeFeaturesForProp.mockReset();
});
describe('analyzeViaEngine1 — soccer reasoning', () => {
test('uses "matches" language and surfaces goals_per_90', async () => {
mockComputeFeaturesForProp.mockResolvedValueOnce(soccerFeatureResult({
goals_per_90: 0.82,
}));
const result = await analyzeViaEngine1({
player: 'Harry Kane', stat_type: 'goals', line: 0.5, direction: 'over', sport: 'soccer',
});
expect(result.reasoning.summary).toMatch(/0\.82 goals per 90 minutes/);
// Sanity: no NBA-flavored language.
expect(result.reasoning.summary).not.toMatch(/last 5 games/);
expect(result.reasoning.summary).not.toMatch(/back-to-back/i);
});
test('xG overperformance triggers the regression line', async () => {
mockComputeFeaturesForProp.mockResolvedValueOnce(soccerFeatureResult({
goals_per_90: 1.2, xg_per_90: 0.7, xg_delta: 0.71,
}));
const result = await analyzeViaEngine1({
player: 'Striker', stat_type: 'goals', line: 0.5, direction: 'over', sport: 'soccer',
});
expect(result.reasoning.summary).toMatch(/Expected goals \(xG\): 0\.70 per 90/);
expect(result.reasoning.summary).toMatch(/overperforming.*regression risk/i);
});
test('penalty taker status surfaced when true', async () => {
mockComputeFeaturesForProp.mockResolvedValueOnce(soccerFeatureResult({
goals_per_90: 0.5, is_penalty_taker: true,
}));
const result = await analyzeViaEngine1({
player: 'PK Taker', stat_type: 'goals', line: 0.5, direction: 'over', sport: 'soccer',
});
expect(result.reasoning.summary).toMatch(/Designated penalty taker/);
});
test('altitude impact surfaces with venue context', async () => {
mockComputeFeaturesForProp.mockResolvedValueOnce(soccerFeatureResult({
altitude_impact: 'high', venue_altitude_ft: 7349, home_continent: false,
}, { venue: 'Estadio Azteca' }));
const result = await analyzeViaEngine1({
player: 'Visitor', stat_type: 'goals', line: 0.5, direction: 'over', sport: 'soccer',
});
expect(result.reasoning.summary).toMatch(/7349ft altitude/);
expect(result.reasoning.summary).toMatch(/non-acclimatized/i);
});
test('low minutes per game triggers the discount note', async () => {
mockComputeFeaturesForProp.mockResolvedValueOnce(soccerFeatureResult({
goals_per_90: 0.5, minutes_per_game: 58,
}));
const result = await analyzeViaEngine1({
player: 'Rotation Player', stat_type: 'goals', line: 0.5, direction: 'over', sport: 'soccer',
});
expect(result.reasoning.summary).toMatch(/58 minutes per match/);
expect(result.reasoning.summary).toMatch(/line may assume full 90/);
});
test('referee card rate surfaces when present', async () => {
mockComputeFeaturesForProp.mockResolvedValueOnce(soccerFeatureResult({
referee_cards_per_game: 5.4, referee_name: 'Anthony Taylor',
}));
const result = await analyzeViaEngine1({
player: 'Anyone', stat_type: 'cards', line: 0.5, direction: 'over', sport: 'soccer',
});
expect(result.reasoning.summary).toMatch(/Anthony Taylor averages 5\.4 cards per match/);
});
test('soccer path skips NBA-only sentences (no injuries / no back-to-back)', async () => {
// Even if soccer features somehow carry an injury_severity_score (they
// shouldn't), the soccer branch must not surface it with NBA language.
mockComputeFeaturesForProp.mockResolvedValueOnce(soccerFeatureResult({
goals_per_90: 0.5, injury_severity_score: 3, game_count_in_7d: 5,
}));
const result = await analyzeViaEngine1({
player: 'Player', stat_type: 'goals', line: 0.5, direction: 'over', sport: 'soccer',
});
expect(result.reasoning.summary).not.toMatch(/opponent starter\(s\)/i);
expect(result.reasoning.summary).not.toMatch(/games in the last week/i);
expect(result.reasoning.summary).not.toMatch(/back-to-back/i);
});
test('engine1 grade closer still applies on soccer (sport-agnostic)', async () => {
mockComputeFeaturesForProp.mockResolvedValueOnce(soccerFeatureResult({
goals_per_90: 1.5, l5_avg: 1.5, l20_avg: 1.2,
}));
const result = await analyzeViaEngine1({
player: 'Top Scorer', stat_type: 'goals', line: 0.5, direction: 'over', sport: 'soccer',
});
// The engine grade line is appended for every sport.
expect(result.reasoning.summary).toMatch(/Engine 1 graded/);
});
});
@@ -0,0 +1,80 @@
// Verify computeFeaturesForProp routes soccer → soccerFeatureExtractor
// and NBA → existing path. The NBA path's full behavior is covered by
// computeFeatures.test.js (existing).
const mockExtractSoccerFeatures = jest.fn();
jest.mock('../../src/services/intelligence/soccerFeatureExtractor', () => ({
extractSoccerFeatures: (...args) => mockExtractSoccerFeatures(...args),
isSoccerSport: (s) => ['soccer', 'football'].includes(String(s || '').toLowerCase()),
}));
// Mock the rest of the upstream chain — none of it should be called on
// the soccer branch.
jest.mock('../../src/utils/supabase', () => ({
getSupabaseServiceClient: () => ({ from: jest.fn() }),
}));
jest.mock('axios');
jest.mock('../../src/services/intelligence/featureCache', () => ({
getFeatures: jest.fn(),
}));
jest.mock('../../src/services/intelligence/trapDetection', () => ({
getTrapScore: jest.fn(async () => ({ composite: 0.2, signals: {}, active_count: 1, recommendation: 'caution' })),
}));
jest.mock('../../src/services/intelligence/consistencyScore', () => ({
getConsistency: jest.fn(),
}));
jest.mock('../../src/services/intelligence/gameLogService', () => ({
getGameLogs: jest.fn(async () => []),
}));
const { computeFeaturesForProp } = require('../../src/services/intelligence/computeFeatures');
beforeEach(() => {
mockExtractSoccerFeatures.mockReset();
});
describe('computeFeaturesForProp — sport dispatch', () => {
test('sport=soccer routes to soccerFeatureExtractor (NBA path NOT invoked)', async () => {
mockExtractSoccerFeatures.mockResolvedValueOnce({
features: { goals_per_90: 0.4 },
trap: { composite: 0, signals: {}, active_count: 0, recommendation: 'proceed' },
consistency: { consistency: 'unknown', score: null, games: 0 },
prop: { line: 0.5, direction: 'over' },
meta: { player: 'Test', sport: 'soccer', statType: 'goals', errors: [] },
});
const result = await computeFeaturesForProp({
player: 'Test', stat_type: 'goals', line: 0.5, direction: 'over', sport: 'soccer',
});
expect(mockExtractSoccerFeatures).toHaveBeenCalledTimes(1);
expect(result.features.goals_per_90).toBe(0.4);
expect(result.meta.sport).toBe('soccer');
// The branch re-runs trap detection so the trap object is populated.
expect(result.trap.composite).toBeGreaterThanOrEqual(0);
});
test('sport=football is normalized into the soccer branch', async () => {
mockExtractSoccerFeatures.mockResolvedValueOnce({
features: {}, trap: {}, consistency: {}, prop: {}, meta: { sport: 'soccer', errors: [] },
});
await computeFeaturesForProp({ player: 'X', stat_type: 'goals', line: 0.5, sport: 'football' });
expect(mockExtractSoccerFeatures).toHaveBeenCalledTimes(1);
});
test('sport=nba does NOT invoke the soccer extractor', async () => {
const featureCache = require('../../src/services/intelligence/featureCache');
featureCache.getFeatures.mockResolvedValueOnce({ features: { l5_avg: 28 } });
await computeFeaturesForProp({
player: 'Jokic', stat_type: 'points', line: 26.5, direction: 'over', sport: 'nba',
});
expect(mockExtractSoccerFeatures).not.toHaveBeenCalled();
});
test('sport omitted defaults to NBA (legacy contract)', async () => {
const featureCache = require('../../src/services/intelligence/featureCache');
featureCache.getFeatures.mockResolvedValueOnce({ features: { l5_avg: 30 } });
await computeFeaturesForProp({ player: 'A', stat_type: 'points', line: 25, direction: 'over' });
expect(mockExtractSoccerFeatures).not.toHaveBeenCalled();
});
});
+182
View File
@@ -0,0 +1,182 @@
// Mock axios and the Redis cache surface BEFORE requiring the adapter so
// jest's module-mock hoisting captures the calls.
const mockAxiosGet = jest.fn();
jest.mock('axios', () => ({ get: (...args) => mockAxiosGet(...args) }));
const mockCacheStore = new Map();
jest.mock('../../src/utils/redis', () => ({
cacheGet: async (k) => (mockCacheStore.has(k) ? mockCacheStore.get(k) : null),
cacheSet: async (k, v) => { mockCacheStore.set(k, v); return true; },
cacheDel: async (k) => { mockCacheStore.delete(k); return true; },
isDegraded: () => false,
}));
const adapter = require('../../src/services/adapters/footballDataAdapter');
beforeEach(() => {
mockAxiosGet.mockReset();
mockCacheStore.clear();
adapter.__internals.resetBucketForTests();
});
describe('footballDataAdapter', () => {
describe('graceful degradation when API key is missing', () => {
const original = process.env.FOOTBALL_DATA_API_KEY;
beforeAll(() => { delete process.env.FOOTBALL_DATA_API_KEY; });
afterAll(() => { if (original !== undefined) process.env.FOOTBALL_DATA_API_KEY = original; });
test('hasApiKey reports false', () => {
expect(adapter.hasApiKey()).toBe(false);
});
test('getWorldCupFixtures returns null (does NOT hit axios)', async () => {
const result = await adapter.getWorldCupFixtures();
expect(result).toBeNull();
expect(mockAxiosGet).not.toHaveBeenCalled();
});
test('getTeamSquad returns null (does NOT hit axios)', async () => {
const result = await adapter.getTeamSquad(42);
expect(result).toBeNull();
expect(mockAxiosGet).not.toHaveBeenCalled();
});
});
describe('happy path with API key configured', () => {
beforeAll(() => { process.env.FOOTBALL_DATA_API_KEY = 'test-key-123'; });
test('getLeagueFixtures projects API response to stable shape', async () => {
mockAxiosGet.mockResolvedValueOnce({
data: {
matches: [
{
id: 1, homeTeam: { name: 'England' }, awayTeam: { name: 'Brazil' },
utcDate: '2026-06-15T20:00:00Z', status: 'SCHEDULED',
score: { winner: null }, matchday: 1, venue: 'MetLife Stadium',
},
],
},
});
const fixtures = await adapter.getLeagueFixtures('WC');
expect(Array.isArray(fixtures)).toBe(true);
expect(fixtures).toHaveLength(1);
expect(fixtures[0]).toMatchObject({
id: 1, homeTeam: 'England', awayTeam: 'Brazil', status: 'SCHEDULED',
matchday: 1, venue: 'MetLife Stadium', competition: 'WC',
});
expect(mockAxiosGet).toHaveBeenCalledTimes(1);
// Auth header carries the API key — never logged elsewhere.
const [, opts] = mockAxiosGet.mock.calls[0];
expect(opts.headers['X-Auth-Token']).toBe('test-key-123');
});
test('second identical call serves from cache (axios not re-invoked)', async () => {
mockAxiosGet.mockResolvedValueOnce({ data: { matches: [{ id: 7 }] } });
await adapter.getLeagueFixtures('PL');
await adapter.getLeagueFixtures('PL');
expect(mockAxiosGet).toHaveBeenCalledTimes(1);
});
test('different competition codes use separate cache keys', async () => {
mockAxiosGet
.mockResolvedValueOnce({ data: { matches: [{ id: 1 }] } })
.mockResolvedValueOnce({ data: { matches: [{ id: 2 }] } });
await adapter.getLeagueFixtures('PL');
await adapter.getLeagueFixtures('PD');
expect(mockAxiosGet).toHaveBeenCalledTimes(2);
});
test('getLeagueScorers projects to flat shape with goals + assists', async () => {
mockAxiosGet.mockResolvedValueOnce({
data: {
scorers: [
{
player: { name: 'Harry Kane', position: 'Striker', nationality: 'England' },
team: { name: 'England' },
goals: 5, assists: 1, playedMatches: 4, minutesPlayed: 360,
},
],
},
});
const scorers = await adapter.getLeagueScorers('WC');
expect(scorers[0]).toMatchObject({
name: 'Harry Kane', team: 'England', goals: 5, assists: 1,
playedMatches: 4, minutesPlayed: 360,
});
});
test('getTeamSquad projects squad rows with position and shirt', async () => {
mockAxiosGet.mockResolvedValueOnce({
data: { squad: [{ id: 9, name: 'Pulisic', position: 'Forward', shirtNumber: 10 }] },
});
const squad = await adapter.getTeamSquad(101);
expect(squad[0]).toMatchObject({ id: 9, name: 'Pulisic', position: 'Forward', shirtNumber: 10 });
});
test('empty/missing arrays in upstream → empty list (not null)', async () => {
mockAxiosGet.mockResolvedValueOnce({ data: {} });
const fixtures = await adapter.getLeagueFixtures('WC');
expect(fixtures).toEqual([]);
});
test('axios throw → returns null (graceful degradation)', async () => {
mockAxiosGet.mockRejectedValueOnce(new Error('network down'));
const fixtures = await adapter.getLeagueFixtures('WC');
expect(fixtures).toBeNull();
});
test('axios throw + prior :stale value → stale-while-revalidate', async () => {
// Prime the stale cache.
mockCacheStore.set('soccer:wc:fixtures:stale', { matches: [{ id: 999 }] });
mockAxiosGet.mockRejectedValueOnce(new Error('upstream 500'));
const fixtures = await adapter.getLeagueFixtures('WC');
// The stale value goes through the same projection.
expect(Array.isArray(fixtures)).toBe(true);
expect(fixtures).toHaveLength(1);
expect(fixtures[0].id).toBe(999);
});
});
describe('token bucket rate limiting', () => {
beforeAll(() => { process.env.FOOTBALL_DATA_API_KEY = 'test-key-123'; });
test('refuses network call when bucket is drained, falls to stale', async () => {
// Drain the bucket by consuming all tokens.
for (let i = 0; i < adapter.__internals.BUCKET_MAX; i += 1) {
expect(adapter.__internals.tryConsumeToken()).toBe(true);
}
// Next consume should fail.
expect(adapter.__internals.tryConsumeToken()).toBe(false);
// Prime a stale value.
mockCacheStore.set('soccer:wc:fixtures:stale', { matches: [{ id: 42 }] });
const fixtures = await adapter.getLeagueFixtures('WC');
expect(fixtures[0].id).toBe(42);
// Critically: axios was NOT called — the bucket short-circuited.
expect(mockAxiosGet).not.toHaveBeenCalled();
});
test('returns null when bucket drained AND no stale value', async () => {
for (let i = 0; i < adapter.__internals.BUCKET_MAX; i += 1) {
adapter.__internals.tryConsumeToken();
}
const fixtures = await adapter.getLeagueFixtures('UNKNOWN_LEAGUE');
expect(fixtures).toBeNull();
expect(mockAxiosGet).not.toHaveBeenCalled();
});
});
describe('input guards', () => {
test('getLeagueFixtures(null) returns null without touching network', async () => {
const r = await adapter.getLeagueFixtures(null);
expect(r).toBeNull();
expect(mockAxiosGet).not.toHaveBeenCalled();
});
test('getTeamSquad(null) returns null without touching network', async () => {
const r = await adapter.getTeamSquad(null);
expect(r).toBeNull();
expect(mockAxiosGet).not.toHaveBeenCalled();
});
});
});
+13 -1
View File
@@ -89,7 +89,7 @@ describe('oddsNormalizer', () => {
expect(result[0].book).toBe('draftkings'); expect(result[0].book).toBe('draftkings');
}); });
it('maps all 8 market keys to correct internal stat_types', () => { it('maps every market key to its internal stat_type (NBA + soccer)', () => {
const markets = Object.entries(MARKET_MAP); const markets = Object.entries(MARKET_MAP);
const bookmaker = makeBookmaker( const bookmaker = makeBookmaker(
'draftkings', 'draftkings',
@@ -109,6 +109,18 @@ describe('oddsNormalizer', () => {
expect(statTypes).toEqual(expected); expect(statTypes).toEqual(expected);
}); });
it('exposes the soccer market keys added in Session 7j', () => {
// Sanity: soccer odds flow through the same normalizer as NBA. If a
// future refactor splits MARKET_MAP per-sport, this test makes the
// surface visible.
const soccerStatTypes = ['goals', 'shots_on_target', 'shots', 'tackles',
'cards', 'corners', 'saves', 'goals_conceded', 'passes', 'clean_sheet'];
const values = Object.values(MARKET_MAP);
for (const t of soccerStatTypes) {
expect(values).toContain(t);
}
});
it('handles missing/null odds gracefully (skips incomplete outcomes)', () => { it('handles missing/null odds gracefully (skips incomplete outcomes)', () => {
const event = makeEvent({ const event = makeEvent({
bookmakers: [ bookmakers: [
+190
View File
@@ -0,0 +1,190 @@
// Soccer daily prefetch — tests the data transforms + Redis writes via
// the cache-write spy. The football-data adapter is mocked at the
// module boundary so no network is touched.
const mockGetLeagueStandings = jest.fn();
const mockGetLeagueScorers = jest.fn();
const mockHasApiKey = jest.fn(() => true);
jest.mock('../../src/services/adapters/footballDataAdapter', () => ({
getLeagueStandings: (...a) => mockGetLeagueStandings(...a),
getLeagueScorers: (...a) => mockGetLeagueScorers(...a),
hasApiKey: (...a) => mockHasApiKey(...a),
}));
const mockCacheSets = new Map();
jest.mock('../../src/utils/redis', () => ({
cacheGet: async () => null,
cacheSet: async (k, v, ttl) => { mockCacheSets.set(k, { value: v, ttl }); return true; },
cacheDel: async () => true,
isDegraded: () => false,
}));
const { normalizeName } = require('../../src/utils/normalize');
const prefetch = require('../../scripts/soccer-data-prefetch');
beforeEach(() => {
mockGetLeagueStandings.mockReset();
mockGetLeagueScorers.mockReset();
mockHasApiKey.mockReset().mockReturnValue(true);
mockCacheSets.clear();
});
describe('soccer-data-prefetch', () => {
describe('parseArgs', () => {
test('default leagues=[WC]', () => {
const a = prefetch.__internals.parseArgs(['node', 'script']);
expect(a.leagues).toEqual(['WC']);
expect(a.dryRun).toBe(false);
});
test('--leagues=WC,PL,PD parsed and uppercased', () => {
const a = prefetch.__internals.parseArgs(['node', 'script', '--leagues=wc,pl,pd']);
expect(a.leagues).toEqual(['WC', 'PL', 'PD']);
});
test('--dry-run flag', () => {
const a = prefetch.__internals.parseArgs(['node', 'script', '--dry-run']);
expect(a.dryRun).toBe(true);
});
});
describe('aggregateTeamDefense', () => {
const { aggregateTeamDefense } = prefetch.__internals;
test('computes goals_conceded_per_game and rank from full table', () => {
const allRows = [
{ teamName: 'Italy', goalsAgainst: 3, playedGames: 10 }, // 0.30 — best
{ teamName: 'England', goalsAgainst: 6, playedGames: 10 }, // 0.60
{ teamName: 'France', goalsAgainst: 9, playedGames: 10 }, // 0.90 — worst
];
const italy = aggregateTeamDefense(allRows[0], allRows);
const england = aggregateTeamDefense(allRows[1], allRows);
const france = aggregateTeamDefense(allRows[2], allRows);
expect(italy.goals_conceded_per_game).toBeCloseTo(0.3);
expect(italy.defensive_rank).toBe(1);
expect(italy.defensive_rank_norm).toBeCloseTo(0);
expect(england.defensive_rank).toBe(2);
expect(england.defensive_rank_norm).toBeCloseTo(0.5);
expect(france.defensive_rank).toBe(3);
expect(france.defensive_rank_norm).toBeCloseTo(1);
});
test('returns null when row has no played games', () => {
const result = aggregateTeamDefense({ goalsAgainst: 0, playedGames: 0 }, []);
expect(result).toBeNull();
});
test('clean_sheet_rate null when API does not provide it', () => {
const allRows = [{ goalsAgainst: 2, playedGames: 4 }];
const r = aggregateTeamDefense(allRows[0], allRows);
expect(r.clean_sheet_rate).toBeNull();
});
});
describe('aggregatePlayerFromScorer', () => {
const { aggregatePlayerFromScorer } = prefetch.__internals;
test('per-90 rates computed from minutes when present', () => {
const r = aggregatePlayerFromScorer({
name: 'Kane', team: 'England', goals: 3, assists: 1, playedMatches: 4, minutesPlayed: 360,
});
// 3 goals / (360/90) = 0.75 per 90.
expect(r.goals_per_90).toBeCloseTo(0.75);
expect(r.assists_per_90).toBeCloseTo(0.25);
expect(r.minutes_per_game).toBe(90);
});
test('per-90 falls back to per-match when minutes are missing', () => {
const r = aggregatePlayerFromScorer({
name: 'X', team: 'Y', goals: 4, assists: 2, playedMatches: 4, minutesPlayed: null,
});
// No minutes data → use goals/played as a rough proxy.
expect(r.goals_per_90).toBeCloseTo(1.0);
expect(r.minutes_per_game).toBeNull();
});
test('xG fields are explicitly null on Day 1', () => {
const r = aggregatePlayerFromScorer({ name: 'X', goals: 1, assists: 0, playedMatches: 2 });
expect(r.xg_per_90).toBeNull();
expect(r.xg_delta).toBeNull();
});
});
describe('processLeague — Redis writes', () => {
test('writes one teamdefense key per table row + one player key per scorer', async () => {
mockGetLeagueStandings.mockResolvedValueOnce([
{
type: 'TOTAL',
table: [
{ team: { name: 'Italy' }, goalsAgainst: 2, playedGames: 5 },
{ team: { name: 'France' }, goalsAgainst: 8, playedGames: 5 },
],
},
]);
mockGetLeagueScorers.mockResolvedValueOnce([
{ name: 'Harry Kane', team: 'England', goals: 5, assists: 1, playedMatches: 4, minutesPlayed: 360 },
{ name: 'Vinicius Junior', team: 'Brazil', goals: 3, assists: 2, playedMatches: 4, minutesPlayed: 340 },
]);
const summary = await prefetch.__internals.processLeague('WC', { dryRun: false });
expect(summary.standings).toBe(2);
expect(summary.scorers).toBe(2);
expect(summary.players).toBe(2);
expect(summary.teamDefense).toBe(2);
// Cache keys present at the right path.
expect(mockCacheSets.has('soccer:teamdefense:wc:Italy')).toBe(true);
expect(mockCacheSets.has('soccer:teamdefense:wc:France')).toBe(true);
expect(mockCacheSets.has(`soccer:player:${normalizeName('Harry Kane')}`)).toBe(true);
expect(mockCacheSets.has(`soccer:player:${normalizeName('Vinicius Junior')}`)).toBe(true);
expect(mockCacheSets.has('soccer:wc:standings')).toBe(true);
expect(mockCacheSets.has('soccer:wc:scorers')).toBe(true);
// TTLs match the constants.
const kaneEntry = mockCacheSets.get(`soccer:player:${normalizeName('Harry Kane')}`);
expect(kaneEntry.ttl).toBe(prefetch.__internals.PLAYER_TTL_SEC);
const italyEntry = mockCacheSets.get('soccer:teamdefense:wc:Italy');
expect(italyEntry.ttl).toBe(prefetch.__internals.DEFENSE_TTL_SEC);
});
test('dry-run computes summary but writes nothing', async () => {
mockGetLeagueStandings.mockResolvedValueOnce([
{ type: 'TOTAL', table: [{ team: { name: 'X' }, goalsAgainst: 1, playedGames: 1 }] },
]);
mockGetLeagueScorers.mockResolvedValueOnce([
{ name: 'X', team: 'X', goals: 1, assists: 0, playedMatches: 1, minutesPlayed: 90 },
]);
const summary = await prefetch.__internals.processLeague('WC', { dryRun: true });
expect(summary.players).toBeGreaterThan(0);
expect(mockCacheSets.size).toBe(0);
});
test('both API calls null → skipped flag', async () => {
mockGetLeagueStandings.mockResolvedValueOnce(null);
mockGetLeagueScorers.mockResolvedValueOnce(null);
const summary = await prefetch.__internals.processLeague('WC', { dryRun: false });
expect(summary.skipped).toBe(true);
expect(mockCacheSets.size).toBe(0);
});
});
describe('main — top-level entry', () => {
test('graceful skip when API key missing', async () => {
mockHasApiKey.mockReturnValueOnce(false);
const result = await prefetch.main(['node', 'script', '--leagues=WC']);
expect(result.skipped).toBe(true);
// Critically: adapter methods never invoked.
expect(mockGetLeagueStandings).not.toHaveBeenCalled();
});
test('processes each --leagues arg in turn', async () => {
mockGetLeagueStandings.mockResolvedValue([]);
mockGetLeagueScorers.mockResolvedValue([]);
await prefetch.main(['node', 'script', '--leagues=WC,PL', '--dry-run']);
expect(mockGetLeagueStandings).toHaveBeenCalledWith('WC');
expect(mockGetLeagueStandings).toHaveBeenCalledWith('PL');
});
});
});
+250
View File
@@ -0,0 +1,250 @@
// Mock Redis cache — populate per-test to simulate prefetched data.
const mockCacheStore = new Map();
jest.mock('../../src/utils/redis', () => ({
cacheGet: async (k) => (mockCacheStore.has(k) ? mockCacheStore.get(k) : null),
cacheSet: async (k, v) => { mockCacheStore.set(k, v); return true; },
cacheDel: async (k) => { mockCacheStore.delete(k); return true; },
isDegraded: () => false,
}));
const { normalizeName } = require('../../src/utils/normalize');
const extractor = require('../../src/services/intelligence/soccerFeatureExtractor');
beforeEach(() => {
mockCacheStore.clear();
});
function primePlayer(name, profile) {
mockCacheStore.set(`soccer:player:${normalizeName(name)}`, profile);
}
describe('soccerFeatureExtractor', () => {
describe('isSoccerSport', () => {
test('accepts soccer + football, rejects nba/mlb/random', () => {
expect(extractor.isSoccerSport('soccer')).toBe(true);
expect(extractor.isSoccerSport('SOCCER')).toBe(true);
expect(extractor.isSoccerSport('football')).toBe(true);
expect(extractor.isSoccerSport('nba')).toBe(false);
expect(extractor.isSoccerSport('mlb')).toBe(false);
expect(extractor.isSoccerSport(null)).toBe(false);
expect(extractor.isSoccerSport(undefined)).toBe(false);
});
});
describe('cache-miss path (everything null)', () => {
test('returns engine1-shaped result with errors flagged, no throw', async () => {
const result = await extractor.extractSoccerFeatures({
player: 'Unknown Player', stat_type: 'goals', line: 0.5, direction: 'over',
});
expect(result).toHaveProperty('features');
expect(result).toHaveProperty('trap');
expect(result).toHaveProperty('consistency');
expect(result).toHaveProperty('prop');
expect(result).toHaveProperty('meta');
// Critical: numeric features default to null (NOT 0 — 0 would
// confuse engine1 into thinking we have a real signal).
expect(result.features.goals_per_90).toBeNull();
expect(result.features.xg_delta).toBeNull();
expect(result.features.minutes_per_game).toBeNull();
// Errors surface the misses.
expect(result.meta.errors).toContain('player_not_found_in_cache');
expect(result.meta.errors).toContain('team_not_resolved');
});
test('does not throw on null player input — surfaces error', async () => {
const result = await extractor.extractSoccerFeatures({
stat_type: 'goals', line: 0.5,
});
expect(result.meta.errors).toEqual(
expect.arrayContaining([expect.stringMatching(/missing required fields/)])
);
});
});
describe('player profile resolution', () => {
test('reads cached profile by normalized name', async () => {
primePlayer('Harry Kane', {
team: 'England', goals_per_90: 0.85, assists_per_90: 0.15,
minutes_per_game: 84, start_rate: 0.95, season_per_90: 0.78,
recent_form_per_90: 1.05,
});
const result = await extractor.extractSoccerFeatures({
player: 'Harry Kane', stat_type: 'goals', line: 0.5, direction: 'over',
});
expect(result.features.goals_per_90).toBe(0.85);
expect(result.features.minutes_per_game).toBe(84);
expect(result.features.start_rate).toBe(0.95);
// engine1-canonical mapping: l5_avg from recent_form_per_90,
// l20_avg from season_per_90.
expect(result.features.l5_avg).toBe(1.05);
expect(result.features.l20_avg).toBe(0.78);
expect(result.meta.teamAbbr).toBe('England');
expect(result.meta.errors).not.toContain('player_not_found_in_cache');
});
});
describe('xG regression risk', () => {
test('fires when actual goals significantly outpace expected', async () => {
primePlayer('Striker A', {
team: 'France', goals_per_90: 1.2, xg_per_90: 0.7, xg_delta: 0.71,
});
const r = await extractor.extractSoccerFeatures({
player: 'Striker A', stat_type: 'goals', line: 1.5, direction: 'over',
});
expect(r.features.xg_regression_risk).toBe(true);
expect(r.features.xg_delta).toBeCloseTo(0.71);
});
test('does NOT fire when xG and goals track each other', async () => {
primePlayer('Striker B', { team: 'France', goals_per_90: 0.8, xg_per_90: 0.78, xg_delta: 0.025 });
const r = await extractor.extractSoccerFeatures({
player: 'Striker B', stat_type: 'goals', line: 0.5, direction: 'over',
});
expect(r.features.xg_regression_risk).toBe(false);
});
test('does NOT fire when xG data is missing', async () => {
primePlayer('Striker C', { team: 'France' });
const r = await extractor.extractSoccerFeatures({
player: 'Striker C', stat_type: 'goals', line: 0.5, direction: 'over',
});
expect(r.features.xg_regression_risk).toBe(false);
});
});
describe('venue + altitude + home-continent overlay', () => {
test('Estadio Azteca venue surfaces high altitude impact', async () => {
primePlayer('Player X', { team: 'England', goals_per_90: 0.5 });
mockCacheStore.set('soccer:nextmatch:England', {
opponent: 'USA', venue: 'Estadio Azteca', isHome: false, referee: 'Bjorn Kuipers',
});
const r = await extractor.extractSoccerFeatures({
player: 'Player X', stat_type: 'goals', line: 0.5, direction: 'over',
});
expect(r.features.venue_altitude_ft).toBeGreaterThan(7000);
expect(r.features.altitude_impact).toBe('high');
expect(r.features.home_continent).toBe(false); // England isn't CONCACAF
expect(r.features.home_away).toBe(0.0); // away match
});
test('USA at MetLife Stadium → home-continent true, altitude none', async () => {
primePlayer('Christian Pulisic', { team: 'USA', goals_per_90: 0.3 });
mockCacheStore.set('soccer:nextmatch:USA', {
opponent: 'Brazil', venue: 'MetLife Stadium', isHome: true, referee: null,
});
const r = await extractor.extractSoccerFeatures({
player: 'Christian Pulisic', stat_type: 'goals', line: 0.5, direction: 'over',
});
expect(r.features.home_continent).toBe(true);
expect(r.features.altitude_impact).toBe('none');
expect(r.features.home_away).toBe(1.0);
// Static-data lookup catches the penalty/corner role.
expect(r.features.is_penalty_taker).toBe(true);
});
});
describe('referee profile overlay', () => {
test('reads cards/penalties per game for upcoming referee', async () => {
primePlayer('Card Heavy', { team: 'Argentina', goals_per_90: 0.1 });
mockCacheStore.set('soccer:nextmatch:Argentina', {
opponent: 'Brazil', venue: 'MetLife Stadium', isHome: true, referee: 'Anthony Taylor',
});
mockCacheStore.set('soccer:referee:Anthony Taylor', {
cards_per_game: 5.4, penalties_per_game: 0.6,
});
const r = await extractor.extractSoccerFeatures({
player: 'Card Heavy', stat_type: 'cards', line: 0.5, direction: 'over',
});
expect(r.features.referee_cards_per_game).toBeCloseTo(5.4);
expect(r.features.referee_penalties_per_game).toBeCloseTo(0.6);
expect(r.features.referee_name).toBe('Anthony Taylor');
});
});
describe('rest_days from last fixture', () => {
test('computes days since last finished fixture', async () => {
primePlayer('Worn Out', { team: 'Brazil', goals_per_90: 0.6 });
const threeDaysAgo = new Date(Date.now() - 3 * 24 * 3600 * 1000).toISOString();
mockCacheStore.set('soccer:lastfixture:Brazil', { utcDate: threeDaysAgo });
const r = await extractor.extractSoccerFeatures({
player: 'Worn Out', stat_type: 'goals', line: 0.5, direction: 'over',
});
expect(r.features.rest_days).toBe(3);
});
test('returns null when no last fixture cached', async () => {
primePlayer('Fresh', { team: 'Croatia', goals_per_90: 0.4 });
const r = await extractor.extractSoccerFeatures({
player: 'Fresh', stat_type: 'goals', line: 0.5, direction: 'over',
});
expect(r.features.rest_days).toBeNull();
});
test('returns null for malformed utcDate', async () => {
primePlayer('Edge Case', { team: 'Portugal', goals_per_90: 0.4 });
mockCacheStore.set('soccer:lastfixture:Portugal', { utcDate: 'not-a-date' });
const r = await extractor.extractSoccerFeatures({
player: 'Edge Case', stat_type: 'goals', line: 0.5, direction: 'over',
});
expect(r.features.rest_days).toBeNull();
});
});
describe('opponent defense overlay', () => {
test('reads team defense aggregates from cache', async () => {
primePlayer('Forward', { team: 'England', goals_per_90: 0.6 });
mockCacheStore.set('soccer:nextmatch:England', {
opponent: 'Italy', venue: "Levi's Stadium", isHome: true, referee: null,
});
mockCacheStore.set('soccer:teamdefense:wc:Italy', {
goals_conceded_per_game: 0.4, clean_sheet_rate: 0.55,
defensive_rank: 3, defensive_rank_norm: 0.05, // top defense
});
const r = await extractor.extractSoccerFeatures({
player: 'Forward', stat_type: 'goals', line: 0.5, direction: 'over',
});
expect(r.features.opp_goals_conceded_per_game).toBeCloseTo(0.4);
expect(r.features.opp_clean_sheet_rate).toBeCloseTo(0.55);
expect(r.features.opp_rank_stat).toBeCloseTo(0.05); // engine1 reads this
});
});
describe('tournament history overlay', () => {
test('marks designated tournament players', async () => {
primePlayer('Lionel Messi', { team: 'Argentina', goals_per_90: 0.8 });
const r = await extractor.extractSoccerFeatures({
player: 'Lionel Messi', stat_type: 'goals', line: 0.5, direction: 'over',
});
expect(r.features.tournament_player).toBe(true);
expect(r.features.wc_goals_career).toBeGreaterThan(0);
});
test('non-tournament players get false', async () => {
primePlayer('Rookie One', { team: 'USA', goals_per_90: 0.2 });
const r = await extractor.extractSoccerFeatures({
player: 'Rookie One', stat_type: 'goals', line: 0.5, direction: 'over',
});
expect(r.features.tournament_player).toBe(false);
expect(r.features.wc_goals_career).toBeNull();
});
});
describe('shape compatibility with engine1', () => {
test('returns the same top-level keys as the NBA path', async () => {
primePlayer('Compat Test', { team: 'USA', goals_per_90: 0.3 });
const r = await extractor.extractSoccerFeatures({
player: 'Compat Test', stat_type: 'goals', line: 0.5, direction: 'over',
});
expect(Object.keys(r).sort()).toEqual(
['consistency', 'features', 'meta', 'prop', 'trap'].sort()
);
// meta carries the same NBA-path field names so callers can read
// teamAbbr / opponentAbbr / errors uniformly.
expect(r.meta).toHaveProperty('teamAbbr');
expect(r.meta).toHaveProperty('opponentAbbr');
expect(r.meta).toHaveProperty('errors');
expect(r.meta).toHaveProperty('sport', 'soccer');
});
});
});
+196
View File
@@ -0,0 +1,196 @@
// Soccer poller — tests the tick() function (the unit of work). Run
// loop intentionally not exercised: it's a sleep+repeat shape that
// would only test setTimeout.
const mockAxiosGet = jest.fn();
jest.mock('axios', () => ({ get: (...args) => mockAxiosGet(...args) }));
const mockCacheSets = new Map();
jest.mock('../../src/utils/redis', () => ({
cacheGet: async () => null,
cacheSet: async (k, v, ttl) => { mockCacheSets.set(k, { value: v, ttl }); return true; },
cacheDel: async () => true,
isDegraded: () => false,
}));
// Stub the football-data adapter so soccer poller's "non-WC league"
// branch is exercised without hitting the real API.
const mockFbdGetLeagueFixtures = jest.fn();
const mockFbdGetWorldCupFixtures = jest.fn();
jest.mock('../../src/services/adapters/footballDataAdapter', () => ({
getLeagueFixtures: (...a) => mockFbdGetLeagueFixtures(...a),
getWorldCupFixtures: (...a) => mockFbdGetWorldCupFixtures(...a),
hasApiKey: () => false,
}));
const soccerPoller = require('../../poller/soccer');
beforeEach(() => {
mockAxiosGet.mockReset();
mockFbdGetLeagueFixtures.mockReset();
mockFbdGetWorldCupFixtures.mockReset();
mockCacheSets.clear();
});
describe('soccer poller', () => {
describe('parseLeagues', () => {
test('defaults to WC when env var unset', () => {
const original = process.env.SOCCER_LEAGUES;
delete process.env.SOCCER_LEAGUES;
expect(soccerPoller.__internals.parseLeagues()).toEqual(['WC']);
if (original !== undefined) process.env.SOCCER_LEAGUES = original;
});
test('parses comma-separated list, uppercases, trims', () => {
const original = process.env.SOCCER_LEAGUES;
process.env.SOCCER_LEAGUES = 'wc, pl ,PD,bl1';
expect(soccerPoller.__internals.parseLeagues()).toEqual(['WC', 'PL', 'PD', 'BL1']);
if (original !== undefined) process.env.SOCCER_LEAGUES = original;
else delete process.env.SOCCER_LEAGUES;
});
});
describe('classifyStatus', () => {
const { classifyStatus } = soccerPoller.__internals;
test.each([
['IN_PLAY', 'live'],
['PAUSED', 'live'],
['LIVE', 'live'],
['FINISHED', 'finished'],
['FINAL', 'finished'],
['COMPLETED', 'finished'],
['SCHEDULED', 'scheduled'],
['TIMED', 'scheduled'],
['', 'scheduled'],
[null, 'scheduled'],
])('classifies %s → %s', (input, expected) => {
expect(classifyStatus(input)).toBe(expected);
});
});
describe('fetchWorldCupFixtures via OSS API', () => {
test('projects the OSS API response to the unified shape', async () => {
mockAxiosGet.mockResolvedValueOnce({
data: [
{
id: 1, home_team: 'England', away_team: 'Brazil',
utc_date: '2026-06-15T20:00:00Z', status: 'SCHEDULED',
matchday: 1, venue: 'MetLife Stadium',
},
],
});
const fixtures = await soccerPoller.__internals.fetchWorldCupFixtures();
expect(Array.isArray(fixtures)).toBe(true);
expect(fixtures[0]).toMatchObject({
id: 1, homeTeam: 'England', awayTeam: 'Brazil',
venue: 'MetLife Stadium', competition: 'WC',
});
});
test('axios throw → returns null (graceful)', async () => {
mockAxiosGet.mockRejectedValueOnce(new Error('OSS down'));
const fixtures = await soccerPoller.__internals.fetchWorldCupFixtures();
expect(fixtures).toBeNull();
});
test('handles both top-level array and {matches: [...]} envelopes', async () => {
mockAxiosGet.mockResolvedValueOnce({ data: { matches: [{ id: 9, homeTeam: 'X', awayTeam: 'Y' }] } });
const fixtures = await soccerPoller.__internals.fetchWorldCupFixtures();
expect(fixtures).toHaveLength(1);
expect(fixtures[0].id).toBe(9);
});
});
describe('fetchLeagueFixtures dispatch', () => {
test('WC prefers OSS API, falls back to football-data when OSS dies', async () => {
mockAxiosGet.mockRejectedValueOnce(new Error('OSS unreachable'));
mockFbdGetWorldCupFixtures.mockResolvedValueOnce([{ id: 1, homeTeam: 'A', awayTeam: 'B', competition: 'WC' }]);
const fixtures = await soccerPoller.__internals.fetchLeagueFixtures('WC');
expect(fixtures).toHaveLength(1);
expect(mockFbdGetWorldCupFixtures).toHaveBeenCalledTimes(1);
});
test('non-WC leagues use the football-data adapter', async () => {
mockFbdGetLeagueFixtures.mockResolvedValueOnce([{ id: 7, homeTeam: 'X', awayTeam: 'Y', competition: 'PL' }]);
const fixtures = await soccerPoller.__internals.fetchLeagueFixtures('PL');
expect(fixtures).toHaveLength(1);
expect(mockFbdGetLeagueFixtures).toHaveBeenCalledWith('PL');
});
});
describe('indexFixturesForLeague', () => {
test('writes per-team nextmatch + lastfixture keys', async () => {
const inFuture = new Date(Date.now() + 5 * 86_400_000).toISOString();
const inPast = new Date(Date.now() - 2 * 86_400_000).toISOString();
const counts = await soccerPoller.__internals.indexFixturesForLeague('WC', [
{ homeTeam: 'England', awayTeam: 'Brazil', utcDate: inFuture, status: 'SCHEDULED', venue: 'MetLife Stadium' },
{ homeTeam: 'USA', awayTeam: 'Mexico', utcDate: inPast, status: 'FINISHED', venue: 'AT&T Stadium', score: { fullTime: { home: 2, away: 1 } } },
]);
expect(counts.scheduled).toBe(1);
expect(counts.finished).toBe(1);
// Future fixture → next match for both teams.
expect(mockCacheSets.has('soccer:nextmatch:England')).toBe(true);
expect(mockCacheSets.has('soccer:nextmatch:Brazil')).toBe(true);
expect(mockCacheSets.get('soccer:nextmatch:England').value).toMatchObject({
opponent: 'Brazil', isHome: true, venue: 'MetLife Stadium',
});
expect(mockCacheSets.get('soccer:nextmatch:Brazil').value).toMatchObject({
opponent: 'England', isHome: false,
});
// Past finished → last fixture for both teams.
expect(mockCacheSets.has('soccer:lastfixture:USA')).toBe(true);
expect(mockCacheSets.has('soccer:lastfixture:Mexico')).toBe(true);
});
test('returns zero counts on empty input', async () => {
const counts = await soccerPoller.__internals.indexFixturesForLeague('WC', []);
expect(counts).toEqual({ scheduled: 0, live: 0, finished: 0 });
expect(mockCacheSets.size).toBe(0);
});
test('returns zero counts on null input (graceful)', async () => {
const counts = await soccerPoller.__internals.indexFixturesForLeague('WC', null);
expect(counts).toEqual({ scheduled: 0, live: 0, finished: 0 });
});
});
describe('tick', () => {
test('tick polls each configured league and reports live status', async () => {
const original = process.env.SOCCER_LEAGUES;
process.env.SOCCER_LEAGUES = 'WC,PL';
const inFuture = new Date(Date.now() + 1 * 86_400_000).toISOString();
// WC: OSS returns one live match.
mockAxiosGet.mockResolvedValueOnce({
data: [{ id: 1, home_team: 'A', away_team: 'B', utc_date: inFuture, status: 'IN_PLAY' }],
});
// PL: football-data adapter returns one scheduled.
mockFbdGetLeagueFixtures.mockResolvedValueOnce([
{ id: 2, homeTeam: 'X', awayTeam: 'Y', utcDate: inFuture, status: 'SCHEDULED' },
]);
const result = await soccerPoller.tick();
expect(result.liveSeen).toBe(true);
expect(result.summary.some((s) => s.startsWith('WC:'))).toBe(true);
expect(result.summary.some((s) => s.startsWith('PL:'))).toBe(true);
if (original !== undefined) process.env.SOCCER_LEAGUES = original;
else delete process.env.SOCCER_LEAGUES;
});
test('tick survives a league with no_data', async () => {
const original = process.env.SOCCER_LEAGUES;
process.env.SOCCER_LEAGUES = 'WC';
mockAxiosGet.mockRejectedValueOnce(new Error('OSS down'));
mockFbdGetWorldCupFixtures.mockResolvedValueOnce(null);
const result = await soccerPoller.tick();
expect(result.summary[0]).toMatch(/WC: no_data/);
if (original !== undefined) process.env.SOCCER_LEAGUES = original;
else delete process.env.SOCCER_LEAGUES;
});
});
});
+229
View File
@@ -0,0 +1,229 @@
// Soccer-branch tests for trapDetection. NBA path is covered by the
// existing trapDetection.test.js; we only need to verify the soccer
// signals fire on the right conditions and that the sport dispatch
// keeps the two trap sets isolated.
//
// Soccer signals are synchronous and pure over `input.features` — no
// Redis or DB mocks required.
const trap = require('../../src/services/intelligence/trapDetection');
function soccerInput(features = {}, statType = 'goals') {
return {
sport: 'soccer',
statType,
features: { stat_type: statType, ...features },
odds: { playerLine: 0.5, consensus: null },
};
}
describe('trapDetection — soccer branch', () => {
describe('getTrapScore dispatches on sport', () => {
test('soccer input runs ONLY the soccer signals (no NBA signals fire)', async () => {
const r = await trap.getTrapScore(soccerInput({ goals_per_90: 0.5 }));
const names = Object.keys(r.signals);
// The NBA names should be absent; the soccer names should be present.
expect(names).not.toContain('reverse_line_movement');
expect(names).not.toContain('historical_hit_rate_paradox');
expect(names).toContain('xg_regression');
expect(names).toContain('altitude_risk');
expect(names).toContain('rotation_risk');
expect(names).toContain('minute_discount');
expect(names).toContain('referee_card_bias');
expect(names).toContain('strong_defense');
});
test('nba input still runs the NBA signal set unchanged', async () => {
const r = await trap.getTrapScore({
sport: 'nba',
gameId: 'gX', playerName: 'A', statType: 'points',
odds: { playerLine: 25.5 },
});
const names = Object.keys(r.signals);
expect(names).toContain('reverse_line_movement');
expect(names).toContain('historical_hit_rate_paradox');
expect(names).not.toContain('xg_regression');
});
});
describe('signalXgRegression', () => {
test('fires when xg_delta > 0.3', () => {
const result = trap.__internals.signalXgRegression(
soccerInput({ xg_delta: 0.6 })
);
expect(result.active).toBe(true);
expect(result.score).toBeGreaterThan(0);
expect(result.explanation).toMatch(/above expected goals/);
});
test('does NOT fire when xg_delta is near zero', () => {
const result = trap.__internals.signalXgRegression(
soccerInput({ xg_delta: 0.05 })
);
expect(result.active).toBe(true);
expect(result.score).toBe(0);
});
test('inactive when xg_delta is null', () => {
const result = trap.__internals.signalXgRegression(soccerInput({ xg_delta: null }));
expect(result.active).toBe(false);
});
});
describe('signalAltitudeRisk', () => {
test('fires for non-host-continent team at high altitude', () => {
const r = trap.__internals.signalAltitudeRisk(
soccerInput({ altitude_impact: 'high', home_continent: false, venue_altitude_ft: 7349 })
);
expect(r.active).toBe(true);
expect(r.score).toBeGreaterThan(0);
expect(r.explanation).toMatch(/altitude/);
});
test('host-continent team gets a pass (acclimated)', () => {
const r = trap.__internals.signalAltitudeRisk(
soccerInput({ altitude_impact: 'high', home_continent: true })
);
expect(r.active).toBe(false);
});
test('moderate or no altitude → inactive', () => {
expect(trap.__internals.signalAltitudeRisk(
soccerInput({ altitude_impact: 'moderate', home_continent: false })
).active).toBe(false);
expect(trap.__internals.signalAltitudeRisk(
soccerInput({ altitude_impact: 'none' })
).active).toBe(false);
});
});
describe('signalRotationRisk', () => {
test('fires for low start_rate + short rest', () => {
const r = trap.__internals.signalRotationRisk(
soccerInput({ start_rate: 0.5, rest_days: 1 })
);
expect(r.active).toBe(true);
expect(r.score).toBeGreaterThan(0);
});
test('does NOT fire when start_rate is high', () => {
const r = trap.__internals.signalRotationRisk(
soccerInput({ start_rate: 0.95, rest_days: 1 })
);
expect(r.active).toBe(true);
expect(r.score).toBe(0);
});
test('does NOT fire when rest_days is sufficient', () => {
const r = trap.__internals.signalRotationRisk(
soccerInput({ start_rate: 0.5, rest_days: 5 })
);
expect(r.active).toBe(true);
expect(r.score).toBe(0);
});
test('inactive when fields missing', () => {
const r = trap.__internals.signalRotationRisk(soccerInput({}));
expect(r.active).toBe(false);
});
});
describe('signalMinuteDiscount', () => {
test('fires when minutes_per_game < 70', () => {
const r = trap.__internals.signalMinuteDiscount(soccerInput({ minutes_per_game: 55 }));
expect(r.active).toBe(true);
expect(r.score).toBeGreaterThan(0);
});
test('does NOT fire when minutes_per_game >= 70', () => {
const r = trap.__internals.signalMinuteDiscount(soccerInput({ minutes_per_game: 88 }));
expect(r.active).toBe(true);
expect(r.score).toBe(0);
});
});
describe('signalRefereeCardBias (POSITIVE)', () => {
test('marks positive signal for card-heavy ref on a CARDS prop', () => {
const r = trap.__internals.signalRefereeCardBias(
soccerInput({ referee_cards_per_game: 6.2, referee_name: 'Anthony Taylor' }, 'cards')
);
expect(r.positive).toBe(true);
expect(r.active).toBe(false); // explicit: positive signals do NOT count active
expect(r.score).toBe(0);
expect(r.explanation).toMatch(/favorable for card over/);
});
test('does NOT mark positive for a non-cards stat type', () => {
const r = trap.__internals.signalRefereeCardBias(
soccerInput({ referee_cards_per_game: 6.2 }, 'goals')
);
expect(r.positive).not.toBe(true);
expect(r.active).toBe(false);
});
test('composite EXCLUDES positive signals even when many fire', async () => {
// Drop one trap + one positive — composite should reflect ONLY the trap.
const r = await trap.getTrapScore(soccerInput(
{
xg_delta: 0.6, // trap fires
referee_cards_per_game: 6.5, // positive fires
},
'cards', // makes the positive applicable
));
expect(r.active_count).toBe(1); // only the xG regression counts
expect(r.composite).toBeGreaterThan(0);
});
});
describe('signalStrongDefense', () => {
test('fires for top-5 defense on goals over', () => {
const r = trap.__internals.signalStrongDefense(
soccerInput({ opp_defensive_rank: 3 }, 'goals')
);
expect(r.active).toBe(true);
expect(r.score).toBeGreaterThan(0);
});
test('fires for top-5 defense on shots_on_target', () => {
const r = trap.__internals.signalStrongDefense(
soccerInput({ opp_defensive_rank: 5 }, 'shots_on_target')
);
expect(r.active).toBe(true);
});
test('inactive for non-scoring stat types', () => {
const r = trap.__internals.signalStrongDefense(
soccerInput({ opp_defensive_rank: 3 }, 'cards')
);
expect(r.active).toBe(false);
});
test('does NOT fire when defense is mid-table', () => {
const r = trap.__internals.signalStrongDefense(
soccerInput({ opp_defensive_rank: 18 }, 'goals')
);
expect(r.active).toBe(true);
expect(r.score).toBe(0);
});
});
describe('composite scoring', () => {
test('multiple soccer traps → composite >= 0.5 → avoid', async () => {
const r = await trap.getTrapScore(soccerInput(
{
xg_delta: 0.5,
altitude_impact: 'high',
home_continent: false,
start_rate: 0.5,
rest_days: 1,
opp_defensive_rank: 3,
},
'goals',
));
expect(r.composite).toBeGreaterThanOrEqual(0.5);
expect(r.recommendation).toBe('avoid');
});
test('no soccer traps → composite 0 → proceed', async () => {
const r = await trap.getTrapScore(soccerInput({
xg_delta: 0.05,
altitude_impact: 'none',
start_rate: 0.95,
rest_days: 5,
minutes_per_game: 85,
opp_defensive_rank: 20,
}, 'goals'));
expect(r.composite).toBe(0);
expect(r.recommendation).toBe('proceed');
});
});
});
+118
View File
@@ -0,0 +1,118 @@
const wc = require('../../src/data/worldcup2026');
describe('worldcup2026 static reference data', () => {
describe('VENUES', () => {
test('has 16 venues (the official 2026 host venue count)', () => {
const count = Object.keys(wc.VENUES).length;
expect(count).toBe(16);
});
test('every venue has altitude_ft, climate, country, city', () => {
for (const [name, data] of Object.entries(wc.VENUES)) {
expect(typeof data.altitude_ft).toBe('number');
expect(typeof data.climate).toBe('string');
expect(typeof data.country).toBe('string');
expect(typeof data.city).toBe('string');
expect(['USA', 'Canada', 'Mexico']).toContain(data.country);
// Sanity check — no venue at impossible altitude.
expect(data.altitude_ft).toBeGreaterThanOrEqual(0);
expect(data.altitude_ft).toBeLessThan(10000);
// Test the data is shaped right, not the actual venue altitudes.
if (!name) throw new Error('empty venue name'); // touch `name`
}
});
test('Mexico City venue is the highest-altitude host site', () => {
const altitudes = Object.values(wc.VENUES).map((v) => v.altitude_ft);
const max = Math.max(...altitudes);
expect(wc.VENUES['Estadio Azteca'].altitude_ft).toBe(max);
expect(max).toBeGreaterThan(7000); // ~7,349 ft per public elevation data
});
});
describe('altitudeImpact', () => {
test('high above 4,000 ft', () => {
expect(wc.altitudeImpact(7349)).toBe('high');
expect(wc.altitudeImpact(5138)).toBe('high');
expect(wc.altitudeImpact(4001)).toBe('high');
});
test('moderate between 1,500 and 4,000 ft', () => {
expect(wc.altitudeImpact(1765)).toBe('moderate');
expect(wc.altitudeImpact(2000)).toBe('moderate');
});
test('none at sea level / typical US altitudes', () => {
expect(wc.altitudeImpact(7)).toBe('none');
expect(wc.altitudeImpact(820)).toBe('none');
expect(wc.altitudeImpact(1499)).toBe('none');
});
test('returns none for nullable inputs (graceful)', () => {
expect(wc.altitudeImpact(null)).toBe('none');
expect(wc.altitudeImpact(undefined)).toBe('none');
expect(wc.altitudeImpact(NaN)).toBe('none');
});
});
describe('isHomeContinent', () => {
test('returns true for the three 2026 hosts', () => {
expect(wc.isHomeContinent('USA')).toBe(true);
expect(wc.isHomeContinent('Canada')).toBe(true);
expect(wc.isHomeContinent('Mexico')).toBe(true);
});
test('returns false for European squads', () => {
expect(wc.isHomeContinent('France')).toBe(false);
expect(wc.isHomeContinent('Brazil')).toBe(false);
});
test('returns false for unknown teams', () => {
expect(wc.isHomeContinent('Atlantis')).toBe(false);
expect(wc.isHomeContinent(null)).toBe(false);
});
});
describe('penalty / corner / free-kick role lookups', () => {
test('isPenaltyTaker — known taker', () => {
expect(wc.isPenaltyTaker('Lionel Messi', 'Argentina')).toBe(true);
expect(wc.isPenaltyTaker('Harry Kane', 'England')).toBe(true);
});
test('isPenaltyTaker — case-insensitive', () => {
expect(wc.isPenaltyTaker('lionel messi', 'Argentina')).toBe(true);
});
test('isPenaltyTaker — wrong team returns false', () => {
expect(wc.isPenaltyTaker('Lionel Messi', 'France')).toBe(false);
});
test('isPenaltyTaker — null inputs return false', () => {
expect(wc.isPenaltyTaker(null, 'Argentina')).toBe(false);
expect(wc.isPenaltyTaker('X', null)).toBe(false);
});
test('isCornerTaker — multi-name array picks up secondaries', () => {
// England has 3+ corner takers — verify the second is found too.
expect(wc.isCornerTaker('Phil Foden', 'England')).toBe(true);
expect(wc.isCornerTaker('Trent Alexander-Arnold', 'England')).toBe(true);
});
test('isFreeKickTaker — sparse map (not every team has one)', () => {
expect(wc.isFreeKickTaker('Lionel Messi', 'Argentina')).toBe(true);
// Australia not in FK takers map at all
expect(wc.isFreeKickTaker('Anyone', 'Australia')).toBe(false);
});
});
describe('getTournamentHistory', () => {
test('returns career WC stats for documented players', () => {
const messi = wc.getTournamentHistory('Lionel Messi');
expect(messi.wc_goals_career).toBeGreaterThanOrEqual(3);
expect(messi.wc_appearances).toBeGreaterThan(0);
});
test('returns null for unknown player', () => {
expect(wc.getTournamentHistory('Nobody Joe')).toBeNull();
expect(wc.getTournamentHistory(null)).toBeNull();
});
});
describe('immutability', () => {
test('VENUES is frozen (cannot mutate top-level)', () => {
expect(Object.isFrozen(wc.VENUES)).toBe(true);
});
test('CONCACAF_TEAMS is frozen', () => {
expect(Object.isFrozen(wc.CONCACAF_TEAMS)).toBe(true);
});
});
});
+1 -1
View File
File diff suppressed because one or more lines are too long