Session 7j: Soccer intelligence - 9 leagues, 11 signals, 6 traps, poller, prefetch, 131 new tests (1173 total)
This commit is contained in:
+89
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
/**
|
||||||
|
* FIFA World Cup 2026 reference data — June 11–July 19, 2026.
|
||||||
|
*
|
||||||
|
* Static, hand-curated. The poller pulls fixtures/standings/squads from
|
||||||
|
* APIs (football-data.org + worldcup2026 OSS); this file holds the
|
||||||
|
* intelligence the APIs don't carry: venue altitudes, host-continent
|
||||||
|
* teams, designated penalty/set-piece takers, and historical tournament
|
||||||
|
* performers.
|
||||||
|
*
|
||||||
|
* Updated manually as the tournament progresses (squad confirmations,
|
||||||
|
* penalty-taker shifts, etc.). Not consumed during normal cache misses —
|
||||||
|
* the feature extractor reads this once per process load.
|
||||||
|
*
|
||||||
|
* Venue altitudes sourced from public elevation data (NOAA / Google
|
||||||
|
* Earth, ft above sea level). Estadio Akron is in Zapopan (Guadalajara
|
||||||
|
* metro) at ~5,100 ft; Estadio Azteca's stadium floor sits at ~7,349 ft
|
||||||
|
* — the highest altitude in tournament history for the host venue.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const VENUES = Object.freeze({
|
||||||
|
// United States (11)
|
||||||
|
'AT&T Stadium': { city: 'Arlington, TX', altitude_ft: 600, climate: 'hot', country: 'USA' },
|
||||||
|
'Mercedes-Benz Stadium': { city: 'Atlanta, GA', altitude_ft: 1050, climate: 'hot_humid', country: 'USA' },
|
||||||
|
'Gillette Stadium': { city: 'Foxborough, MA', altitude_ft: 260, climate: 'temperate', country: 'USA' },
|
||||||
|
'NRG Stadium': { city: 'Houston, TX', altitude_ft: 80, climate: 'hot_humid', country: 'USA' },
|
||||||
|
'Arrowhead Stadium': { city: 'Kansas City, MO', altitude_ft: 820, climate: 'temperate', country: 'USA' },
|
||||||
|
'SoFi Stadium': { city: 'Inglewood, CA', altitude_ft: 100, climate: 'temperate', country: 'USA' },
|
||||||
|
'Hard Rock Stadium': { city: 'Miami Gardens, FL', altitude_ft: 10, climate: 'hot_humid', country: 'USA' },
|
||||||
|
'MetLife Stadium': { city: 'East Rutherford, NJ', altitude_ft: 7, climate: 'temperate', country: 'USA' },
|
||||||
|
'Lincoln Financial Field': { city: 'Philadelphia, PA', altitude_ft: 30, climate: 'temperate', country: 'USA' },
|
||||||
|
"Levi's Stadium": { city: 'Santa Clara, CA', altitude_ft: 33, climate: 'temperate', country: 'USA' },
|
||||||
|
'Lumen Field': { city: 'Seattle, WA', altitude_ft: 15, climate: 'cool', country: 'USA' },
|
||||||
|
// Canada (2)
|
||||||
|
'BMO Field': { city: 'Toronto, ON', altitude_ft: 250, climate: 'temperate', country: 'Canada' },
|
||||||
|
'BC Place': { city: 'Vancouver, BC', altitude_ft: 33, climate: 'cool', country: 'Canada' },
|
||||||
|
// Mexico (3)
|
||||||
|
'Estadio Azteca': { city: 'Mexico City', altitude_ft: 7349, climate: 'altitude', country: 'Mexico' },
|
||||||
|
'Estadio BBVA': { city: 'Monterrey', altitude_ft: 1765, climate: 'hot', country: 'Mexico' },
|
||||||
|
'Estadio Akron': { city: 'Guadalajara (Zapopan)', altitude_ft: 5138, climate: 'altitude', country: 'Mexico' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Host-continent teams (CONCACAF) — historically benefit from reduced
|
||||||
|
// travel, fan support, and altitude acclimation. The 2026 hosts (USA,
|
||||||
|
// Canada, Mexico) auto-qualified; the rest qualified through CONCACAF.
|
||||||
|
const CONCACAF_TEAMS = Object.freeze([
|
||||||
|
'USA', 'Canada', 'Mexico', 'Costa Rica', 'Jamaica', 'Honduras',
|
||||||
|
'Panama', 'El Salvador', 'Haiti', 'Trinidad and Tobago',
|
||||||
|
'Guatemala', 'Curacao', 'Suriname',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// CONMEBOL teams travel less than European/African squads and are
|
||||||
|
// historically strong in 2026-style climates. Used as a softer secondary
|
||||||
|
// modifier (not full home-continent advantage).
|
||||||
|
const CONMEBOL_TEAMS = Object.freeze([
|
||||||
|
'Argentina', 'Brazil', 'Uruguay', 'Colombia', 'Ecuador', 'Paraguay',
|
||||||
|
'Peru', 'Chile', 'Venezuela', 'Bolivia',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Designated penalty takers — primary plus secondary fallback if primary
|
||||||
|
// is off the pitch. Sourced from each team's most recent qualifier or
|
||||||
|
// pre-tournament friendly; updated as confirmations come in.
|
||||||
|
//
|
||||||
|
// Penalty-taker status adds ~0.15 goals per 90 to the player's base rate.
|
||||||
|
// Keys are team names matching football-data.org's `team.name` field;
|
||||||
|
// values are arrays in preference order.
|
||||||
|
const PENALTY_TAKERS = Object.freeze({
|
||||||
|
'Argentina': ['Lionel Messi', 'Lautaro Martínez'],
|
||||||
|
'Brazil': ['Vinicius Junior', 'Neymar'],
|
||||||
|
'France': ['Kylian Mbappé', 'Antoine Griezmann'],
|
||||||
|
'England': ['Harry Kane', 'Bukayo Saka'],
|
||||||
|
'Portugal': ['Cristiano Ronaldo', 'Bruno Fernandes'],
|
||||||
|
'Spain': ['Álvaro Morata', 'Mikel Merino'],
|
||||||
|
'Germany': ['Kai Havertz', 'İlkay Gündoğan'],
|
||||||
|
'Netherlands': ['Memphis Depay', 'Cody Gakpo'],
|
||||||
|
'Belgium': ['Romelu Lukaku', 'Kevin De Bruyne'],
|
||||||
|
'Italy': ['Jorginho', 'Lorenzo Pellegrini'],
|
||||||
|
'Croatia': ['Luka Modrić', 'Andrej Kramarić'],
|
||||||
|
'Uruguay': ['Darwin Núñez', 'Federico Valverde'],
|
||||||
|
'Colombia': ['James Rodríguez', 'Luis Díaz'],
|
||||||
|
'Mexico': ['Raúl Jiménez', 'Hirving Lozano'],
|
||||||
|
'USA': ['Christian Pulisic', 'Folarin Balogun'],
|
||||||
|
'Canada': ['Jonathan David', 'Alphonso Davies'],
|
||||||
|
'Morocco': ['Hakim Ziyech', 'Achraf Hakimi'],
|
||||||
|
'Senegal': ['Sadio Mané', 'Ismaïla Sarr'],
|
||||||
|
'Japan': ['Takefusa Kubo', 'Wataru Endō'],
|
||||||
|
'South Korea': ['Son Heung-min', 'Hwang Hee-chan'],
|
||||||
|
'Australia': ['Jackson Irvine', 'Mitchell Duke'],
|
||||||
|
'Switzerland': ['Granit Xhaka', 'Xherdan Shaqiri'],
|
||||||
|
'Poland': ['Robert Lewandowski', 'Piotr Zieliński'],
|
||||||
|
'Denmark': ['Christian Eriksen', 'Pierre-Emile Højbjerg'],
|
||||||
|
'Serbia': ['Aleksandar Mitrović', 'Dušan Vlahović'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Designated corner takers (set-piece delivery role). Multi-name arrays
|
||||||
|
// reflect rotation; the first is the most common deliverer. Corner-taker
|
||||||
|
// status meaningfully boosts assist probability for headed goals.
|
||||||
|
const CORNER_TAKERS = Object.freeze({
|
||||||
|
'Argentina': ['Lionel Messi', 'Ángel Di María'],
|
||||||
|
'Brazil': ['Lucas Paquetá', 'Bruno Guimarães'],
|
||||||
|
'France': ['Antoine Griezmann', 'Kylian Mbappé'],
|
||||||
|
'England': ['Bukayo Saka', 'Phil Foden', 'Trent Alexander-Arnold'],
|
||||||
|
'Portugal': ['Bruno Fernandes', 'João Cancelo'],
|
||||||
|
'Spain': ['Dani Olmo', 'Mikel Merino'],
|
||||||
|
'Germany': ['Joshua Kimmich', 'Toni Kroos'],
|
||||||
|
'Netherlands': ['Frenkie de Jong', 'Cody Gakpo'],
|
||||||
|
'Belgium': ['Kevin De Bruyne', 'Yannick Carrasco'],
|
||||||
|
'Italy': ['Lorenzo Pellegrini', 'Federico Chiesa'],
|
||||||
|
'Croatia': ['Luka Modrić', 'Mateo Kovačić'],
|
||||||
|
'Uruguay': ['Federico Valverde', 'Giorgian de Arrascaeta'],
|
||||||
|
'Mexico': ['Andrés Guardado', 'Hirving Lozano'],
|
||||||
|
'USA': ['Christian Pulisic', 'Gio Reyna'],
|
||||||
|
'Canada': ['Stephen Eustáquio', 'Tajon Buchanan'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Direct free-kick specialists. These players take long-range and
|
||||||
|
// dangerous-area free kicks. Boosts both goal AND assist probability
|
||||||
|
// when a foul is drawn in shooting range.
|
||||||
|
const FREE_KICK_TAKERS = Object.freeze({
|
||||||
|
'Argentina': ['Lionel Messi'],
|
||||||
|
'Brazil': ['Neymar', 'Vinicius Junior'],
|
||||||
|
'France': ['Kylian Mbappé'],
|
||||||
|
'England': ['Trent Alexander-Arnold', 'Bukayo Saka'],
|
||||||
|
'Portugal': ['Cristiano Ronaldo', 'Bruno Fernandes'],
|
||||||
|
'Spain': ['Dani Olmo'],
|
||||||
|
'Germany': ['Joshua Kimmich'],
|
||||||
|
'Belgium': ['Kevin De Bruyne'],
|
||||||
|
'Italy': ['Federico Chiesa'],
|
||||||
|
'Croatia': ['Luka Modrić'],
|
||||||
|
'Colombia': ['James Rodríguez'],
|
||||||
|
'Mexico': ['Raúl Jiménez'],
|
||||||
|
'USA': ['Christian Pulisic'],
|
||||||
|
'Morocco': ['Hakim Ziyech'],
|
||||||
|
'South Korea': ['Son Heung-min'],
|
||||||
|
'Poland': ['Piotr Zieliński'],
|
||||||
|
'Serbia': ['Dušan Tadić'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// "Tournament players" — historical World Cup performers with three or
|
||||||
|
// more career WC goals. These names lift the prior on big-game scoring.
|
||||||
|
// Threshold: >=3 career WC goals OR >=2 in the most recent WC.
|
||||||
|
const TOURNAMENT_PLAYERS = Object.freeze({
|
||||||
|
'Lionel Messi': { wc_goals_career: 13, wc_appearances: 26 },
|
||||||
|
'Cristiano Ronaldo': { wc_goals_career: 8, wc_appearances: 22 },
|
||||||
|
'Kylian Mbappé': { wc_goals_career: 12, wc_appearances: 14 },
|
||||||
|
'Harry Kane': { wc_goals_career: 8, wc_appearances: 11 },
|
||||||
|
'Neymar': { wc_goals_career: 8, wc_appearances: 16 },
|
||||||
|
'Olivier Giroud': { wc_goals_career: 5, wc_appearances: 18 },
|
||||||
|
'Antoine Griezmann': { wc_goals_career: 6, wc_appearances: 17 },
|
||||||
|
'Romelu Lukaku': { wc_goals_career: 5, wc_appearances: 11 },
|
||||||
|
'Luka Modrić': { wc_goals_career: 2, wc_appearances: 18 }, // captain bias
|
||||||
|
'Robert Lewandowski': { wc_goals_career: 2, wc_appearances: 8 },
|
||||||
|
'Karim Benzema': { wc_goals_career: 3, wc_appearances: 11 },
|
||||||
|
'Edinson Cavani': { wc_goals_career: 4, wc_appearances: 14 },
|
||||||
|
'Luis Suárez': { wc_goals_career: 7, wc_appearances: 14 },
|
||||||
|
'Andrés Guardado': { wc_goals_career: 1, wc_appearances: 20 }, // captain bias
|
||||||
|
'Thomas Müller': { wc_goals_career: 10, wc_appearances: 16 },
|
||||||
|
'Eden Hazard': { wc_goals_career: 3, wc_appearances: 11 },
|
||||||
|
'Hirving Lozano': { wc_goals_career: 2, wc_appearances: 7 },
|
||||||
|
'Sadio Mané': { wc_goals_career: 1, wc_appearances: 5 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lookup helpers — case-insensitive on player names, exact-match on
|
||||||
|
// team names. Each returns a primitive so the feature extractor can
|
||||||
|
// drop the result straight into the feature vector.
|
||||||
|
|
||||||
|
function isPenaltyTaker(playerName, teamName) {
|
||||||
|
if (!playerName || !teamName) return false;
|
||||||
|
const takers = PENALTY_TAKERS[teamName];
|
||||||
|
if (!Array.isArray(takers)) return false;
|
||||||
|
const p = String(playerName).toLowerCase();
|
||||||
|
return takers.some((t) => t.toLowerCase() === p);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCornerTaker(playerName, teamName) {
|
||||||
|
if (!playerName || !teamName) return false;
|
||||||
|
const takers = CORNER_TAKERS[teamName];
|
||||||
|
if (!Array.isArray(takers)) return false;
|
||||||
|
const p = String(playerName).toLowerCase();
|
||||||
|
return takers.some((t) => t.toLowerCase() === p);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFreeKickTaker(playerName, teamName) {
|
||||||
|
if (!playerName || !teamName) return false;
|
||||||
|
const takers = FREE_KICK_TAKERS[teamName];
|
||||||
|
if (!Array.isArray(takers)) return false;
|
||||||
|
const p = String(playerName).toLowerCase();
|
||||||
|
return takers.some((t) => t.toLowerCase() === p);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTournamentHistory(playerName) {
|
||||||
|
if (!playerName) return null;
|
||||||
|
// Exact match first, then case-insensitive scan.
|
||||||
|
if (TOURNAMENT_PLAYERS[playerName]) return TOURNAMENT_PLAYERS[playerName];
|
||||||
|
const p = String(playerName).toLowerCase();
|
||||||
|
for (const [name, history] of Object.entries(TOURNAMENT_PLAYERS)) {
|
||||||
|
if (name.toLowerCase() === p) return history;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHomeContinent(teamName) {
|
||||||
|
if (!teamName) return false;
|
||||||
|
return CONCACAF_TEAMS.includes(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVenue(venueName) {
|
||||||
|
if (!venueName) return null;
|
||||||
|
return VENUES[venueName] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classify altitude impact for non-acclimatized teams. The historical
|
||||||
|
// goal-output reduction kicks in around 1,500 ft and gets material above
|
||||||
|
// 4,000 ft (per CSIC studies on player physiology at altitude).
|
||||||
|
function altitudeImpact(altitudeFt) {
|
||||||
|
if (!Number.isFinite(altitudeFt)) return 'none';
|
||||||
|
if (altitudeFt >= 4000) return 'high';
|
||||||
|
if (altitudeFt >= 1500) return 'moderate';
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
VENUES,
|
||||||
|
CONCACAF_TEAMS,
|
||||||
|
CONMEBOL_TEAMS,
|
||||||
|
PENALTY_TAKERS,
|
||||||
|
CORNER_TAKERS,
|
||||||
|
FREE_KICK_TAKERS,
|
||||||
|
TOURNAMENT_PLAYERS,
|
||||||
|
isPenaltyTaker,
|
||||||
|
isCornerTaker,
|
||||||
|
isFreeKickTaker,
|
||||||
|
getTournamentHistory,
|
||||||
|
isHomeContinent,
|
||||||
|
getVenue,
|
||||||
|
altitudeImpact,
|
||||||
|
};
|
||||||
@@ -48,8 +48,13 @@ async function cachedAnalyze(prop) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const VALID_STAT_TYPES = new Set([
|
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']);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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; },
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user