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
+64 -1
View File
@@ -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 };