Wave 2A: offseason data feeds — news wire + quota-disciplined futures
FREE ESPN news wire + championship-winner futures for the never-dark
offseason hub. Both graceful/empty, never fabricate a market value.
- newsService (mirrors injuryService): per-sport ESPN /news FEEDS, pure
parseNews → { sport, items:[{id,headline,description,published,type,
athlete?{name,key},team?,href}] }; athlete/team from categories[] only
(absent when not present). Cache 15m, injectable, offline-tested.
- oddsNormalizer.normalizeOutrights: NEW branch — outrights outcomes are
{name,price} with no point, so normalizeProps drops them; keeps them with
best-price-across-allowed-books per selection. + americanToDecimal.
- oddsService.FUTURES_KEYS: separate map (mlb/nba/wnba championship winner),
OUT of the daily SPORT_KEYS/snapshot budget.
- futuresService: getFutures(sport,deps) → { sport, updated_at, markets:
[{key,title,selections:[{name,price,prevPrice?,move?}]}] }. One outrights
call per 12h TTL (quota-disciplined), FUTURES_ENABLED gate. Price-move
(shortening/drifting/flat) mirrors computeLineDeltas SHAPE on odds not
line; prev prices persisted inside the futures:{sport} value (no new key).
linkNewsToMoves pure causal-tie helper.
- Routes /api/news/:sport + /api/futures/:sport (registered) + Next proxies.
- Tests: newsService, futuresService, oddsNormalizerOutrights (fail→pass,
no network). Full suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
'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;
|
||||
// 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;
|
||||
try {
|
||||
const res = await axios.get(`${base}/${futuresKey}/odds`, {
|
||||
params: { apiKey, regions: 'us', markets: 'outrights', oddsFormat: 'american' },
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
});
|
||||
// Best-effort quota sync (same headers the player-prop path reads).
|
||||
try {
|
||||
if (res && res.headers) {
|
||||
require('./quotaTracker').syncFromHeaders('odds-api', res.headers);
|
||||
}
|
||||
} catch (_) { /* quota tracking is a signal, never a dependency */ }
|
||||
|
||||
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 },
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* newsService — the FREE ESPN news wire (Wave 2A / offseason-hub).
|
||||
*
|
||||
* Mirrors injuryService's pattern EXACTLY: per-sport FEEDS map → ESPN's
|
||||
* free `/news` endpoint, injectable axios/http + cache 15 min, pure
|
||||
* `parseNews(json)` → the Wave-2 news contract. NO odds-api credits.
|
||||
*
|
||||
* Contract (consumed by the parallel Wave 2B ExploreHub):
|
||||
* { sport, items: [{ id, headline, description, published (ISO), type,
|
||||
* athlete?: { name, key }, team?: string, href }] }
|
||||
* Newest first, cap ~25. Empty feed → { sport, items: [] } (never errors).
|
||||
*
|
||||
* Governing rules: never fabricate — athlete/team are extracted ONLY from
|
||||
* ESPN `categories[]` where present, and are ABSENT (undefined) otherwise.
|
||||
*/
|
||||
|
||||
const { nameKey } = require('../utils/playerName');
|
||||
|
||||
const TTL = 900; // 15 min — same cadence as injuryService.
|
||||
const HTTP_TIMEOUT_MS = 10_000;
|
||||
const CAP = 25;
|
||||
|
||||
// ESPN site API `/news` per sport (FREE, no auth). Soccer/nfl/nhl are
|
||||
// optional — absent keys degrade to an empty feed, never an error.
|
||||
const FEEDS = {
|
||||
mlb: 'https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/news',
|
||||
nba: 'https://site.api.espn.com/apis/site/v2/sports/basketball/nba/news',
|
||||
wnba: 'https://site.api.espn.com/apis/site/v2/sports/basketball/wnba/news',
|
||||
nfl: 'https://site.api.espn.com/apis/site/v2/sports/football/nfl/news',
|
||||
nhl: 'https://site.api.espn.com/apis/site/v2/sports/hockey/nhl/news',
|
||||
};
|
||||
|
||||
/** First `categories[]` entry of a given ESPN type, or null. */
|
||||
function firstCategory(categories, type) {
|
||||
for (const c of Array.isArray(categories) ? categories : []) {
|
||||
if (c && c.type === type) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure: one ESPN article → the contract's item, or null if unusable
|
||||
* (no headline). athlete/team are attached ONLY when a matching category
|
||||
* is present — never invented.
|
||||
*/
|
||||
function parseArticle(article) {
|
||||
if (!article || !article.headline) return null;
|
||||
|
||||
const cats = article.categories;
|
||||
const item = {
|
||||
id: article.id != null ? String(article.id) : null,
|
||||
headline: String(article.headline),
|
||||
description: article.description ? String(article.description) : '',
|
||||
published: article.published || article.lastModified || null,
|
||||
type: article.type || 'Story',
|
||||
href: (article.links && article.links.web && article.links.web.href) || null,
|
||||
};
|
||||
|
||||
// Athlete — from the first `type:'athlete'` category (has `description` = name).
|
||||
const ath = firstCategory(cats, 'athlete');
|
||||
const athName = ath && (ath.description || (ath.athlete && ath.athlete.description));
|
||||
if (athName) {
|
||||
item.athlete = { name: String(athName), key: nameKey(String(athName)) };
|
||||
}
|
||||
|
||||
// Team — from the first `type:'team'` category (full display name, or abbr).
|
||||
const tm = firstCategory(cats, 'team');
|
||||
const tmName = tm && (tm.description || (tm.team && (tm.team.description || tm.team.abbreviation)));
|
||||
if (tmName) {
|
||||
item.team = String(tmName);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/** Pure: ESPN news JSON → the contract's items[] (newest first, capped). */
|
||||
function parseNews(json) {
|
||||
const articles = (json && Array.isArray(json.articles)) ? json.articles : [];
|
||||
const items = [];
|
||||
for (const a of articles) {
|
||||
const it = parseArticle(a);
|
||||
if (it) items.push(it);
|
||||
}
|
||||
// ESPN returns newest-first already; sort defensively by `published` desc
|
||||
// (items with no timestamp sink to the bottom, order preserved otherwise).
|
||||
items.sort((x, y) => {
|
||||
const tx = x.published ? Date.parse(x.published) : 0;
|
||||
const ty = y.published ? Date.parse(y.published) : 0;
|
||||
return (ty || 0) - (tx || 0);
|
||||
});
|
||||
return items.slice(0, CAP);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch (cache-aside) the news wire for a sport → { sport, items: [] }.
|
||||
* Injectable axios/cacheGet/cacheSet → unit-tested fully offline. Never
|
||||
* throws: an unknown sport or a failed fetch returns an empty feed.
|
||||
*/
|
||||
async function getNews(sport, opts = {}) {
|
||||
const sp = String(sport || '').toLowerCase();
|
||||
const url = FEEDS[sp];
|
||||
if (!url) return { sport: sp, items: [] };
|
||||
|
||||
const cacheGet = opts.cacheGet || require('../utils/redis').cacheGet;
|
||||
const cacheSet = opts.cacheSet || require('../utils/redis').cacheSet;
|
||||
const key = `news:${sp}`;
|
||||
|
||||
try {
|
||||
const cached = await cacheGet(key);
|
||||
if (cached && Array.isArray(cached.items)) return { sport: sp, items: cached.items };
|
||||
} catch (_) { /* cache miss / redis down → fetch live */ }
|
||||
|
||||
const axios = opts.axios || require('axios');
|
||||
try {
|
||||
const res = await axios.get(url, { timeout: HTTP_TIMEOUT_MS });
|
||||
const items = parseNews(res.data);
|
||||
try { await cacheSet(key, { items }, TTL); } catch (_) { /* best-effort */ }
|
||||
return { sport: sp, items };
|
||||
} catch (e) {
|
||||
console.warn(`[news] ${sp} fetch failed:`, e.message);
|
||||
return { sport: sp, items: [] };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getNews, parseNews, parseArticle, __internals: { TTL, FEEDS, CAP } };
|
||||
@@ -76,6 +76,18 @@ const SPORT_KEYS = {
|
||||
const SOCCER_SPORT_KEYS = Object.freeze(
|
||||
Object.keys(SPORT_KEYS).filter((k) => k.startsWith('soccer_'))
|
||||
);
|
||||
|
||||
// Wave 2A — FUTURES / OUTRIGHTS keys. DELIBERATELY SEPARATE from SPORT_KEYS so
|
||||
// they NEVER enter the daily player-prop `getOdds`/snapshot loop or its budget.
|
||||
// These are championship-winner outright markets on the already-paid odds-api
|
||||
// key, fetched by `futuresService` on a long TTL (quota-disciplined). Each key
|
||||
// is a SINGLE `/sports/{key}/odds?markets=outrights` call (outrights don't fan
|
||||
// out over events → 1 credit per refresh, not 1+N like player props).
|
||||
const FUTURES_KEYS = Object.freeze({
|
||||
mlb: 'baseball_mlb_world_series_winner',
|
||||
nba: 'basketball_nba_championship_winner',
|
||||
wnba: 'basketball_wnba_championship_winner',
|
||||
});
|
||||
// Session 16 — per-sport market lists.
|
||||
//
|
||||
// The old `ALL_MARKETS = every key in MARKET_MAP` would send
|
||||
@@ -510,6 +522,9 @@ module.exports = {
|
||||
getCacheKey,
|
||||
SPORT_KEYS,
|
||||
SOCCER_SPORT_KEYS,
|
||||
// Wave 2A — futures/outrights keys (separate budget; futuresService only).
|
||||
FUTURES_KEYS,
|
||||
ODDS_API_BASE,
|
||||
// Wave 6 — combat game-level markets (moneyline + round total).
|
||||
MMA_MARKETS,
|
||||
// Session 16 — per-sport market scoping.
|
||||
|
||||
Reference in New Issue
Block a user