Session 14: Africa checkout, Tank01 NBA/MLB wiring, WNBA+MLB odds proxies, OAuth icons, loading skeletons (1330 tests)

This commit is contained in:
Kev
2026-06-11 10:06:49 -04:00
parent 10159209fa
commit f5d79cf70d
22 changed files with 979 additions and 27 deletions
@@ -32,6 +32,11 @@ 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');
// Session 14 — Tank01 augmentor. Reads cache keys the Tank01
// adapters write; no network from this path. Daily prefetch (future)
// populates the cache. Until that lands, the augmentor returns
// empty objects and the existing ESPN-derived features stand alone.
const tank01Augment = require('./tank01Augment');
const HTTP_TIMEOUT_MS = 8_000;
@@ -188,6 +193,37 @@ async function computeFeaturesForProp(rawProp = {}) {
errors.push('no_features_computed');
}
// Session 14 — Tank01 augmentation. Sport-specific. Both calls are
// cache-only (no network), Promise.allSettled-style isolated so a
// Redis hiccup on the Tank01 read doesn't fail the whole grade.
// The `t01_*` fields land alongside the ESPN-derived features;
// grading + reasoning + trap detection read them when present and
// ignore them when absent.
const ymd = new Date().toISOString().slice(0, 10).replace(/-/g, '');
try {
if (sport === 'nba') {
const aug = await tank01Augment.augmentNbaFeatures({
gameId: game?.gameId ?? null,
playerName: player,
ymd,
});
Object.assign(features, aug);
} else if (sport === 'mlb') {
const aug = await tank01Augment.augmentMlbFeatures({
gameId: game?.gameId ?? null,
batterName: player,
// batterId/pitcherId/pitcherName not yet plumbed through
// computeFeatures — the augmentor returns name-only markers
// when IDs are absent.
ymd,
});
Object.assign(features, aug);
}
} catch (err) {
// Never let augmentation failure poison the grade.
console.warn('[computeFeatures] Tank01 augmentation skipped:', err.message);
}
const trap = await safeGetTrap({
playerName: player,
statType,
+201
View File
@@ -0,0 +1,201 @@
/**
* Tank01 feature augmentor (Session 14).
*
* Reads cache keys the Tank01 adapters write — never hits the network
* directly. This keeps the user request path off the RapidAPI budget
* (1000 req/mo on the free tier) and makes the seam explicit: a
* future daily prefetch populates these keys, and `computeFeatures`
* reads them through this module.
*
* Contract: every export returns a flat object suitable for
* `Object.assign(features, augmentation)` — no nested structures, no
* throws, no DB writes. An empty object on miss is the correct
* "absent signal" response (the grading engine treats unknown
* features as neutral, not penalizing).
*
* Cache keys consumed (written by the adapters in Session 9):
* tank01:nba:boxscore:{gameId} — per-game player stats
* tank01:nba:games:{ymd} — daily schedule with statuses
* tank01:nba:odds:{ymd} — book-by-book market lines
* tank01:mlb:boxscore:{gameId} — per-game batter + pitcher lines
* tank01:mlb:bvp:{batterId}:{pitcherId} — historical matchup
* tank01:mlb:scoreboard:{ymd} — daily slate
*
* Player matching: Tank01 box scores key players by ESPN-ish IDs.
* Without a Tank01-id → name index, we match by case-insensitive
* `longName`. Best-effort — if the prefetch eventually writes an
* index keyed by `tank01:player_by_name:{normalizedName}`, we'll
* prefer that.
*/
const { cacheGet } = require('../../utils/redis');
const { normalizeName } = require('../../utils/normalize');
function nameMatches(a, b) {
if (!a || !b) return false;
return String(a).trim().toLowerCase() === String(b).trim().toLowerCase();
}
async function safeRead(key) {
try {
return await cacheGet(key);
} catch {
return null;
}
}
// Find a player row inside a Tank01 NBA box score response. The
// adapter projects `{playerId, name, team, pts, reb, ast, ...}` so
// we walk the projected list rather than the raw Tank01 envelope.
function findPlayerInBoxScore(boxScoreList, playerName) {
if (!Array.isArray(boxScoreList)) return null;
for (const row of boxScoreList) {
if (nameMatches(row.name, playerName)) return row;
}
return null;
}
// Find this player's market line + market average for the requested
// stat type, when Tank01's odds payload includes them.
function extractMarketLine(oddsPayload, _playerName, _statType) {
if (!oddsPayload || typeof oddsPayload !== 'object') return null;
// Tank01's odds schema isn't fully stable across versions; the
// adapter passes the raw body through. Surface the upstream
// payload under a namespaced key so downstream features can dig
// when the schema firms up; for now, just confirm presence.
return oddsPayload.body || oddsPayload || null;
}
/**
* augmentNbaFeatures — merge Tank01 NBA cache reads into the
* computeFeatures pipeline.
*
* @param {Object} params { gameId, playerName, ymd }
* gameId — ESPN game ID (preferred Tank01 key) or null
* playerName — display name to match against box scores
* ymd — YYYYMMDD for the daily odds/schedule lookups
* @returns {Promise<Object>} flat feature additions (possibly empty)
*/
async function augmentNbaFeatures({ gameId, playerName, ymd } = {}) {
const out = {};
if (!playerName && !gameId) return out;
// 1) Per-game box score (live mid-game, final post-game).
if (gameId) {
const box = await safeRead(`tank01:nba:boxscore:${gameId}`);
if (Array.isArray(box) && playerName) {
const row = findPlayerInBoxScore(box, playerName);
if (row) {
// Surface as `t01_*` so the grading engine + reasoning
// builder can distinguish Tank01-sourced fields from ESPN-
// sourced ones (audit trail in production logs).
out.t01_minutes = row.mins ?? null;
out.t01_pts = row.pts ?? null;
out.t01_reb = row.reb ?? null;
out.t01_ast = row.ast ?? null;
out.t01_threes = row.threes ?? null;
out.t01_blk = row.blk ?? null;
out.t01_stl = row.stl ?? null;
out.t01_tov = row.tov ?? null;
out.t01_final = !!row._final;
out.t01_source = 'tank01';
}
}
}
// 2) Market odds for the slate date — exposes consensus lines that
// the trap detector can read alongside odds-api numbers.
if (ymd) {
const odds = await safeRead(`tank01:nba:odds:${ymd}`);
if (odds) {
const market = extractMarketLine(odds, playerName);
if (market) out.t01_market_present = true;
}
}
return out;
}
/**
* augmentMlbFeatures — merge Tank01 MLB cache reads into computeFeatures.
*
* BvP is the headline signal: a batter's historical line against a
* specific pitcher. Pitcher resolution is best-effort — we check the
* daily scoreboard for the opposing starter when `pitcherName` isn't
* supplied by the caller.
*/
async function augmentMlbFeatures({ gameId, batterName, pitcherName, batterId, pitcherId, ymd } = {}) {
const out = {};
if (!batterName && !gameId) return out;
// 1) Per-game box score — live batter line during play, final after.
if (gameId) {
const box = await safeRead(`tank01:mlb:boxscore:${gameId}`);
if (Array.isArray(box) && batterName) {
const row = box.find((r) => r.role === 'batter' && nameMatches(r.name, batterName));
if (row) {
out.t01_box_present = true;
out.t01_team = row.team;
out.t01_final = !!row._final;
}
}
}
// 2) Batter-vs-pitcher historical line. Tank01 keys BvP by IDs.
// Without IDs, fall through silently — name-based BvP lookup
// needs a separate name→id index the prefetch can build.
if (batterId && pitcherId) {
const bvp = await safeRead(`tank01:mlb:bvp:${batterId}:${pitcherId}`);
if (bvp && typeof bvp === 'object' && !Array.isArray(bvp)) {
out.t01_bvp_pa = bvp.plateAppearances ?? 0;
out.t01_bvp_ab = bvp.atBats ?? 0;
out.t01_bvp_h = bvp.hits ?? 0;
out.t01_bvp_hr = bvp.homeRuns ?? 0;
out.t01_bvp_rbi = bvp.rbi ?? 0;
out.t01_bvp_so = bvp.strikeouts ?? 0;
out.t01_bvp_avg = bvp.avg;
out.t01_bvp_ops = bvp.ops;
// K-rate as a friendly derived signal — trap detection reads
// strikeout-prone matchups directly off this.
if (out.t01_bvp_ab > 0) {
out.t01_bvp_so_rate = Math.round((out.t01_bvp_so / out.t01_bvp_ab) * 1000) / 1000;
}
out.t01_source = 'tank01';
}
} else if (batterName && pitcherName) {
// Future: if a name→id index lands in the prefetch, resolve
// here. For now we drop a marker so reasoning can say
// "BvP unavailable — names not yet indexed."
out.t01_bvp_name_only = true;
out.t01_bvp_batter_name = batterName;
out.t01_bvp_pitcher_name = pitcherName;
}
// 3) Daily scoreboard — read the opposing pitcher when the caller
// didn't pass one. This is the "best-effort pitcher detection"
// the spec called out.
if (!pitcherName && !pitcherId && ymd && batterName) {
const slate = await safeRead(`tank01:mlb:scoreboard:${ymd}`);
if (Array.isArray(slate) && slate.length > 0) {
// We don't have a batter→team map here (that lives in the
// box score we may not have hit yet). Mark the slate as
// present so reasoning can say "starting pitcher data
// available — call augmentMlbFeatures with pitcherName."
out.t01_slate_present = true;
}
}
return out;
}
module.exports = {
augmentNbaFeatures,
augmentMlbFeatures,
__internals: {
nameMatches,
safeRead,
findPlayerInBoxScore,
extractMarketLine,
normalizeName,
},
};