From f0752b804bf973ed0ddc49f5e034548c723b6577 Mon Sep 17 00:00:00 2001 From: Kev Date: Mon, 13 Jul 2026 23:33:26 -0400 Subject: [PATCH] =?UTF-8?q?Wave=202A:=20offseason=20data=20feeds=20?= =?UTF-8?q?=E2=80=94=20news=20wire=20+=20quota-disciplined=20futures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/app.js | 4 + src/routes/futures.js | 36 ++++ src/routes/news.js | 33 +++ src/services/futuresService.js | 228 +++++++++++++++++++++ src/services/newsService.js | 127 ++++++++++++ src/services/oddsService.js | 15 ++ src/utils/oddsNormalizer.js | 65 +++++- tests/unit/futuresService.test.js | 161 +++++++++++++++ tests/unit/newsService.test.js | 107 ++++++++++ tests/unit/oddsNormalizerOutrights.test.js | 91 ++++++++ web/src/app/api/futures/[sport]/route.ts | 27 +++ web/src/app/api/news/[sport]/route.ts | 27 +++ 12 files changed, 920 insertions(+), 1 deletion(-) create mode 100644 src/routes/futures.js create mode 100644 src/routes/news.js create mode 100644 src/services/futuresService.js create mode 100644 src/services/newsService.js create mode 100644 tests/unit/futuresService.test.js create mode 100644 tests/unit/newsService.test.js create mode 100644 tests/unit/oddsNormalizerOutrights.test.js create mode 100644 web/src/app/api/futures/[sport]/route.ts create mode 100644 web/src/app/api/news/[sport]/route.ts diff --git a/src/app.js b/src/app.js index b8e6f7f..774f012 100644 --- a/src/app.js +++ b/src/app.js @@ -184,6 +184,10 @@ const streaksRoutes = require('./routes/streaks'); app.use('/api/streaks', streaksRoutes); const hotListRoutes = require('./routes/hotlist'); 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. // All three are zero-credit: parlay math is pure, lines read a Redis // snapshot history, books read the cached odds props. diff --git a/src/routes/futures.js b/src/routes/futures.js new file mode 100644 index 0000000..c51269c --- /dev/null +++ b/src/routes/futures.js @@ -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; diff --git a/src/routes/news.js b/src/routes/news.js new file mode 100644 index 0000000..42b2e1a --- /dev/null +++ b/src/routes/news.js @@ -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; diff --git a/src/services/futuresService.js b/src/services/futuresService.js new file mode 100644 index 0000000..5e57cb6 --- /dev/null +++ b/src/services/futuresService.js @@ -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 }, +}; diff --git a/src/services/newsService.js b/src/services/newsService.js new file mode 100644 index 0000000..5f9e7c4 --- /dev/null +++ b/src/services/newsService.js @@ -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 } }; diff --git a/src/services/oddsService.js b/src/services/oddsService.js index f2587b8..877d795 100644 --- a/src/services/oddsService.js +++ b/src/services/oddsService.js @@ -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. diff --git a/src/utils/oddsNormalizer.js b/src/utils/oddsNormalizer.js index 86bc1ef..ae3d99f 100644 --- a/src/utils/oddsNormalizer.js +++ b/src/utils/oddsNormalizer.js @@ -157,6 +157,69 @@ function normalizeProps(eventsWithOdds) { 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) { const spreads = []; @@ -197,4 +260,4 @@ function extractSpreads(eventsWithOdds) { return spreads; } -module.exports = { normalizeProps, extractSpreads, MARKET_MAP, ALLOWED_BOOKS }; +module.exports = { normalizeProps, normalizeOutrights, americanToDecimal, extractSpreads, MARKET_MAP, ALLOWED_BOOKS }; diff --git a/tests/unit/futuresService.test.js b/tests/unit/futuresService.test.js new file mode 100644 index 0000000..2cd5829 --- /dev/null +++ b/tests/unit/futuresService.test.js @@ -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([]); + }); +}); diff --git a/tests/unit/newsService.test.js b/tests/unit/newsService.test.js new file mode 100644 index 0000000..7fe4617 --- /dev/null +++ b/tests/unit/newsService.test.js @@ -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: [] }); + }); +}); diff --git a/tests/unit/oddsNormalizerOutrights.test.js b/tests/unit/oddsNormalizerOutrights.test.js new file mode 100644 index 0000000..f6af5f6 --- /dev/null +++ b/tests/unit/oddsNormalizerOutrights.test.js @@ -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(); + }); +}); diff --git a/web/src/app/api/futures/[sport]/route.ts b/web/src/app/api/futures/[sport]/route.ts new file mode 100644 index 0000000..89876e5 --- /dev/null +++ b/web/src/app/api/futures/[sport]/route.ts @@ -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: [] }); + } +} diff --git a/web/src/app/api/news/[sport]/route.ts b/web/src/app/api/news/[sport]/route.ts new file mode 100644 index 0000000..fd2d050 --- /dev/null +++ b/web/src/app/api/news/[sport]/route.ts @@ -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: [] }); + } +}