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:
Kev
2026-07-13 23:33:26 -04:00
parent a4b6255bed
commit f0752b804b
12 changed files with 920 additions and 1 deletions
+4
View File
@@ -184,6 +184,10 @@ const streaksRoutes = require('./routes/streaks');
app.use('/api/streaks', streaksRoutes); app.use('/api/streaks', streaksRoutes);
const hotListRoutes = require('./routes/hotlist'); const hotListRoutes = require('./routes/hotlist');
app.use('/api/hotlist', hotListRoutes); app.use('/api/hotlist', hotListRoutes);
// Wave 2A — offseason/never-dark hub feeds. FREE ESPN news wire + quota-
// disciplined championship futures (both graceful/empty; never error).
app.use('/api/news', require('./routes/news'));
app.use('/api/futures', require('./routes/futures'));
// Session 28 — parlay builder, line-movement views, book comparison. // Session 28 — parlay builder, line-movement views, book comparison.
// All three are zero-credit: parlay math is pure, lines read a Redis // All three are zero-credit: parlay math is pure, lines read a Redis
// snapshot history, books read the cached odds props. // snapshot history, books read the cached odds props.
+36
View File
@@ -0,0 +1,36 @@
/**
* /api/futures/:sport (Wave 2A, D1) — championship/outright futures.
*
* Response contract:
* { sport, updated_at, markets: [{ key, title, selections:
* [{ name, price, prevPrice?, move? }] }] }
* No data / gated-off (FUTURES_ENABLED=0) / quota-skip → { sport, updated_at,
* markets: [] }. Never errors, never fabricates a price.
*
* QUOTA-DISCIPLINED: futuresService fetches at most one odds-api outrights call
* per sport per 12h (default), isolated from the daily player-prop budget.
*/
const express = require('express');
const { createRateLimit } = require('../middleware/rateLimit');
const router = express.Router();
// Public throttle (30/min — futures are cheap to serve from cache).
router.use(createRateLimit({ windowMs: 60_000, max: 30 }));
const MISSION_HEADER = { 'X-VYNDR-Mission': 'The slate is never empty' };
router.get('/:sport', async (req, res) => {
const sport = String(req.params.sport || '').toLowerCase();
try {
const { getFutures } = require('../services/futuresService');
const payload = await getFutures(sport);
res.set('Cache-Control', 'public, max-age=600');
return res.set(MISSION_HEADER).json(payload);
} catch (err) {
console.error('[futures]', err.message);
return res.set(MISSION_HEADER).json({ sport, updated_at: new Date().toISOString(), markets: [] });
}
});
module.exports = router;
+33
View File
@@ -0,0 +1,33 @@
/**
* /api/news/:sport (Wave 2A) — the FREE ESPN news wire. NO odds-api credits.
*
* Response contract:
* { sport, items: [{ id, headline, description, published (ISO), type,
* athlete?: { name, key }, team?: string, href }] }
* Newest first, cap ~25. Empty feed / unknown sport → { sport, items: [] }.
* Never errors.
*/
const express = require('express');
const { createRateLimit } = require('../middleware/rateLimit');
const router = express.Router();
// Public throttle (60/min; ESPN is free but be respectful).
router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
const MISSION_HEADER = { 'X-VYNDR-Mission': 'The slate is never empty' };
router.get('/:sport', async (req, res) => {
const sport = String(req.params.sport || '').toLowerCase();
try {
const { getNews } = require('../services/newsService');
const payload = await getNews(sport);
res.set('Cache-Control', 'public, max-age=600');
return res.set(MISSION_HEADER).json(payload);
} catch (err) {
console.error('[news]', err.message);
return res.set(MISSION_HEADER).json({ sport, items: [] });
}
});
module.exports = router;
+228
View File
@@ -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 },
};
+127
View File
@@ -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 } };
+15
View File
@@ -76,6 +76,18 @@ const SPORT_KEYS = {
const SOCCER_SPORT_KEYS = Object.freeze( const SOCCER_SPORT_KEYS = Object.freeze(
Object.keys(SPORT_KEYS).filter((k) => k.startsWith('soccer_')) 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. // Session 16 — per-sport market lists.
// //
// The old `ALL_MARKETS = every key in MARKET_MAP` would send // The old `ALL_MARKETS = every key in MARKET_MAP` would send
@@ -510,6 +522,9 @@ module.exports = {
getCacheKey, getCacheKey,
SPORT_KEYS, SPORT_KEYS,
SOCCER_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). // Wave 6 — combat game-level markets (moneyline + round total).
MMA_MARKETS, MMA_MARKETS,
// Session 16 — per-sport market scoping. // Session 16 — per-sport market scoping.
+64 -1
View File
@@ -157,6 +157,69 @@ function normalizeProps(eventsWithOdds) {
return props; return props;
} }
/**
* american → decimal payout (for "best price" selection across books).
* +150 → 2.5, -200 → 1.5. Returns null for a non-integer/absent price.
*/
function americanToDecimal(price) {
if (price == null || typeof price !== 'number' || !Number.isFinite(price) || price === 0) return null;
return price > 0 ? 1 + price / 100 : 1 + 100 / Math.abs(price);
}
/**
* Wave 2A — outrights/futures normalizer. NEW branch: outrights outcomes are
* `{ name, price }` with NO `point` and NO Over/Under grouping, so
* `normalizeProps` (which requires `outcome.point` + pairs Over/Under) would
* DROP every one of them. This keeps them.
*
* Input: odds-api `/sports/{key}/odds?markets=outrights` events. Each event is
* a single futures market (e.g. "MLB World Series Winner"). Output: one market
* per event with the BEST available american price per selection across the
* ALLOWED books (best = highest decimal payout — honest "best price you can
* get"), plus the book that posted it for provenance.
*
* [{ key: sport_key, title: sport_title, selections:
* [{ name, price (american int), book }] }]
*
* Never fabricates: an event with no outrights market / no priced outcomes
* yields no selections; a book not on the ALLOWED list is skipped.
*/
function normalizeOutrights(events) {
const markets = [];
for (const event of Array.isArray(events) ? events : []) {
const key = event.sport_key || event.id;
if (!key) continue;
const title = event.sport_title || key;
// best[name] = { price, book, dec }
const best = {};
for (const bookmaker of Array.isArray(event.bookmakers) ? event.bookmakers : []) {
if (!ALLOWED_BOOKS.has(bookmaker.key)) continue;
for (const market of Array.isArray(bookmaker.markets) ? bookmaker.markets : []) {
if (market.key !== 'outrights') continue;
for (const outcome of market.outcomes || []) {
if (!outcome || !outcome.name || outcome.price == null) continue;
const dec = americanToDecimal(outcome.price);
if (dec == null) continue;
const cur = best[outcome.name];
if (!cur || dec > cur.dec) {
best[outcome.name] = { price: outcome.price, book: bookmaker.key, dec };
}
}
}
}
const selections = Object.keys(best).map((name) => ({
name,
price: best[name].price,
book: best[name].book,
}));
if (selections.length === 0) continue;
markets.push({ key: String(key), title: String(title), selections });
}
return markets;
}
function extractSpreads(eventsWithOdds) { function extractSpreads(eventsWithOdds) {
const spreads = []; const spreads = [];
@@ -197,4 +260,4 @@ function extractSpreads(eventsWithOdds) {
return spreads; return spreads;
} }
module.exports = { normalizeProps, extractSpreads, MARKET_MAP, ALLOWED_BOOKS }; module.exports = { normalizeProps, normalizeOutrights, americanToDecimal, extractSpreads, MARKET_MAP, ALLOWED_BOOKS };
+161
View File
@@ -0,0 +1,161 @@
'use strict';
const {
getFutures, priceMove, attachMoves, linkNewsToMoves,
} = require('../../src/services/futuresService');
const outrightEvents = [
{
id: 'evt-ws',
sport_key: 'baseball_mlb_world_series_winner',
sport_title: 'MLB World Series Winner',
bookmakers: [
{
key: 'draftkings',
markets: [{ key: 'outrights', outcomes: [
{ name: 'Los Angeles Dodgers', price: 300 },
{ name: 'New York Yankees', price: 500 },
] }],
},
],
},
];
describe('priceMove (pure)', () => {
test('shortening when payout drops (+400 → +300)', () => {
expect(priceMove(400, 300)).toBe('shortening');
});
test('drifting when payout rises (+300 → +450)', () => {
expect(priceMove(300, 450)).toBe('drifting');
});
test('flat within epsilon / unusable prices', () => {
expect(priceMove(300, 300)).toBe('flat');
expect(priceMove(null, 300)).toBe('flat');
expect(priceMove(300, null)).toBe('flat');
});
test('sign correct across the +/- american boundary', () => {
// +120 (dec 2.2) → -110 (dec 1.909): payout dropped → shortening
expect(priceMove(120, -110)).toBe('shortening');
// -110 → +120: payout rose → drifting
expect(priceMove(-110, 120)).toBe('drifting');
});
});
describe('attachMoves (pure)', () => {
const cur = [{ key: 'k', title: 'K', selections: [
{ name: 'A', price: 300 }, { name: 'B', price: 500 },
] }];
test('first fetch (no prev) → move:flat, no prevPrice', () => {
const out = attachMoves(cur, undefined);
expect(out[0].selections[0]).toEqual({ name: 'A', price: 300, move: 'flat' });
});
test('diffs against prev cached markets → prevPrice + move', () => {
const prev = [{ key: 'k', selections: [{ name: 'A', price: 400 }, { name: 'B', price: 500 }] }];
const out = attachMoves(cur, prev);
const a = out[0].selections.find((s) => s.name === 'A');
expect(a.prevPrice).toBe(400);
expect(a.move).toBe('shortening'); // 400 → 300
const b = out[0].selections.find((s) => s.name === 'B');
expect(b.prevPrice).toBe(500);
expect(b.move).toBe('flat'); // unchanged
});
});
describe('linkNewsToMoves (pure causal tie)', () => {
const news = [
{ headline: 'Dodgers acquire ace at deadline', published: '2026-07-30T12:00:00Z' },
{ headline: 'Unrelated older story', published: '2026-06-01T12:00:00Z' },
];
test('matches a move to the most-recent PRECEDING headline in window', () => {
const moves = [{ key: 'k', name: 'Los Angeles Dodgers', at: '2026-07-31T00:00:00Z' }];
const links = linkNewsToMoves(news, moves, 48);
expect(links).toEqual([{ moveKey: 'k|Los Angeles Dodgers', headline: 'Dodgers acquire ace at deadline' }]);
});
test('no headline within window → no cause (never invents)', () => {
const moves = [{ key: 'k', name: 'X', at: '2026-08-15T00:00:00Z' }];
expect(linkNewsToMoves(news, moves, 48)).toEqual([]);
});
test('headline AFTER the move is not a cause', () => {
const moves = [{ key: 'k', name: 'X', at: '2026-07-29T00:00:00Z' }];
expect(linkNewsToMoves(news, moves, 48)).toEqual([]);
});
});
describe('getFutures (injectable, offline)', () => {
const baseDeps = { apiKey: 'test', cacheGet: async () => null, cacheSet: async () => {} };
test('unknown sport → { markets: [] }, no fetch', async () => {
const axios = { get: jest.fn() };
const out = await getFutures('cricket', { ...baseDeps, axios });
expect(out.markets).toEqual([]);
expect(out.sport).toBe('cricket');
expect(axios.get).not.toHaveBeenCalled();
});
test('quota gate OFF (enabled:false) → { markets: [] }, no fetch', async () => {
const axios = { get: jest.fn() };
const out = await getFutures('mlb', { ...baseDeps, axios, enabled: false });
expect(out.markets).toEqual([]);
expect(axios.get).not.toHaveBeenCalled();
});
test('cold cache + enabled → fetches once, normalizes markets', async () => {
const axios = { get: jest.fn().mockResolvedValue({ data: outrightEvents, headers: {} }) };
const cacheSet = jest.fn(async () => {});
const out = await getFutures('mlb', { ...baseDeps, axios, enabled: true, ttl: 3600, cacheSet });
expect(axios.get).toHaveBeenCalledTimes(1);
// single outrights call
expect(axios.get.mock.calls[0][1].params.markets).toBe('outrights');
expect(out.markets).toHaveLength(1);
expect(out.markets[0].key).toBe('baseball_mlb_world_series_winner');
const dodgers = out.markets[0].selections.find((s) => s.name === 'Los Angeles Dodgers');
expect(dodgers.price).toBe(300);
expect(dodgers.move).toBe('flat'); // no prev
expect(cacheSet).toHaveBeenCalled();
});
test('price move shortening from a prev-price cache', async () => {
const prevCache = {
updated_at: '2000-01-01T00:00:00Z', // ancient → stale → refetch
markets: [{ key: 'baseball_mlb_world_series_winner', selections: [
{ name: 'Los Angeles Dodgers', price: 450 }, // was +450, now +300 → shortening
] }],
};
const axios = { get: jest.fn().mockResolvedValue({ data: outrightEvents, headers: {} }) };
const out = await getFutures('mlb', {
apiKey: 'test', axios, enabled: true, ttl: 3600,
cacheGet: async () => prevCache, cacheSet: async () => {},
});
const dodgers = out.markets[0].selections.find((s) => s.name === 'Los Angeles Dodgers');
expect(dodgers.prevPrice).toBe(450);
expect(dodgers.move).toBe('shortening');
});
test('fresh cache → served without fetching', async () => {
const fresh = {
updated_at: new Date().toISOString(),
markets: [{ key: 'baseball_mlb_world_series_winner', title: 'WS', selections: [{ name: 'A', price: 100 }] }],
};
const axios = { get: jest.fn() };
const out = await getFutures('mlb', {
apiKey: 'test', axios, enabled: true, ttl: 3600,
cacheGet: async () => fresh, cacheSet: async () => {},
});
expect(axios.get).not.toHaveBeenCalled();
expect(out.markets).toEqual(fresh.markets);
});
test('fetch failure → empty (never throws, never fabricates)', async () => {
const axios = { get: jest.fn().mockRejectedValue(new Error('boom')) };
const out = await getFutures('mlb', { ...baseDeps, axios, enabled: true, ttl: 3600 });
expect(out.markets).toEqual([]);
});
test('empty board with no prior cache → { markets: [] }', async () => {
const axios = { get: jest.fn().mockResolvedValue({ data: [], headers: {} }) };
const out = await getFutures('mlb', { ...baseDeps, axios, enabled: true, ttl: 3600 });
expect(out.markets).toEqual([]);
});
});
+107
View File
@@ -0,0 +1,107 @@
'use strict';
const { parseNews, getNews } = require('../../src/services/newsService');
const espnFixture = {
header: 'MLB News',
articles: [
{
id: 45735348,
type: 'Story',
headline: 'Aaron Judge homers twice in Yankees win',
description: 'The captain went deep in the seventh and ninth.',
published: '2026-07-14T03:08:19Z',
links: { web: { href: 'https://www.espn.com/mlb/story/_/id/45735348/judge' } },
categories: [
{ type: 'league', description: 'MLB', sportId: 10 },
{ type: 'athlete', description: 'Aaron Judge', athleteId: 33192, athlete: { id: 33192, description: 'Aaron Judge' } },
{ type: 'team', description: 'New York Yankees', teamId: 10, team: { id: 10, description: 'New York Yankees', abbreviation: 'NYY' } },
],
},
{
id: 45735111,
type: 'HeadlineNews',
headline: 'Commissioner addresses expansion timeline',
description: 'League office weighs in on the next two cities.',
published: '2026-07-13T20:00:00Z',
links: { web: { href: 'https://www.espn.com/mlb/story/_/id/45735111/expansion' } },
categories: [
{ type: 'league', description: 'MLB', sportId: 10 }, // NO athlete, NO team
],
},
],
};
describe('parseNews (pure)', () => {
test('maps ESPN articles → the contract items[]', () => {
const items = parseNews(espnFixture);
expect(items).toHaveLength(2);
const a = items[0];
expect(a.id).toBe('45735348');
expect(a.headline).toMatch(/Aaron Judge/);
expect(a.type).toBe('Story');
expect(a.published).toBe('2026-07-14T03:08:19Z');
expect(a.href).toBe('https://www.espn.com/mlb/story/_/id/45735348/judge');
});
test('extracts athlete + team from categories where present', () => {
const [a] = parseNews(espnFixture);
expect(a.athlete).toEqual({ name: 'Aaron Judge', key: expect.any(String) });
expect(a.athlete.key).toBe('aaron judge');
expect(a.team).toBe('New York Yankees');
});
test('missing athlete/team → fields ABSENT, never invented', () => {
const items = parseNews(espnFixture);
const noEntity = items.find((i) => i.headline.includes('expansion'));
expect(noEntity).toBeTruthy();
expect('athlete' in noEntity).toBe(false);
expect('team' in noEntity).toBe(false);
});
test('sorts newest first by published', () => {
const items = parseNews(espnFixture);
expect(items[0].published > items[1].published).toBe(true);
});
test('empty / malformed input → []', () => {
expect(parseNews({})).toEqual([]);
expect(parseNews(null)).toEqual([]);
expect(parseNews({ articles: 'nope' })).toEqual([]);
// Article with no headline is dropped.
expect(parseNews({ articles: [{ id: 1, description: 'x' }] })).toEqual([]);
});
});
describe('getNews (injectable, offline)', () => {
test('unknown sport → { sport, items: [] } without fetching', async () => {
const axios = { get: jest.fn() };
const out = await getNews('cricket', { axios, cacheGet: async () => null, cacheSet: async () => {} });
expect(out).toEqual({ sport: 'cricket', items: [] });
expect(axios.get).not.toHaveBeenCalled();
});
test('cache miss → fetches, parses, caches', async () => {
const axios = { get: jest.fn().mockResolvedValue({ data: espnFixture }) };
const cacheSet = jest.fn(async () => {});
const out = await getNews('mlb', { axios, cacheGet: async () => null, cacheSet });
expect(out.sport).toBe('mlb');
expect(out.items).toHaveLength(2);
expect(axios.get).toHaveBeenCalledTimes(1);
expect(cacheSet).toHaveBeenCalledWith('news:mlb', { items: expect.any(Array) }, expect.any(Number));
});
test('cache hit → serves cache, no fetch', async () => {
const axios = { get: jest.fn() };
const cached = { items: [{ id: '1', headline: 'cached', published: '2026-07-14T00:00:00Z', type: 'Story', href: null, description: '' }] };
const out = await getNews('mlb', { axios, cacheGet: async () => cached, cacheSet: async () => {} });
expect(out.items).toEqual(cached.items);
expect(axios.get).not.toHaveBeenCalled();
});
test('fetch failure → empty feed (never throws)', async () => {
const axios = { get: jest.fn().mockRejectedValue(new Error('network')) };
const out = await getNews('nba', { axios, cacheGet: async () => null, cacheSet: async () => {} });
expect(out).toEqual({ sport: 'nba', items: [] });
});
});
@@ -0,0 +1,91 @@
'use strict';
const { normalizeOutrights, normalizeProps, americanToDecimal } = require('../../src/utils/oddsNormalizer');
const outrightEvents = [
{
id: 'evt-ws',
sport_key: 'baseball_mlb_world_series_winner',
sport_title: 'MLB World Series Winner',
commence_time: '2026-10-20T00:00:00Z',
bookmakers: [
{
key: 'draftkings',
title: 'DraftKings',
markets: [
{
key: 'outrights',
outcomes: [
{ name: 'Los Angeles Dodgers', price: 350 },
{ name: 'New York Yankees', price: 450 },
],
},
],
},
{
key: 'fanduel',
title: 'FanDuel',
markets: [
{
key: 'outrights',
outcomes: [
{ name: 'Los Angeles Dodgers', price: 400 }, // better payout than DK's 350
{ name: 'New York Yankees', price: 420 },
],
},
],
},
{
key: 'bovada', // NOT allowed → skipped
markets: [{ key: 'outrights', outcomes: [{ name: 'Los Angeles Dodgers', price: 9999 }] }],
},
],
},
];
describe('normalizeOutrights', () => {
test('normalizes outright outcomes (name+price, no point) into markets', () => {
const markets = normalizeOutrights(outrightEvents);
expect(markets).toHaveLength(1);
const m = markets[0];
expect(m.key).toBe('baseball_mlb_world_series_winner');
expect(m.title).toBe('MLB World Series Winner');
expect(m.selections).toHaveLength(2);
});
test('picks the BEST (highest decimal payout) price per selection across allowed books', () => {
const [m] = normalizeOutrights(outrightEvents);
const dodgers = m.selections.find((s) => s.name === 'Los Angeles Dodgers');
// FanDuel +400 beats DraftKings +350; Bovada is not an allowed book.
expect(dodgers.price).toBe(400);
expect(dodgers.book).toBe('fanduel');
const yankees = m.selections.find((s) => s.name === 'New York Yankees');
// DraftKings +450 beats FanDuel +420.
expect(yankees.price).toBe(450);
expect(yankees.book).toBe('draftkings');
});
test('normalizeProps would DROP these outright outcomes (no point) — the reason a new branch exists', () => {
// Feed the SAME events through the player-prop normalizer: outrights have
// no `point` and no Over/Under, so normalizeProps yields nothing.
const props = normalizeProps(outrightEvents);
expect(props).toEqual([]);
// ...while normalizeOutrights keeps them.
expect(normalizeOutrights(outrightEvents)[0].selections.length).toBeGreaterThan(0);
});
test('never fabricates: empty / malformed input → empty array', () => {
expect(normalizeOutrights([])).toEqual([]);
expect(normalizeOutrights(null)).toEqual([]);
expect(normalizeOutrights([{ sport_key: 'x', bookmakers: [] }])).toEqual([]);
expect(normalizeOutrights([{ sport_key: 'x', bookmakers: [{ key: 'draftkings', markets: [{ key: 'outrights', outcomes: [{ name: 'A', price: null }] }] }] }])).toEqual([]);
});
test('americanToDecimal: +150→2.5, -200→1.5, junk→null', () => {
expect(americanToDecimal(150)).toBeCloseTo(2.5, 5);
expect(americanToDecimal(-200)).toBeCloseTo(1.5, 5);
expect(americanToDecimal(0)).toBeNull();
expect(americanToDecimal(null)).toBeNull();
expect(americanToDecimal('150')).toBeNull();
});
});
+27
View File
@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/**
* Futures proxy (Wave 2A, S25 rule). Forwards to the Express
* `/api/futures/:sport` (quota-disciplined championship outrights).
* Degrades to an empty-but-valid board so the hub never blows up.
*/
export async function GET(req: NextRequest, { params }: { params: Promise<{ sport: string }> }) {
const { sport } = await params;
const sportLc = String(sport || '').toLowerCase();
const qs = req.nextUrl.search;
try {
const upstream = await fetch(`${BACKEND_URL}/api/futures/${encodeURIComponent(sportLc)}${qs}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const data = await upstream.json().catch(() => ({}));
if (!upstream.ok) return NextResponse.json(data, { status: upstream.status });
return NextResponse.json(data);
} catch {
return NextResponse.json({ sport: sportLc, updated_at: new Date().toISOString(), markets: [] });
}
}
+27
View File
@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/**
* News wire proxy (Wave 2A, S25 rule). Forwards to the Express
* `/api/news/:sport` (FREE ESPN feed). Degrades to an empty-but-valid
* feed so the offseason hub never blows up.
*/
export async function GET(req: NextRequest, { params }: { params: Promise<{ sport: string }> }) {
const { sport } = await params;
const sportLc = String(sport || '').toLowerCase();
const qs = req.nextUrl.search;
try {
const upstream = await fetch(`${BACKEND_URL}/api/news/${encodeURIComponent(sportLc)}${qs}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const data = await upstream.json().catch(() => ({}));
if (!upstream.ok) return NextResponse.json(data, { status: upstream.status });
return NextResponse.json(data);
} catch {
return NextResponse.json({ sport: sportLc, items: [] });
}
}