2d413cfe1e
Diagnosis (why 500/500 went unpaged): the only regular odds-api burner was
futuresService, which called axios DIRECTLY — bypassing the gateway, so it
never hit recordCall (the ONE place the WARN/BLOCK pager fires) and never
respected the 95% block. It only syncFromHeaders, which updated the counter's
number SILENTLY. oddsService (which does go through the gateway) only touches
odds-api when PropLine fails, so recordCall for odds-api effectively never ran.
Result: the counter could reach 100% with neither pager firing.
Fixes (a silent drain is now impossible, not just guarded):
- futuresService routes through gateway.fetch('odds-api', …) → counted, blocked
at 95%, and reserve-gated. Closes the raw-axios bypass.
- Reserve floor in the gateway: a DISCRETIONARY call (futures/soccer) passes
reserve=ODDS_API_RESERVE (default 50) and is refused while remaining <= reserve.
The ESSENTIAL MLB prop-backup passes no reserve and may spend to the 95% block.
→ a futures/soccer drain can NEVER starve MLB's backup path.
- quotaTracker.syncFromHeaders (the AUTHORITATIVE number) now fires the same
once-per-period WARN/BLOCK alert on a crossing — extracted fireThresholdAlert
shared with recordCall. The header-only drain now pages.
- POST /api/internal/quota/test-alert (internal-key) test-fires the pager
end-to-end so ntfy delivery is verifiable on demand.
Also (reality-corrected cadence): WNBA restored to the full grid. 2026-07-15
had two AFTERNOON WNBA games finished before the 22 UTC slot — 14 UTC (10am ET)
is the only slot early enough for a 1pm ET game's props, and on PropLine the
extra slots cost a rounding error. Soccer stays the only trimmed sport (the
real odds-api discipline). Assumption corrected by observed data.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
242 lines
9.8 KiB
JavaScript
242 lines
9.8 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* futuresService — championship/outright futures wire (Wave 2A, D1).
|
|
*
|
|
* D1 DECISION: BUILD futures, QUOTA-DISCIPLINED. Futures spend the already-paid
|
|
* The Odds API 500/mo quota, so this path is deliberately isolated from the
|
|
* daily player-prop budget:
|
|
* - Separate `FUTURES_KEYS` map in oddsService (NOT in SPORT_KEYS / the
|
|
* snapshot loop).
|
|
* - A SINGLE `/sports/{key}/odds?markets=outrights` call per refresh —
|
|
* outrights do NOT fan out over events, so it's 1 credit, not 1+N.
|
|
* - Long logical TTL (default 12h; futures move slowly). With a 12h TTL the
|
|
* ceiling is 2 refreshes/day/sport ≈ 60 credits/mo/sport IF continuously
|
|
* queried; realistically a handful/day because fetches are on-demand
|
|
* (cache-aside, no cron). `FUTURES_ENABLED=0` skips the fetch entirely.
|
|
*
|
|
* Contract (consumed by the parallel Wave 2B ExploreHub):
|
|
* { sport, updated_at, markets: [{ key, title, selections:
|
|
* [{ name, price (american int), prevPrice?: int, move?:
|
|
* 'shortening'|'drifting'|'flat' }] }] }
|
|
* No data / gated-off / quota-skip → { sport, updated_at, markets: [] }.
|
|
* NEVER errors, NEVER fabricates a price.
|
|
*
|
|
* Line-movement REUSE: `move` is computed by diffing the current outright PRICE
|
|
* against the previous cached snapshot's price per selection — the same SHAPE
|
|
* as snapshotService.computeLineDeltas/signedDelta, but on `odds` (price) not
|
|
* `line`. Prev prices are persisted INSIDE the `futures:{sport}` cache value
|
|
* (no new Redis key): the previous write's `price` fields ARE the next diff's
|
|
* reference.
|
|
*/
|
|
|
|
const { normalizeOutrights, americanToDecimal } = require('../utils/oddsNormalizer');
|
|
const { FUTURES_KEYS, ODDS_API_BASE } = require('./oddsService');
|
|
|
|
const DEFAULT_TTL = 12 * 60 * 60; // 12h logical freshness (quota-disciplined).
|
|
const PERSIST_TTL = 7 * 24 * 60 * 60; // 7d Redis persistence so prev survives the gap.
|
|
const HTTP_TIMEOUT_MS = 12_000;
|
|
// Credits held back for the ESSENTIAL path (MLB prop backup when PropLine
|
|
// fails). Discretionary futures calls stop while ≤ this many odds-api credits
|
|
// remain. Operator-tunable; default 50 of the 500/mo pool.
|
|
const ODDS_API_RESERVE = (() => {
|
|
const n = Number.parseInt(process.env.ODDS_API_RESERVE, 10);
|
|
return Number.isFinite(n) && n >= 0 ? n : 50;
|
|
})();
|
|
// A price is "flat" unless the decimal payout moves by at least this much —
|
|
// filters odds-jitter from a real steam/drift (mirrors DELTA_NOISE in shape).
|
|
const MOVE_EPSILON = 0.05;
|
|
|
|
function futuresEnabled() {
|
|
const raw = process.env.FUTURES_ENABLED;
|
|
if (raw === '0' || String(raw).toLowerCase() === 'false') return false;
|
|
return true; // default ON
|
|
}
|
|
|
|
function configuredTTL() {
|
|
const raw = process.env.FUTURES_CACHE_TTL_SECONDS;
|
|
if (!raw) return DEFAULT_TTL;
|
|
const n = Number.parseInt(raw, 10);
|
|
if (!Number.isFinite(n) || n < 300 || n > 86400) return DEFAULT_TTL; // 5m..24h
|
|
return n;
|
|
}
|
|
|
|
/**
|
|
* Pure: classify a price move from prev→cur american prices. Uses DECIMAL
|
|
* payout so the sign is correct across the +/- american boundary.
|
|
* shortening = odds got SHORTER (payout ↓, market more confident)
|
|
* drifting = odds got LONGER (payout ↑, market less confident)
|
|
* flat = |Δdecimal| < epsilon, or either price unusable
|
|
*/
|
|
function priceMove(prevPrice, curPrice) {
|
|
const prev = americanToDecimal(prevPrice);
|
|
const cur = americanToDecimal(curPrice);
|
|
if (prev == null || cur == null) return 'flat';
|
|
const d = cur - prev;
|
|
if (d <= -MOVE_EPSILON) return 'shortening';
|
|
if (d >= MOVE_EPSILON) return 'drifting';
|
|
return 'flat';
|
|
}
|
|
|
|
/**
|
|
* Diff freshly-normalized markets against the previous cached contract markets,
|
|
* attaching `prevPrice` + `move` per selection. Pure. `prevMarkets` may be
|
|
* undefined (first-ever fetch → every selection is `flat` with no prevPrice).
|
|
*/
|
|
function attachMoves(newMarkets, prevMarkets) {
|
|
const prevIndex = {}; // key|name → price
|
|
for (const m of Array.isArray(prevMarkets) ? prevMarkets : []) {
|
|
for (const s of m.selections || []) {
|
|
if (s && s.name != null && s.price != null) prevIndex[`${m.key}|${s.name}`] = s.price;
|
|
}
|
|
}
|
|
return (newMarkets || []).map((m) => ({
|
|
key: m.key,
|
|
title: m.title,
|
|
selections: (m.selections || []).map((s) => {
|
|
const prevPrice = prevIndex[`${m.key}|${s.name}`];
|
|
const sel = { name: s.name, price: s.price };
|
|
if (prevPrice != null) {
|
|
sel.prevPrice = prevPrice;
|
|
sel.move = priceMove(prevPrice, s.price);
|
|
} else {
|
|
sel.move = 'flat';
|
|
}
|
|
return sel;
|
|
}),
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Pure NEWS→MOVE causal tie (optional-but-nice). Match a futures move to a
|
|
* headline whose `published` PRECEDES the move within `windowHours`. Returns
|
|
* `[{ moveKey, headline }]`. `moves` = [{ key, name, at }] (at = ISO time the
|
|
* move was detected). NEVER invents a cause — a move with no preceding headline
|
|
* in-window yields nothing.
|
|
*/
|
|
function linkNewsToMoves(newsItems, moves, windowHours = 48) {
|
|
const out = [];
|
|
const windowMs = windowHours * 60 * 60 * 1000;
|
|
const dated = (Array.isArray(newsItems) ? newsItems : [])
|
|
.filter((n) => n && n.published && Number.isFinite(Date.parse(n.published)))
|
|
.map((n) => ({ headline: n.headline, t: Date.parse(n.published) }));
|
|
for (const mv of Array.isArray(moves) ? moves : []) {
|
|
const at = mv && mv.at ? Date.parse(mv.at) : NaN;
|
|
if (!Number.isFinite(at)) continue;
|
|
let best = null;
|
|
for (const n of dated) {
|
|
if (n.t <= at && at - n.t <= windowMs) {
|
|
if (!best || n.t > best.t) best = n; // most recent preceding headline
|
|
}
|
|
}
|
|
if (best) out.push({ moveKey: `${mv.key}|${mv.name}`, headline: best.headline });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Build the empty-but-valid contract response. */
|
|
function emptyResponse(sport) {
|
|
return { sport, updated_at: new Date().toISOString(), markets: [] };
|
|
}
|
|
|
|
/**
|
|
* getFutures(sport, deps) → the Wave-2 futures contract. Cache-aside on a long
|
|
* logical TTL; persists prev prices inside the same key for the next diff.
|
|
* All deps injectable → unit-tested with zero network. Never throws.
|
|
*/
|
|
async function getFutures(sport, deps = {}) {
|
|
const sp = String(sport || '').toLowerCase();
|
|
const futuresKey = (deps.FUTURES_KEYS || FUTURES_KEYS)[sp];
|
|
if (!futuresKey) return emptyResponse(sp);
|
|
|
|
const enabled = deps.enabled != null ? deps.enabled : futuresEnabled();
|
|
const ttl = deps.ttl != null ? deps.ttl : configuredTTL();
|
|
const cacheGet = deps.cacheGet || require('../utils/redis').cacheGet;
|
|
const cacheSet = deps.cacheSet || require('../utils/redis').cacheSet;
|
|
const key = `futures:${sp}`;
|
|
|
|
// Read the last board (persists past the logical TTL so prev prices survive).
|
|
let cached = null;
|
|
try { cached = await cacheGet(key); } catch (_) { cached = null; }
|
|
const now = Date.now();
|
|
const isFresh = cached && cached.updated_at &&
|
|
(now - Date.parse(cached.updated_at)) < ttl * 1000;
|
|
|
|
// Gate: disabled → never spend quota. Serve the last board if we have one
|
|
// (honest, already-captured market values), else empty.
|
|
if (!enabled) {
|
|
if (cached && Array.isArray(cached.markets)) {
|
|
return { sport: sp, updated_at: cached.updated_at, markets: cached.markets };
|
|
}
|
|
return emptyResponse(sp);
|
|
}
|
|
|
|
// Fresh cache → serve it, no fetch.
|
|
if (isFresh) {
|
|
return { sport: sp, updated_at: cached.updated_at, markets: cached.markets };
|
|
}
|
|
|
|
// Stale / cold → fetch a single outrights call.
|
|
const apiKey = deps.apiKey || process.env.ODDS_API_KEY;
|
|
if (!apiKey) {
|
|
if (cached && Array.isArray(cached.markets)) {
|
|
return { sport: sp, updated_at: cached.updated_at, markets: cached.markets };
|
|
}
|
|
return emptyResponse(sp);
|
|
}
|
|
|
|
const axios = deps.axios || require('axios');
|
|
const base = deps.ODDS_API_BASE || ODDS_API_BASE;
|
|
// Quota guard — futures is DISCRETIONARY, so it goes through the gateway
|
|
// (counted via recordCall → the 80% pager sees it, and blocked at 95%) with a
|
|
// RESERVE floor: it stops spending while ≤ ODDS_API_RESERVE credits remain, so
|
|
// a futures drain can never starve the essential MLB prop-backup path. This
|
|
// closes the raw-axios bypass that let futures burn odds-api invisibly (the
|
|
// reason the 500/500 exhaustion went unpaged). The gateway also syncs headers.
|
|
const gateway = deps.gateway || require('./providerGateway');
|
|
const reserve = deps.reserve != null ? deps.reserve : ODDS_API_RESERVE;
|
|
try {
|
|
const res = await gateway.fetch(
|
|
'odds-api',
|
|
() => axios.get(`${base}/${futuresKey}/odds`, {
|
|
params: { apiKey, regions: 'us', markets: 'outrights', oddsFormat: 'american' },
|
|
timeout: HTTP_TIMEOUT_MS,
|
|
}),
|
|
{ capability: 'futures', sport: sp, reserve, syncHeadersFrom: (r) => r && r.headers },
|
|
);
|
|
|
|
const normalized = normalizeOutrights(res.data);
|
|
const markets = attachMoves(normalized, cached && cached.markets);
|
|
|
|
// Empty board (off-season / no priced selections) → serve empty, but keep
|
|
// any prior good board rather than clobbering it with nothing.
|
|
if (markets.length === 0) {
|
|
if (cached && Array.isArray(cached.markets) && cached.markets.length > 0) {
|
|
return { sport: sp, updated_at: cached.updated_at, markets: cached.markets };
|
|
}
|
|
return emptyResponse(sp);
|
|
}
|
|
|
|
const updated_at = new Date().toISOString();
|
|
try { await cacheSet(key, { updated_at, markets }, PERSIST_TTL); } catch (_) { /* best-effort */ }
|
|
return { sport: sp, updated_at, markets };
|
|
} catch (e) {
|
|
console.warn(`[futures] ${sp} fetch failed:`, e.message);
|
|
// Serve stale board if present — better than empty, never fabricated.
|
|
if (cached && Array.isArray(cached.markets)) {
|
|
return { sport: sp, updated_at: cached.updated_at, markets: cached.markets };
|
|
}
|
|
return emptyResponse(sp);
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
getFutures,
|
|
priceMove,
|
|
attachMoves,
|
|
linkNewsToMoves,
|
|
futuresEnabled,
|
|
configuredTTL,
|
|
__internals: { DEFAULT_TTL, PERSIST_TTL, MOVE_EPSILON },
|
|
};
|