Session 23: All-day intelligence layer — schedule, game lines, streaks, hot lists, stat filtering, ParlayAPI dead (1567 tests)

This commit is contained in:
Kev
2026-06-12 11:16:58 -04:00
parent 6ab49d4c37
commit 0538205fab
32 changed files with 2276 additions and 2 deletions
+144
View File
@@ -0,0 +1,144 @@
/**
* /api/gamelines/:sport (Session 23)
*
* Today's game-level betting odds from Tank01 — book-by-book moneylines,
* run/point spreads, and totals. Separate budget from odds-api player
* props: this uses the RAPID_API_KEY quota via the Tank01 adapters.
*
* Chrome Claude confirmed (2026-06-12) Tank01 MLB serves LIVE lines from
* bet365 / betmgm / caesars. NBA carries the same in-season — an empty
* result off-season is correct, not an error.
*
* Response shape:
* {
* sport: 'mlb',
* date: '2026-06-12',
* games: {
* '20260612_ARI@CIN': {
* homeTeam: 'CIN', awayTeam: 'ARI',
* books: {
* bet365: { homeML, awayML, total, overOdds, underOdds,
* homeSpread, awaySpread },
* betmgm: { ... },
* },
* },
* },
* source: 'tank01',
* }
*/
const express = require('express');
const nbaAdapter = require('../services/adapters/tank01NbaAdapter');
const mlbAdapter = require('../services/adapters/tank01MlbAdapter');
const scheduleService = require('../services/scheduleService');
const router = express.Router();
const MISSION_HEADER = { 'X-VYNDR-Mission': 'Lines keep the slate alive' };
// Sports that have a Tank01 game-lines feed wired up.
const FETCHERS = {
nba: (date) => nbaAdapter.getNBABettingOdds(date),
mlb: (date) => mlbAdapter.getMLBBettingOdds(date),
};
const HAS_KEY = {
nba: () => nbaAdapter.hasApiKey(),
mlb: () => mlbAdapter.hasApiKey(),
};
/**
* Parse `YYYYMMDD_AWAY@HOME` → { awayTeam, homeTeam }. Tank01 keys every
* game this way. Falls back to nulls on an unexpected key.
*/
function teamsFromGameId(gameId) {
const m = String(gameId || '').match(/_([A-Za-z0-9]+)@([A-Za-z0-9]+)/);
if (!m) return { awayTeam: null, homeTeam: null };
return { awayTeam: m[1], homeTeam: m[2] };
}
/**
* Normalize one sportsbook's raw odds object into a flat, UI-ready row.
* Tank01 field names are verbose and occasionally vary; pull defensively.
*/
function normalizeBook(odds) {
if (!odds || typeof odds !== 'object') return null;
const pick = (...keys) => {
for (const k of keys) {
if (odds[k] !== undefined && odds[k] !== null && odds[k] !== '') return odds[k];
}
return null;
};
return {
homeML: pick('homeTeamMLOdds', 'homeML', 'moneyLineHome'),
awayML: pick('awayTeamMLOdds', 'awayML', 'moneyLineAway'),
total: pick('totalOver', 'total', 'overUnder'),
overOdds: pick('totalOverOdds', 'overOdds'),
underOdds: pick('totalUnderOdds', 'underOdds'),
homeSpread: pick('homeTeamSpread', 'homeSpread'),
awaySpread: pick('awayTeamSpread', 'awaySpread'),
homeSpreadOdds: pick('homeTeamSpreadOdds'),
awaySpreadOdds: pick('awayTeamSpreadOdds'),
};
}
/**
* Normalize the Tank01 betting-odds body (a map keyed by gameID) into the
* route's `games` shape. Defensive against both the documented map form
* and a bare array of game objects.
*/
function normalizeGameLines(body) {
const games = {};
if (!body || typeof body !== 'object') return games;
const entries = Array.isArray(body)
? body.map((g) => [g.gameID || g.gameId, g])
: Object.entries(body);
for (const [gameId, game] of entries) {
if (!gameId || !game || typeof game !== 'object') continue;
const { awayTeam, homeTeam } = teamsFromGameId(gameId);
const books = {};
const sbList = game.sportsBooks || game.books || [];
if (Array.isArray(sbList)) {
for (const sb of sbList) {
const name = sb?.sportsBook || sb?.book || sb?.name;
const row = normalizeBook(sb?.odds || sb);
if (name && row) books[String(name).toLowerCase()] = row;
}
}
games[gameId] = {
homeTeam: game.homeTeam || homeTeam,
awayTeam: game.awayTeam || awayTeam,
books,
};
}
return games;
}
router.get('/:sport', async (req, res) => {
const sport = String(req.params.sport || '').toLowerCase();
const date = req.query.date || scheduleService.todayET();
const fetcher = FETCHERS[sport];
if (!fetcher) {
return res.status(404).set(MISSION_HEADER).json({ error: `No game lines for sport: ${sport}` });
}
// Missing RAPID_API_KEY → graceful empty, never a crash.
if (HAS_KEY[sport] && !HAS_KEY[sport]()) {
return res.set(MISSION_HEADER).json({ sport, date, games: {}, source: 'tank01', configured: false });
}
try {
const body = await fetcher(date);
const games = normalizeGameLines(body);
return res.set(MISSION_HEADER).json({ sport, date, games, source: 'tank01' });
} catch (err) {
console.error(`[gamelines/${sport}]`, err.message);
return res.set(MISSION_HEADER).json({ sport, date, games: {}, source: 'tank01' });
}
});
module.exports = router;
module.exports.__internals = { teamsFromGameId, normalizeBook, normalizeGameLines };
+39
View File
@@ -0,0 +1,39 @@
/**
* /api/hotlist/:sport (Session 23)
*
* Rolling recent-window leaders, ranked by how far ABOVE baseline each
* player is trending. NO API calls — reads warm cached game logs and runs
* the pure hot-list engine. Supports `?stat=points` and `?limit=N`.
*
* Response: { sport, stat, players: [...], source: 'computed' }
*/
const express = require('express');
const hotListService = require('../services/hotListService');
const { loadRosterLogs } = require('../services/rosterLogs');
const router = express.Router();
const MISSION_HEADER = { 'X-VYNDR-Mission': 'Hot right now' };
const SUPPORTED = new Set(['nba', 'wnba', 'mlb', 'soccer']);
router.get('/:sport', async (req, res) => {
const sport = String(req.params.sport || '').toLowerCase();
if (!SUPPORTED.has(sport)) {
return res.status(404).set(MISSION_HEADER).json({ error: `No hot list for sport: ${sport}` });
}
const stat = req.query.stat ? String(req.query.stat).toLowerCase() : 'all';
const limit = req.query.limit ? Math.max(0, parseInt(req.query.limit, 10) || 0) : 0;
try {
const roster = await loadRosterLogs(sport);
const players = hotListService.computeHotList(roster, sport, { stat, limit });
return res.set(MISSION_HEADER).json({ sport, stat, players, source: 'computed' });
} catch (err) {
console.error(`[hotlist/${sport}]`, err.message);
return res.set(MISSION_HEADER).json({ sport, stat, players: [], source: 'computed' });
}
});
module.exports = router;
+56
View File
@@ -0,0 +1,56 @@
/**
* /api/schedule/:sport (Session 23)
*
* Returns today's game schedule from cached/free ESPN data. NO odds-api
* credits burned. The PM2 pollers warm this cache every 60s; on a miss
* the schedule service self-heals by fetching the free ESPN scoreboard.
*
* Each game carries two boolean flags read from OTHER caches (no fetch):
* hasOdds — odds-api player props exist for this slate
* hasGameLines — Tank01 game-level lines exist for this slate
*
* Response shape:
* {
* sport: 'nba',
* date: '2026-06-12',
* games: [ { id, homeTeam, awayTeam, gameTime, status, score,
* venue, broadcast, hasOdds, hasGameLines } ],
* source: 'espn',
* }
*/
const express = require('express');
const scheduleService = require('../services/scheduleService');
const { SPORT_CONFIG } = require('../config/sports');
const router = express.Router();
const MISSION_HEADER = { 'X-VYNDR-Mission': 'The slate is never empty' };
router.get('/:sport', async (req, res) => {
const sport = String(req.params.sport || '').toLowerCase();
const date = req.query.date || scheduleService.todayET();
if (!SPORT_CONFIG[sport]) {
return res.status(404).set(MISSION_HEADER).json({ error: `Unknown sport: ${sport}` });
}
try {
const raw = await scheduleService.getSchedule(sport, date);
// null → unsupported sport (already guarded above); treat defensively.
const games = await scheduleService.enrichFlags(sport, date, raw || []);
return res.set(MISSION_HEADER).json({
sport,
date,
games,
source: 'espn',
});
} catch (err) {
console.error(`[schedule/${sport}]`, err.message);
// Even on error we return an empty slate, not a 5xx — the platform
// is NEVER down. Other layers (game lines, props) keep it alive.
return res.set(MISSION_HEADER).json({ sport, date, games: [], source: 'espn' });
}
});
module.exports = router;
+9
View File
@@ -1,10 +1,19 @@
const express = require('express');
const { getSupabaseServiceClient } = require('../utils/supabase');
const { getStatFilters } = require('../config/statFilters');
const router = express.Router();
const MISSION_HEADER = { 'X-VYNDR-Mission': 'Kill bad satisfieds before they satisfieds you' };
// GET /filters/:sport — stat-filter categories for the StatFilterPills UI
// (Session 23). Lets the frontend stay data-driven without re-declaring
// the category list. NO auth / NO DB — pure config.
router.get('/filters/:sport', (req, res) => {
const sport = String(req.params.sport || '').toLowerCase();
res.set(MISSION_HEADER).json({ sport, filters: getStatFilters(sport) });
});
// GET /parlays-graded — total scan count
router.get('/parlays-graded', async (req, res) => {
try {
+43
View File
@@ -0,0 +1,43 @@
/**
* /api/streaks/:sport (Session 23)
*
* Computed player streaks from cached game logs. NO API calls — reads
* warm Redis logs and runs the pure streaks engine over them. Supports
* `?stat=points` to narrow to one category, and `?limit=N`.
*
* Response: { sport, stat, streaks: [...], source: 'computed' }
*
* An empty `streaks` array is a valid, non-error state — the platform
* leans on the other layers (schedule, game lines, props) when no logs
* are warm yet.
*/
const express = require('express');
const streaksService = require('../services/streaksService');
const { loadRosterLogs } = require('../services/rosterLogs');
const router = express.Router();
const MISSION_HEADER = { 'X-VYNDR-Mission': 'Streaks are the heartbeat' };
const SUPPORTED = new Set(['nba', 'wnba', 'mlb', 'nfl', 'soccer']);
router.get('/:sport', async (req, res) => {
const sport = String(req.params.sport || '').toLowerCase();
if (!SUPPORTED.has(sport)) {
return res.status(404).set(MISSION_HEADER).json({ error: `No streaks for sport: ${sport}` });
}
const stat = req.query.stat ? String(req.query.stat).toLowerCase() : 'all';
const limit = req.query.limit ? Math.max(0, parseInt(req.query.limit, 10) || 0) : 0;
try {
const roster = await loadRosterLogs(sport);
const streaks = streaksService.computeStreaks(roster, sport, { stat, limit });
return res.set(MISSION_HEADER).json({ sport, stat, streaks, source: 'computed' });
} catch (err) {
console.error(`[streaks/${sport}]`, err.message);
return res.set(MISSION_HEADER).json({ sport, stat, streaks: [], source: 'computed' });
}
});
module.exports = router;