'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 }, };