Session 15: Intelligence hardening — park factors, weather, Tank01 prefetch, pace factors, signal audit, founder pricing fix (1405 tests)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Tank01 daily prefetch (Session 15).
|
||||
*
|
||||
* trigger: n8n "Morning Ops" workflow OR cron `0 7 * * *` (7am UTC,
|
||||
* ~3am ET — before US slates publish but after overnight
|
||||
* box scores finalize).
|
||||
* call: node scripts/tank01-prefetch.js [--max=80] [--dry-run]
|
||||
*
|
||||
* Why this exists: Session 14 wired `src/services/intelligence/tank01Augment.js`
|
||||
* to read `tank01:nba:*` and `tank01:mlb:*` cache keys, but nothing
|
||||
* populated those keys. Session 15 closes that loop with a daily
|
||||
* pull. The augmentor returns empty objects when the keys are
|
||||
* missing — grades still work today, they just don't include the
|
||||
* Tank01 signals. Once this script runs, grades pick up the new
|
||||
* data automatically.
|
||||
*
|
||||
* Budget posture: free tier is 1,000 req/month per RapidAPI account.
|
||||
* Even with NBA + MLB combined, one daily run targets ≤80 requests.
|
||||
* 80 * 30 days = 2,400 → would burn the budget. So default cap is 80
|
||||
* total across both sports, with a hard stop. Override via `--max`.
|
||||
*
|
||||
* What it writes:
|
||||
* tank01:nba:games:{ymd} — daily schedule
|
||||
* tank01:nba:boxscore:{gameId} — per-game final box scores
|
||||
* tank01:nba:odds:{ymd} — book-by-book lines
|
||||
* tank01:mlb:scoreboard:{ymd} — daily schedule
|
||||
* tank01:mlb:boxscore:{gameId} — per-game lines for finals
|
||||
* tank01:mlb:bvp:{batterId}:{pitcherId} — historical matchups
|
||||
*
|
||||
* The adapters (Session 9) own the `cacheSet` calls — this script
|
||||
* is the orchestrator that decides what to fetch. Adapter calls are
|
||||
* idempotent: re-running the script won't double-spend the budget
|
||||
* because each adapter checks its cache before hitting RapidAPI.
|
||||
*/
|
||||
|
||||
const tankNba = require('../src/services/adapters/tank01NbaAdapter');
|
||||
const tankMlb = require('../src/services/adapters/tank01MlbAdapter');
|
||||
|
||||
const DEFAULT_BUDGET = 80;
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { maxRequests: DEFAULT_BUDGET, dryRun: false, sports: ['nba', 'mlb'] };
|
||||
for (const a of argv.slice(2)) {
|
||||
if (a.startsWith('--max=')) {
|
||||
const n = Number(a.slice('--max='.length));
|
||||
if (Number.isFinite(n) && n > 0) args.maxRequests = Math.floor(n);
|
||||
} else if (a === '--dry-run') {
|
||||
args.dryRun = true;
|
||||
} else if (a.startsWith('--sports=')) {
|
||||
args.sports = a.slice('--sports='.length).split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function todayYMD() {
|
||||
const d = new Date();
|
||||
return `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, '0')}${String(d.getUTCDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Simple counter — the adapters cache internally, so a "request" here
|
||||
// is "I asked the adapter for X; whether it hit cache or network is
|
||||
// out of our control." For budgeting we treat every adapter call as
|
||||
// 1 potential request and stop when the cap is hit.
|
||||
function makeBudget(maxRequests) {
|
||||
let spent = 0;
|
||||
return {
|
||||
canSpend: () => spent < maxRequests,
|
||||
spend: () => { spent += 1; return spent; },
|
||||
spent: () => spent,
|
||||
};
|
||||
}
|
||||
|
||||
async function processNba(args, budget, summary) {
|
||||
if (!tankNba.hasApiKey()) {
|
||||
summary.nba.skipped = 'no_key';
|
||||
return;
|
||||
}
|
||||
const ymd = todayYMD();
|
||||
|
||||
if (!budget.canSpend()) return;
|
||||
budget.spend();
|
||||
const games = await tankNba.getNBAGamesForDate(ymd);
|
||||
if (!Array.isArray(games)) {
|
||||
summary.nba.games = 0;
|
||||
return;
|
||||
}
|
||||
summary.nba.games = games.length;
|
||||
|
||||
// Box scores for every final game — captures the post-game stat
|
||||
// lines the augmentor surfaces to the analyze path.
|
||||
for (const g of games) {
|
||||
if (!budget.canSpend()) break;
|
||||
if (!g.gameId) continue;
|
||||
const status = String(g.gameStatus || '').toLowerCase();
|
||||
if (!status.includes('final')) continue;
|
||||
budget.spend();
|
||||
await tankNba.getNBABoxScore(g.gameId);
|
||||
summary.nba.boxscores += 1;
|
||||
}
|
||||
|
||||
// One odds pull for the day — surfaces the t01_market_present
|
||||
// marker the trap detector reads alongside odds-api.
|
||||
if (budget.canSpend()) {
|
||||
budget.spend();
|
||||
await tankNba.getNBABettingOdds(ymd);
|
||||
summary.nba.odds = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function processMlb(args, budget, summary) {
|
||||
if (!tankMlb.hasApiKey()) {
|
||||
summary.mlb.skipped = 'no_key';
|
||||
return;
|
||||
}
|
||||
const ymd = todayYMD();
|
||||
|
||||
if (!budget.canSpend()) return;
|
||||
budget.spend();
|
||||
const slate = await tankMlb.getMLBDailyScoreboard(ymd);
|
||||
if (!Array.isArray(slate)) {
|
||||
summary.mlb.games = 0;
|
||||
return;
|
||||
}
|
||||
summary.mlb.games = slate.length;
|
||||
|
||||
// Box scores for finished games.
|
||||
for (const g of slate) {
|
||||
if (!budget.canSpend()) break;
|
||||
if (!g.gameId) continue;
|
||||
const status = String(g.gameStatus || '').toLowerCase();
|
||||
if (!status.includes('final') && !status.includes('completed')) continue;
|
||||
budget.spend();
|
||||
await tankMlb.getMLBBoxScore(g.gameId);
|
||||
summary.mlb.boxscores += 1;
|
||||
}
|
||||
|
||||
// BvP matchups — only when probable pitchers are exposed on the
|
||||
// scoreboard payload (Tank01 includes them for upcoming games). We
|
||||
// can't safely run BvP without batter/pitcher IDs, so the pass is
|
||||
// a no-op until the scoreboard projection grows those fields. The
|
||||
// augmentor handles the empty-cache case gracefully.
|
||||
summary.mlb.bvp_skipped_reason = 'awaiting_starter_ids_on_scoreboard';
|
||||
}
|
||||
|
||||
async function main(argv = process.argv) {
|
||||
const args = parseArgs(argv);
|
||||
const budget = makeBudget(args.maxRequests);
|
||||
const summary = {
|
||||
nba: { games: 0, boxscores: 0, odds: false, skipped: null },
|
||||
mlb: { games: 0, boxscores: 0, bvp: 0, skipped: null, bvp_skipped_reason: null },
|
||||
requestsSpent: 0,
|
||||
dryRun: args.dryRun,
|
||||
};
|
||||
|
||||
console.log(`[tank01-prefetch] starting — max_requests=${args.maxRequests} sports=${args.sports.join(',')} dry_run=${args.dryRun}`);
|
||||
|
||||
if (args.dryRun) {
|
||||
summary.nba.skipped = 'dry_run';
|
||||
summary.mlb.skipped = 'dry_run';
|
||||
console.log('[tank01-prefetch] dry-run — adapter calls suppressed');
|
||||
} else {
|
||||
if (args.sports.includes('nba')) await processNba(args, budget, summary);
|
||||
if (args.sports.includes('mlb')) await processMlb(args, budget, summary);
|
||||
}
|
||||
|
||||
summary.requestsSpent = budget.spent();
|
||||
console.log(`[tank01-prefetch] done — nba.games=${summary.nba.games} nba.box=${summary.nba.boxscores} mlb.games=${summary.mlb.games} mlb.box=${summary.mlb.boxscores} spent=${summary.requestsSpent}/${args.maxRequests}`);
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().then(() => process.exit(0)).catch((err) => {
|
||||
console.error('[tank01-prefetch] fatal:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
main,
|
||||
__internals: { parseArgs, makeBudget, todayYMD, processNba, processMlb, DEFAULT_BUDGET },
|
||||
};
|
||||
Reference in New Issue
Block a user