Session 28: Parlay builder, line movement tracker, book comparison — 3 features, zero credits (1623 tests)
This commit is contained in:
@@ -150,6 +150,15 @@ const streaksRoutes = require('./routes/streaks');
|
||||
app.use('/api/streaks', streaksRoutes);
|
||||
const hotListRoutes = require('./routes/hotlist');
|
||||
app.use('/api/hotlist', hotListRoutes);
|
||||
// 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.
|
||||
const parlayRoutes = require('./routes/parlay');
|
||||
app.use('/api/parlay', parlayRoutes);
|
||||
const lineMovementRoutes = require('./routes/lineMovement');
|
||||
app.use('/api/lines', lineMovementRoutes);
|
||||
const bookComparisonRoutes = require('./routes/bookComparison');
|
||||
app.use('/api/books', bookComparisonRoutes);
|
||||
// Session 18 — internal ops endpoints (admin dashboard triggers,
|
||||
// shared-key auth via `VYNDR_INTERNAL_KEY`). Never reachable from
|
||||
// the public surface; the Next.js admin route proxies through with
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* /api/books (Session 28)
|
||||
*
|
||||
* Book comparison views over the CACHED odds props — zero odds-api
|
||||
* credits (it never triggers a fetch; it reads what's already cached).
|
||||
*
|
||||
* GET /api/books/:sport → best lines tonight (sorted by savings)
|
||||
* GET /api/books/:sport/:player/:stat → book-by-book for one prop
|
||||
*
|
||||
* `?side=over|under` selects which side to optimize (default over).
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const bookComparison = require('../services/bookComparisonService');
|
||||
const { cacheGet } = require('../utils/redis');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const MISSION_HEADER = { 'X-VYNDR-Mission': 'Never leave money on the table' };
|
||||
|
||||
const SUPPORTED = new Set(['nba', 'wnba', 'mlb', 'soccer', 'nfl', 'nhl']);
|
||||
|
||||
// Read cached grouped props for a sport without triggering a fetch.
|
||||
// oddsService caches `odds:{sport}:{utcDate}` = { updated_at, props, spreads }.
|
||||
async function readCachedProps(sport) {
|
||||
const utcDate = new Date().toISOString().split('T')[0];
|
||||
const cache =
|
||||
(await cacheGet(`odds:${sport}:${utcDate}`)) ??
|
||||
(await cacheGet(`odds:${sport}`));
|
||||
if (!cache) return [];
|
||||
return Array.isArray(cache.props) ? cache.props : [];
|
||||
}
|
||||
|
||||
router.get('/:sport', async (req, res) => {
|
||||
const sport = String(req.params.sport || '').toLowerCase();
|
||||
if (!SUPPORTED.has(sport)) {
|
||||
return res.status(404).set(MISSION_HEADER).json({ error: `No book comparison for sport: ${sport}` });
|
||||
}
|
||||
const side = req.query.side === 'under' ? 'under' : 'over';
|
||||
const limit = req.query.limit ? Math.max(0, parseInt(req.query.limit, 10) || 0) : 20;
|
||||
try {
|
||||
const props = await readCachedProps(sport);
|
||||
const lines = bookComparison.bestLines(props, { side, limit });
|
||||
return res.set(MISSION_HEADER).json({ sport, side, bestLines: lines, source: 'odds-cache' });
|
||||
} catch (err) {
|
||||
console.error(`[books/${sport}]`, err.message);
|
||||
return res.set(MISSION_HEADER).json({ sport, side, bestLines: [], source: 'odds-cache' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:sport/:player/:stat', async (req, res) => {
|
||||
const sport = String(req.params.sport || '').toLowerCase();
|
||||
const player = decodeURIComponent(req.params.player);
|
||||
const stat = req.params.stat;
|
||||
const side = req.query.side === 'under' ? 'under' : 'over';
|
||||
try {
|
||||
const props = await readCachedProps(sport);
|
||||
const prop = props.find(
|
||||
(p) => (p.player || '').toLowerCase() === player.toLowerCase() &&
|
||||
(p.stat_type || p.stat || '').toLowerCase() === stat.toLowerCase(),
|
||||
);
|
||||
if (!prop) {
|
||||
return res.status(404).set(MISSION_HEADER).json({ error: 'Prop not found in current slate.' });
|
||||
}
|
||||
const comparison = bookComparison.compareProp(prop, side);
|
||||
if (!comparison) {
|
||||
return res.set(MISSION_HEADER).json({ sport, player, stat, side, books: [], bestBook: null });
|
||||
}
|
||||
return res.set(MISSION_HEADER).json({ sport, ...comparison });
|
||||
} catch (err) {
|
||||
console.error(`[books/${sport}/prop]`, err.message);
|
||||
return res.status(500).set(MISSION_HEADER).json({ error: 'Comparison failed' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,49 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* /api/lines (Session 28)
|
||||
*
|
||||
* Read-only views over the line-snapshot history (Redis). Zero credits.
|
||||
*
|
||||
* GET /api/lines/:sport/movers → biggest movers today
|
||||
* GET /api/lines/:sport/:gameId/:player/:stat → one prop's history + classification
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const lineSnapshots = require('../services/lineSnapshotService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const MISSION_HEADER = { 'X-VYNDR-Mission': 'The market confirms the grade' };
|
||||
|
||||
const SUPPORTED = new Set(['nba', 'wnba', 'mlb', 'soccer', 'nfl', 'nhl']);
|
||||
|
||||
router.get('/:sport/movers', async (req, res) => {
|
||||
const sport = String(req.params.sport || '').toLowerCase();
|
||||
if (!SUPPORTED.has(sport)) {
|
||||
return res.status(404).set(MISSION_HEADER).json({ error: `No line tracking for sport: ${sport}` });
|
||||
}
|
||||
const limit = req.query.limit ? Math.max(0, parseInt(req.query.limit, 10) || 0) : 20;
|
||||
try {
|
||||
const movers = await lineSnapshots.getBiggestMovers(sport, { limit });
|
||||
return res.set(MISSION_HEADER).json({ sport, movers, source: 'snapshots' });
|
||||
} catch (err) {
|
||||
console.error(`[lines/${sport}/movers]`, err.message);
|
||||
return res.set(MISSION_HEADER).json({ sport, movers: [], source: 'snapshots' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:sport/:gameId/:player/:stat', async (req, res) => {
|
||||
const sport = String(req.params.sport || '').toLowerCase();
|
||||
const { gameId, player, stat } = req.params;
|
||||
try {
|
||||
const history = await lineSnapshots.getLineHistory(sport, gameId, decodeURIComponent(player), stat);
|
||||
const classification = lineSnapshots.classifyMovement(history);
|
||||
return res.set(MISSION_HEADER).json({ sport, gameId, player, stat, ...classification });
|
||||
} catch (err) {
|
||||
console.error(`[lines/${sport}/prop]`, err.message);
|
||||
return res.set(MISSION_HEADER).json({ sport, gameId, player, stat, movement: 'stable', delta: 0, snapshots: [] });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,46 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* /api/parlay (Session 28)
|
||||
*
|
||||
* Builder-side parlay math — combined odds, grade, and correlation flags
|
||||
* for a set of user-selected legs. Distinct from the existing parlay
|
||||
* GRADING path (parlayGrader); this is the lightweight, zero-credit
|
||||
* combination used by the live parlay builder.
|
||||
*
|
||||
* POST /api/parlay/calculate { legs: [...] } → combined analysis
|
||||
* POST /api/parlay/suggestions { props, legs?, max? } → suggested combos
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const parlayService = require('../services/parlayService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const MISSION_HEADER = { 'X-VYNDR-Mission': 'Catch the legs that fight each other' };
|
||||
|
||||
router.post('/calculate', (req, res) => {
|
||||
try {
|
||||
const legs = req.body?.legs;
|
||||
const result = parlayService.calculateParlay(legs);
|
||||
return res.set(MISSION_HEADER).json(result);
|
||||
} catch (err) {
|
||||
const status = err.statusCode || 400;
|
||||
return res.status(status).set(MISSION_HEADER).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/suggestions', (req, res) => {
|
||||
try {
|
||||
const { props, legs, max } = req.body || {};
|
||||
const suggestions = parlayService.suggestParlays(props || [], {
|
||||
legs: Number(legs) || 3,
|
||||
max: Number(max) || 3,
|
||||
});
|
||||
return res.set(MISSION_HEADER).json({ suggestions });
|
||||
} catch (err) {
|
||||
return res.status(400).set(MISSION_HEADER).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,90 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Book comparison (Session 28).
|
||||
*
|
||||
* Surfaces the same prop across every available sportsbook with the best
|
||||
* line highlighted. Data source is the grouped odds-api prop shape, which
|
||||
* already carries book-by-book lines:
|
||||
* { player, stat_type, lines: [{ book, line, over_odds, under_odds }] }
|
||||
*
|
||||
* "Best line" = highest decimal payout for the selected side. Savings is
|
||||
* the per-$100 payout edge of the best book over the field average — the
|
||||
* concrete dollars a user leaves on the table by not line-shopping.
|
||||
*
|
||||
* Zero credits: it only reads odds already fetched/cached.
|
||||
*/
|
||||
|
||||
const { __internals } = require('./parlayService');
|
||||
const { americanToDecimal } = __internals;
|
||||
|
||||
function average(nums) {
|
||||
const valid = nums.filter((n) => Number.isFinite(n));
|
||||
if (valid.length === 0) return 0;
|
||||
return valid.reduce((a, b) => a + b, 0) / valid.length;
|
||||
}
|
||||
|
||||
function oddsForSide(line, side) {
|
||||
if (!line) return null;
|
||||
const raw = side === 'under' ? line.under_odds : line.over_odds;
|
||||
return raw == null ? null : Number(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare one grouped prop across its books for a given side.
|
||||
* Returns null when there are no usable book lines.
|
||||
*/
|
||||
function compareProp(prop, side = 'over') {
|
||||
const books = (prop?.lines || prop?.books || []).filter((b) => b && b.book);
|
||||
const priced = books.filter((b) => Number.isFinite(oddsForSide(b, side)));
|
||||
if (priced.length === 0) return null;
|
||||
|
||||
let best = priced[0];
|
||||
for (const b of priced) {
|
||||
if (americanToDecimal(oddsForSide(b, side)) > americanToDecimal(oddsForSide(best, side))) best = b;
|
||||
}
|
||||
|
||||
const bestDecimal = americanToDecimal(oddsForSide(best, side));
|
||||
const avgDecimal = average(priced.map((b) => americanToDecimal(oddsForSide(b, side))));
|
||||
const savings = Math.round((bestDecimal - avgDecimal) * 100 * 100) / 100; // per $100, 2dp
|
||||
|
||||
return {
|
||||
player: prop.player,
|
||||
stat: prop.stat_type || prop.stat,
|
||||
line: best.line ?? prop.line ?? null,
|
||||
side,
|
||||
books: priced.map((b) => ({
|
||||
book: b.book,
|
||||
line: b.line ?? null,
|
||||
over_odds: b.over_odds ?? null,
|
||||
under_odds: b.under_odds ?? null,
|
||||
isBest: b.book === best.book,
|
||||
})),
|
||||
bestBook: best.book,
|
||||
bestOdds: oddsForSide(best, side),
|
||||
bookCount: priced.length,
|
||||
savings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Best lines across a list of props, sorted by savings desc (where line-
|
||||
* shopping matters most). Drops props with a single book (nothing to
|
||||
* compare). `limit` caps the result.
|
||||
*/
|
||||
function bestLines(props, { side = 'over', limit = 20 } = {}) {
|
||||
if (!Array.isArray(props)) return [];
|
||||
const out = [];
|
||||
for (const prop of props) {
|
||||
const cmp = compareProp(prop, side);
|
||||
if (cmp && cmp.bookCount >= 2) out.push(cmp);
|
||||
}
|
||||
out.sort((a, b) => b.savings - a.savings);
|
||||
return limit > 0 ? out.slice(0, limit) : out;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
compareProp,
|
||||
bestLines,
|
||||
__internals: { average, oddsForSide },
|
||||
};
|
||||
@@ -0,0 +1,174 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Line-snapshot service (Session 28).
|
||||
*
|
||||
* Lightweight, Redis-only line-movement tracking that complements the
|
||||
* Supabase-backed `lineMovementService` (which captures opening baselines
|
||||
* + sharp indicators for grading). This layer records a rolling history
|
||||
* of a prop's line through the day so the UI can draw a sparkline and a
|
||||
* "biggest movers" board — with ZERO odds-api credits (it only stores
|
||||
* what an odds fetch already returned).
|
||||
*
|
||||
* Key shape:
|
||||
* linehistory:{sport}:{gameId}:{player}:{stat} → Redis list of
|
||||
* JSON { time, line, book } snapshots (cap 100, 48h TTL).
|
||||
*
|
||||
* Everything is defensive: Redis down → no-op / empty, never a throw.
|
||||
*/
|
||||
|
||||
const { getRedisClient, isDegraded } = require('../utils/redis');
|
||||
|
||||
const MAX_SNAPSHOTS = 100;
|
||||
const TTL_SECONDS = 48 * 3600;
|
||||
const SCAN_COUNT = 200;
|
||||
const MAX_KEYS = 1000;
|
||||
const SHARP_THRESHOLD = 1.5; // points of movement that flags sharp money
|
||||
const STABLE_THRESHOLD = 0.5; // < this = "stable"
|
||||
|
||||
function snapshotKey(sport, gameId, player, stat) {
|
||||
return `linehistory:${sport}:${gameId}:${player}:${stat}`;
|
||||
}
|
||||
|
||||
function safeNum(v) {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a line snapshot for each prop. `now` is injectable for tests.
|
||||
* Props need { gameId|game_id, player, stat|stat_type, line, book }.
|
||||
*/
|
||||
async function recordSnapshots(sport, props, now = Date.now()) {
|
||||
if (isDegraded && isDegraded()) return 0;
|
||||
if (!Array.isArray(props) || props.length === 0) return 0;
|
||||
const redis = getRedisClient();
|
||||
if (!redis || typeof redis.rpush !== 'function') return 0;
|
||||
|
||||
let written = 0;
|
||||
for (const p of props) {
|
||||
const gameId = p.gameId || p.game_id;
|
||||
const player = p.player;
|
||||
const stat = p.stat || p.stat_type;
|
||||
const line = safeNum(p.line);
|
||||
if (!gameId || !player || !stat || line === null) continue;
|
||||
const key = snapshotKey(sport, gameId, player, stat);
|
||||
const snap = JSON.stringify({ time: now, line, book: p.book || null });
|
||||
try {
|
||||
await redis.rpush(key, snap);
|
||||
await redis.ltrim(key, -MAX_SNAPSHOTS, -1);
|
||||
await redis.expire(key, TTL_SECONDS);
|
||||
written += 1;
|
||||
} catch {
|
||||
/* swallow — snapshot recording must never break an odds fetch */
|
||||
}
|
||||
}
|
||||
return written;
|
||||
}
|
||||
|
||||
async function getLineHistory(sport, gameId, player, stat) {
|
||||
if (isDegraded && isDegraded()) return [];
|
||||
const redis = getRedisClient();
|
||||
if (!redis || typeof redis.lrange !== 'function') return [];
|
||||
try {
|
||||
const raw = await redis.lrange(snapshotKey(sport, gameId, player, stat), 0, -1);
|
||||
return (raw || [])
|
||||
.map((s) => { try { return JSON.parse(s); } catch { return null; } })
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a list of snapshots (oldest → newest) into a movement summary.
|
||||
* Empty / single-snapshot → stable, never an error.
|
||||
*/
|
||||
function classifyMovement(snapshots) {
|
||||
if (!Array.isArray(snapshots) || snapshots.length < 2) {
|
||||
const only = snapshots && snapshots[0] ? safeNum(snapshots[0].line) : null;
|
||||
return { opening: only, current: only, delta: 0, movement: 'stable', sharpSignal: false, snapshots: snapshots || [] };
|
||||
}
|
||||
const opening = safeNum(snapshots[0].line) ?? 0;
|
||||
const current = safeNum(snapshots[snapshots.length - 1].line) ?? 0;
|
||||
const delta = Math.round((current - opening) * 100) / 100;
|
||||
const abs = Math.abs(delta);
|
||||
return {
|
||||
opening,
|
||||
current,
|
||||
delta,
|
||||
movement: abs < STABLE_THRESHOLD ? 'stable' : delta > 0 ? 'rising' : 'dropping',
|
||||
sharpSignal: abs >= SHARP_THRESHOLD,
|
||||
snapshots,
|
||||
};
|
||||
}
|
||||
|
||||
function parseKey(key) {
|
||||
// linehistory:{sport}:{gameId}:{player}:{stat}
|
||||
const parts = String(key).split(':');
|
||||
if (parts.length < 5 || parts[0] !== 'linehistory') return null;
|
||||
// player names may contain no colons in practice; stat is the last part.
|
||||
const [, sport, gameId] = parts;
|
||||
const stat = parts[parts.length - 1];
|
||||
const player = parts.slice(3, parts.length - 1).join(':');
|
||||
return { sport, gameId, player, stat };
|
||||
}
|
||||
|
||||
async function scanKeys(match) {
|
||||
if (isDegraded && isDegraded()) return [];
|
||||
const redis = getRedisClient();
|
||||
if (!redis || typeof redis.scan !== 'function') return [];
|
||||
const keys = [];
|
||||
let cursor = '0';
|
||||
try {
|
||||
do {
|
||||
const [next, batch] = await redis.scan(cursor, 'MATCH', match, 'COUNT', SCAN_COUNT);
|
||||
cursor = next;
|
||||
for (const k of batch) {
|
||||
if (!keys.includes(k)) keys.push(k);
|
||||
if (keys.length >= MAX_KEYS) return keys;
|
||||
}
|
||||
} while (cursor !== '0');
|
||||
} catch {
|
||||
return keys;
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Biggest movers for a sport — every tracked prop classified, filtered to
|
||||
* meaningful moves, sorted by absolute delta desc. `limit` caps the list.
|
||||
*/
|
||||
async function getBiggestMovers(sport, { limit = 20, minDelta = STABLE_THRESHOLD } = {}) {
|
||||
const keys = await scanKeys(`linehistory:${sport}:*`);
|
||||
const movers = [];
|
||||
for (const key of keys) {
|
||||
const meta = parseKey(key);
|
||||
if (!meta) continue;
|
||||
const history = await getLineHistory(sport, meta.gameId, meta.player, meta.stat);
|
||||
const cls = classifyMovement(history);
|
||||
if (Math.abs(cls.delta) < minDelta) continue;
|
||||
movers.push({
|
||||
sport,
|
||||
gameId: meta.gameId,
|
||||
player: meta.player,
|
||||
stat: meta.stat,
|
||||
opening: cls.opening,
|
||||
current: cls.current,
|
||||
delta: cls.delta,
|
||||
movement: cls.movement,
|
||||
sharpSignal: cls.sharpSignal,
|
||||
snapshots: cls.snapshots,
|
||||
});
|
||||
}
|
||||
movers.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta));
|
||||
return limit > 0 ? movers.slice(0, limit) : movers;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
recordSnapshots,
|
||||
getLineHistory,
|
||||
classifyMovement,
|
||||
getBiggestMovers,
|
||||
__internals: { snapshotKey, parseKey, scanKeys, SHARP_THRESHOLD, STABLE_THRESHOLD },
|
||||
};
|
||||
@@ -346,6 +346,10 @@ async function getOdds(sport) {
|
||||
movements = moveResult.movements || [];
|
||||
const cascadeResult = await cascade.detectScratches(sport, props);
|
||||
scratchedPlayers = cascadeResult.scratchedPlayers || [];
|
||||
// Session 28 — append a rolling line-history snapshot per prop so the
|
||||
// sparkline / biggest-movers views have data. Redis-only, free.
|
||||
const lineSnapshots = require('./lineSnapshotService');
|
||||
await lineSnapshots.recordSnapshots(sport, props);
|
||||
} catch (e) {
|
||||
// Non-fatal — log and continue
|
||||
console.warn('[VYNDR] Movement/cascade detection error:', e.message);
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Parlay service (Session 28).
|
||||
*
|
||||
* Combines user-selected props (from the parlay builder) into a parlay:
|
||||
* - combined American/decimal odds (multiply decimal odds)
|
||||
* - combined grade (confidence-weighted average of leg grades)
|
||||
* - correlation detection (interaction matrix for same-game legs)
|
||||
* - kill-condition aggregation (any leg flagged → surface it)
|
||||
*
|
||||
* This is the lightweight BUILDER path — it operates on the simple leg
|
||||
* shape the frontend sends ({ player, team, gameId, stat, side, line,
|
||||
* odds, grade, confidence, killConditions }). It is distinct from the
|
||||
* full grading pipeline (`parlayGrader` / `correlationEngine`), which
|
||||
* consumes already-analyzed legs with full reasoning trees.
|
||||
*
|
||||
* Odds math reuses `payoutCalculator` where it can; the decimal/American
|
||||
* conversions live here because the builder needs both directions.
|
||||
*/
|
||||
|
||||
const { calculateParlayPayout } = require('./payoutCalculator');
|
||||
|
||||
// ---- odds conversions -------------------------------------------------
|
||||
function americanToDecimal(odds) {
|
||||
const n = Number(odds);
|
||||
if (!Number.isFinite(n) || n === 0) return 1;
|
||||
return n > 0 ? 1 + n / 100 : 1 + 100 / Math.abs(n);
|
||||
}
|
||||
|
||||
function decimalToAmerican(decimal) {
|
||||
const d = Number(decimal);
|
||||
if (!Number.isFinite(d) || d <= 1) return 0;
|
||||
return d >= 2
|
||||
? Math.round((d - 1) * 100)
|
||||
: Math.round(-100 / (d - 1));
|
||||
}
|
||||
|
||||
// ---- grade <-> numeric (A+ = 12 … F = 0) ------------------------------
|
||||
const GRADE_ORDER = ['F', 'D-', 'D', 'D+', 'C-', 'C', 'C+', 'B-', 'B', 'B+', 'A-', 'A', 'A+'];
|
||||
|
||||
function gradeToNumeric(grade) {
|
||||
const idx = GRADE_ORDER.indexOf(String(grade || 'C').toUpperCase());
|
||||
return idx === -1 ? GRADE_ORDER.indexOf('C') : idx;
|
||||
}
|
||||
|
||||
function numericToGrade(value) {
|
||||
const idx = Math.max(0, Math.min(GRADE_ORDER.length - 1, Math.round(value)));
|
||||
return GRADE_ORDER[idx];
|
||||
}
|
||||
|
||||
// ---- correlation interaction matrix -----------------------------------
|
||||
// Keyed by the two stat types sorted + joined. `sameTeam` / `crossTeam`
|
||||
// give the correlation sign. Stat names are normalized to lowercase.
|
||||
const INTERACTIONS = {
|
||||
'assists_points': { sameTeam: 'positive', crossTeam: 'neutral' },
|
||||
'assists_rebounds': { sameTeam: 'positive', crossTeam: 'neutral' },
|
||||
'points_points': { sameTeam: 'neutral', crossTeam: 'negative' },
|
||||
'rebounds_rebounds': { sameTeam: 'negative', crossTeam: 'negative' },
|
||||
'points_rebounds': { sameTeam: 'positive', crossTeam: 'neutral' },
|
||||
'assists_assists': { sameTeam: 'negative', crossTeam: 'neutral' },
|
||||
'pra_points': { sameTeam: 'positive', crossTeam: 'neutral' },
|
||||
// MLB
|
||||
'hits_strikeouts': { sameTeam: 'neutral', crossTeam: 'negative' },
|
||||
'hits_hits': { sameTeam: 'positive', crossTeam: 'neutral' },
|
||||
'strikeouts_strikeouts':{ sameTeam: 'neutral', crossTeam: 'positive' },
|
||||
'home_runs_strikeouts': { sameTeam: 'neutral', crossTeam: 'negative' },
|
||||
};
|
||||
|
||||
function normStat(s) {
|
||||
return String(s || '').toLowerCase().replace(/\s+/g, '_');
|
||||
}
|
||||
|
||||
function describeCorrelation(legA, legB, sign) {
|
||||
const a = `${legA.player} ${normStat(legA.stat).replace(/_/g, ' ')}`;
|
||||
const b = `${legB.player} ${normStat(legB.stat).replace(/_/g, ' ')}`;
|
||||
if (sign === 'negative') return `${a} and ${b} compete — these legs fight each other`;
|
||||
if (sign === 'positive') return `${a} and ${b} feed each other — correlated outcome`;
|
||||
return `${a} and ${b} are weakly related`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correlation between two builder legs. Independent across different
|
||||
* games; otherwise looked up in the interaction matrix.
|
||||
*/
|
||||
function detectCorrelation(legA, legB) {
|
||||
if (!legA || !legB) return { correlated: false, type: 'independent' };
|
||||
|
||||
// Same player, opposite directions on the same stat → direct conflict.
|
||||
if (legA.player && legB.player && legA.player.toLowerCase() === legB.player.toLowerCase()) {
|
||||
if (normStat(legA.stat) === normStat(legB.stat) && legA.side && legB.side && legA.side !== legB.side) {
|
||||
return { correlated: true, type: 'negative', description: `${legA.player}: ${legA.side} vs ${legB.side} on the same prop — direct conflict` };
|
||||
}
|
||||
}
|
||||
|
||||
if (!legA.gameId || !legB.gameId || legA.gameId !== legB.gameId) {
|
||||
return { correlated: false, type: 'independent' };
|
||||
}
|
||||
|
||||
const key = [normStat(legA.stat), normStat(legB.stat)].sort().join('_');
|
||||
const interaction = INTERACTIONS[key];
|
||||
if (!interaction) return { correlated: false, type: 'unknown' };
|
||||
|
||||
const sameTeam = legA.team != null && legA.team === legB.team;
|
||||
const sign = sameTeam ? interaction.sameTeam : interaction.crossTeam;
|
||||
if (sign === 'neutral') return { correlated: false, type: 'neutral' };
|
||||
|
||||
return { correlated: true, type: sign, description: describeCorrelation(legA, legB, sign) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine legs into a parlay analysis. Throws on empty/invalid input so
|
||||
* the route can 400 cleanly.
|
||||
*/
|
||||
function calculateParlay(legs) {
|
||||
if (!Array.isArray(legs) || legs.length === 0) {
|
||||
const err = new Error('A parlay needs at least one leg.');
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const decimalOdds = legs.map((l) => americanToDecimal(l.odds));
|
||||
const combinedDecimal = decimalOdds.reduce((a, b) => a * b, 1);
|
||||
const combinedAmerican = decimalToAmerican(combinedDecimal);
|
||||
|
||||
// Confidence-weighted grade.
|
||||
const totalConfidence = legs.reduce((sum, l) => sum + (Number(l.confidence) || 50), 0);
|
||||
const weightedGrade = legs.reduce((sum, l) => {
|
||||
const weight = (Number(l.confidence) || 50) / totalConfidence;
|
||||
return sum + gradeToNumeric(l.grade) * weight;
|
||||
}, 0);
|
||||
|
||||
// All pairwise correlations.
|
||||
const correlations = [];
|
||||
for (let i = 0; i < legs.length; i += 1) {
|
||||
for (let j = i + 1; j < legs.length; j += 1) {
|
||||
const c = detectCorrelation(legs[i], legs[j]);
|
||||
if (c.correlated) correlations.push({ legA: i, legB: j, type: c.type, description: c.description });
|
||||
}
|
||||
}
|
||||
|
||||
const killConditions = legs
|
||||
.map((l, i) => ({ leg: i, player: l.player, conditions: l.killConditions || [] }))
|
||||
.filter((k) => Array.isArray(k.conditions) && k.conditions.length > 0);
|
||||
|
||||
return {
|
||||
legCount: legs.length,
|
||||
combinedOdds: combinedAmerican,
|
||||
combinedDecimal: Math.round(combinedDecimal * 10000) / 10000,
|
||||
combinedGrade: numericToGrade(weightedGrade),
|
||||
payoutPer10: Math.round(calculateParlayPayout(10, legs.map((l) => Number(l.odds) || 0)) * 100) / 100,
|
||||
correlations,
|
||||
hasNegativeCorrelation: correlations.some((c) => c.type === 'negative'),
|
||||
hasPositiveCorrelation: correlations.some((c) => c.type === 'positive'),
|
||||
killConditions,
|
||||
hasKillCondition: killConditions.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggest up to `max` parlays from a pool of graded props. Greedy: take
|
||||
* the best-graded props, avoid negative correlations within a suggestion.
|
||||
* Pure — the route supplies the prop pool (no API calls here).
|
||||
*/
|
||||
function suggestParlays(props, { legs = 3, max = 3 } = {}) {
|
||||
if (!Array.isArray(props) || props.length < legs) return [];
|
||||
const sorted = [...props].sort((a, b) => gradeToNumeric(b.grade) - gradeToNumeric(a.grade));
|
||||
|
||||
const suggestions = [];
|
||||
const used = new Set();
|
||||
for (let start = 0; start < sorted.length && suggestions.length < max; start += 1) {
|
||||
if (used.has(start)) continue;
|
||||
const combo = [start];
|
||||
for (let k = 0; k < sorted.length && combo.length < legs; k += 1) {
|
||||
if (k === start || used.has(k) || combo.includes(k)) continue;
|
||||
const conflicts = combo.some((idx) => detectCorrelation(sorted[idx], sorted[k]).type === 'negative');
|
||||
if (!conflicts) combo.push(k);
|
||||
}
|
||||
if (combo.length === legs) {
|
||||
combo.forEach((idx) => used.add(idx));
|
||||
const legObjs = combo.map((idx) => sorted[idx]);
|
||||
suggestions.push({ legs: legObjs, ...calculateParlay(legObjs) });
|
||||
}
|
||||
}
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
calculateParlay,
|
||||
detectCorrelation,
|
||||
suggestParlays,
|
||||
__internals: { americanToDecimal, decimalToAmerican, gradeToNumeric, numericToGrade, INTERACTIONS },
|
||||
};
|
||||
Reference in New Issue
Block a user