Sessions 5-7a: 955 tests, deployment ready

This commit is contained in:
Kev
2026-06-08 18:35:13 -04:00
parent 06b82624a2
commit 1fa04dc776
371 changed files with 49366 additions and 955 deletions
+45
View File
@@ -0,0 +1,45 @@
/**
* Shared odds math. Extracted so adapters don't duplicate the formulas
* (and don't subtly diverge — one off-by-one rounding in implied prob
* breaks CLV history).
*
* American odds convention:
* +150 → win $150 on a $100 bet
* -150 → win $100 on a $150 bet
*/
function oddsToImplied(odds) {
const n = Number(odds);
if (!Number.isFinite(n) || n === 0) return null;
if (n > 0) return 100 / (n + 100);
return Math.abs(n) / (Math.abs(n) + 100);
}
function impliedToAmerican(prob) {
const p = Number(prob);
if (!Number.isFinite(p) || p <= 0 || p >= 1) return null;
if (p >= 0.5) return Math.round((-100 * p) / (1 - p));
return Math.round((100 * (1 - p)) / p);
}
// Vig removal: each book bakes profit into both sides. Normalizing the
// over+under implied probs back to 1.0 gives the book's *fair* estimate.
function devig(overOdds, underOdds) {
const io = oddsToImplied(overOdds);
const iu = oddsToImplied(underOdds);
if (io == null || iu == null) return null;
const total = io + iu;
if (total <= 0) return null;
return { fairOver: io / total, fairUnder: iu / total };
}
// Positive CLV = we beat the close = real edge. We compare *fair* probs
// rather than raw odds so vig doesn't flip the sign on us.
function clv(gradedFairProb, closingFairProb) {
const g = Number(gradedFairProb);
const c = Number(closingFairProb);
if (!Number.isFinite(g) || !Number.isFinite(c)) return null;
return c - g;
}
module.exports = { oddsToImplied, impliedToAmerican, devig, clv };
+1 -1
View File
@@ -1,6 +1,6 @@
const { getAbbreviation } = require('./teamMap');
const ALLOWED_BOOKS = new Set(['draftkings', 'fanduel', 'betmgm']);
const ALLOWED_BOOKS = new Set(['draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365', 'hardrockbet', 'pointsbet', 'betrivers']);
const MARKET_MAP = {
player_points: 'points',
+136
View File
@@ -0,0 +1,136 @@
/**
* Token-bucket limiter + circuit breaker, factory-style.
*
* Why this exists alongside src/services/rateLimiter.js:
* - services/rateLimiter.js is a SHARED registry of upstream-keyed buckets
* ('espn', 'fanduel', etc.) used by the original odds adapters.
* - This file is a PER-INSTANCE factory used by the new pipeline adapters
* (SharpAPI, OddsPapi) where each adapter owns its own bucket and
* circuit breaker, paired together so a 5xx storm flips the breaker
* without polluting other adapters.
*
* The two coexist; new code should prefer this file.
*/
const DEFAULT_WAIT_TIMEOUT_MS = 30_000;
function createLimiter({ tokensPerInterval, interval }) {
if (!(tokensPerInterval > 0) || !(interval > 0)) {
throw new Error('createLimiter requires positive tokensPerInterval and interval');
}
const refillPerMs = tokensPerInterval / interval;
let tokens = tokensPerInterval;
let lastRefill = Date.now();
function refill() {
const now = Date.now();
const elapsed = now - lastRefill;
if (elapsed <= 0) return;
tokens = Math.min(tokensPerInterval, tokens + elapsed * refillPerMs);
lastRefill = now;
}
async function waitForToken(timeoutMs = DEFAULT_WAIT_TIMEOUT_MS) {
const deadline = Date.now() + timeoutMs;
while (true) {
refill();
if (tokens >= 1) {
tokens -= 1;
return true;
}
if (Date.now() >= deadline) {
// CRITICAL: never block forever. A stuck limiter must not kill the
// poller — log loud, proceed, let the caller hit the upstream rate
// limit naturally if it's actually exhausted.
console.warn('[rateLimiter] token wait exceeded timeout, proceeding without token');
return false;
}
const needed = 1 - tokens;
const waitMs = Math.min(250, Math.ceil(needed / refillPerMs));
await new Promise((r) => setTimeout(r, Math.max(10, waitMs)));
}
}
function snapshot() {
refill();
return { tokens: Math.floor(tokens), capacity: tokensPerInterval, interval };
}
return { waitForToken, snapshot };
}
function createCircuitBreaker({ failureThreshold = 3, resetTimeout = 60_000 } = {}) {
// States:
// closed — calls flow through, failures accumulate
// open — calls reject immediately until resetTimeout elapses
// half_open — one test call permitted; success closes, failure re-opens
let state = 'closed';
let failures = 0;
let openedAt = 0;
let halfOpenInFlight = false;
function transitionIfReady() {
if (state === 'open' && Date.now() - openedAt >= resetTimeout) {
state = 'half_open';
halfOpenInFlight = false;
}
}
async function call(fn) {
transitionIfReady();
if (state === 'open') {
const err = new Error('circuit breaker is open');
err.code = 'CIRCUIT_OPEN';
throw err;
}
if (state === 'half_open') {
if (halfOpenInFlight) {
const err = new Error('circuit breaker is half-open (test in flight)');
err.code = 'CIRCUIT_HALF_OPEN_BUSY';
throw err;
}
halfOpenInFlight = true;
}
try {
const result = await fn();
// Success: close the circuit and clear the failure count.
failures = 0;
state = 'closed';
halfOpenInFlight = false;
return result;
} catch (err) {
halfOpenInFlight = false;
if (state === 'half_open') {
state = 'open';
openedAt = Date.now();
throw err;
}
failures += 1;
if (failures >= failureThreshold) {
state = 'open';
openedAt = Date.now();
}
throw err;
}
}
function snapshot() {
transitionIfReady();
return { state, failures, openedAt };
}
return { call, snapshot };
}
const API_BUDGETS = Object.freeze({
sharpApi: { tokensPerInterval: 10, interval: 60_000 },
espn: { tokensPerInterval: 2, interval: 60_000 },
mlbStats: { tokensPerInterval: 2, interval: 60_000 },
oddsPapi: { tokensPerInterval: 5, interval: 60_000 },
openRouter: { tokensPerInterval: 15, interval: 60_000 },
});
module.exports = { createLimiter, createCircuitBreaker, API_BUDGETS };
+78 -2
View File
@@ -1,12 +1,88 @@
const Redis = require('ioredis');
// Redis is a cache, not a source of truth. If it's unreachable, callers
// should fall back to the underlying data store (Supabase). All higher-level
// helpers here return null on connection failure rather than throwing —
// that keeps every consumer single-branch (`if (cached) return cached`).
let client = null;
let degraded = false;
function getRedisClient() {
if (!client) {
client = new Redis(process.env.REDIS_URL || 'redis://127.0.0.1:6379');
client = new Redis(process.env.REDIS_URL || 'redis://127.0.0.1:6379', {
// Don't block the app starting up if Redis is down at boot. Failed
// commands fall through to the cache-miss path immediately.
lazyConnect: false,
enableOfflineQueue: false,
maxRetriesPerRequest: 1,
retryStrategy(times) {
// Exponential backoff up to 30s. ioredis keeps trying forever on its
// own; we just slow it down so the logs aren't a hose.
return Math.min(times * 1000, 30_000);
},
});
client.on('error', (err) => {
// Only log the first failure to avoid log spam during outages.
if (!degraded) {
console.warn('[redis] entering degraded mode:', err.message);
degraded = true;
}
});
client.on('ready', () => {
if (degraded) console.info('[redis] reconnected, leaving degraded mode');
degraded = false;
});
}
return client;
}
module.exports = { getRedisClient };
function isDegraded() {
return degraded;
}
async function cacheGet(key) {
if (degraded) return null;
try {
const raw = await getRedisClient().get(key);
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
// Non-JSON values are returned as-is (some callers store plain strings).
return raw;
}
} catch (err) {
if (!degraded) console.warn('[redis] cacheGet failed:', err.message);
return null;
}
}
async function cacheSet(key, value, ttlSeconds) {
if (degraded) return false;
try {
const payload = typeof value === 'string' ? value : JSON.stringify(value);
if (ttlSeconds && ttlSeconds > 0) {
await getRedisClient().set(key, payload, 'EX', ttlSeconds);
} else {
await getRedisClient().set(key, payload);
}
return true;
} catch (err) {
if (!degraded) console.warn('[redis] cacheSet failed:', err.message);
return false;
}
}
async function cacheDel(key) {
if (degraded) return false;
try {
await getRedisClient().del(key);
return true;
} catch (err) {
if (!degraded) console.warn('[redis] cacheDel failed:', err.message);
return false;
}
}
module.exports = { getRedisClient, cacheGet, cacheSet, cacheDel, isDegraded };