Session 28: Parlay builder, line movement tracker, book comparison — 3 features, zero credits (1623 tests)

This commit is contained in:
Kev
2026-06-13 12:37:08 -04:00
parent 66fafd8429
commit c48aecd510
23 changed files with 1567 additions and 1 deletions
+65
View File
@@ -4,6 +4,71 @@
2026-06-12 2026-06-12
## Current Phase ## Current Phase
SHIP BUILD v28.0 — Parlay builder, line-movement tracking, book comparison (Session 28)
## Session 28 (2026-06-13) — SHIPPED
The three features every competitor has: parlay building, line movement,
book comparison. All zero-credit (pure math / Redis snapshots / cached
odds). Reused existing primitives heavily (payoutCalculator, the existing
ParlayTray/ParlayContext frontend).
Backend 1584 → **1623 tests** (+39), 130 suites, zero regressions. Web
build clean.
### PHASE 1-2 — Parlay builder
- `parlayService.js` — combined American/decimal odds (reuses
payoutCalculator), confidence-weighted combined grade, correlation
detection via an interaction matrix (same-game teammates = positive,
opposing rebounds = negative, cross-game = independent), kill-condition
aggregation, and `suggestParlays` (greedy, conflict-avoiding).
- `POST /api/parlay/calculate` + `/suggestions`. Frontend parlay builder
already existed (ParlayTray + ParlayContext → /api/scan/parlay grading);
left intact. Added the calculate proxy for the lightweight path.
- 15 unit + 3 route tests.
### PHASE 3-4 — Line movement
- `lineSnapshotService.js` — Redis-only rolling history
(`linehistory:{sport}:{gameId}:{player}:{stat}`, cap 100, 48h TTL),
`classifyMovement` (stable/rising/dropping + sharp signal ≥1.5 pts),
`getBiggestMovers` (scan + classify + sort by |delta|). Complements the
existing Supabase-backed lineMovementService rather than replacing it.
- Wired `recordSnapshots` into oddsService's existing best-effort block.
- `GET /api/lines/:sport/movers` + per-prop history.
- Frontend: `LineMovementChart` (dependency-free SVG sparkline) +
`MoversPanel` (mounted in the Slate, self-hiding, tier-gated).
- 9 unit + 2 route tests.
### PHASE 5-6 — Book comparison
- `bookComparisonService.js` — best line per side (highest decimal
payout), savings vs field average per $100, over the grouped odds
`lines[]`. `GET /api/books/:sport` (best lines) + per-prop grid, reading
CACHED odds props (zero credits).
- Frontend: `BookComparison` (book grid, BEST badge) + `BestLinesPanel`
(mounted in the Slate, self-hiding, tier-gated).
- 7 unit + 3 route tests.
### PHASE 7 — Wiring
- Mounted /api/parlay, /api/lines, /api/books in app.js.
- Next proxies: `parlay/calculate/route.ts` (explicit, avoids catch-all
conflict with existing grade/add-leg), `lines/[...path]`, `books/[...path]`.
- MoversPanel + BestLinesPanel added to the Slate below streaks/hot lists.
### Files created
- `src/services/parlayService.js`, `src/routes/parlay.js`
- `src/services/lineSnapshotService.js`, `src/routes/lineMovement.js`
- `src/services/bookComparisonService.js`, `src/routes/bookComparison.js`
- `web/src/components/{LineMovementChart,MoversPanel,BestLinesPanel,BookComparison}.tsx`
- `web/src/app/api/parlay/calculate/route.ts`, `api/lines/[...path]/route.ts`, `api/books/[...path]/route.ts`
- 4 new test files (parlayService, lineSnapshotService, bookComparisonService, session28Routes)
### Files modified
- `src/app.js` (3 mounts), `src/services/oddsService.js` (snapshot recording)
- `web/src/components/Slate.tsx` (2 panels)
---
## Previous Phase
SHIP BUILD v27.0 — PWA autopilot: deployment-aware service worker, push foundation, offline fallback, install + cookie + tier polish (Session 27) SHIP BUILD v27.0 — PWA autopilot: deployment-aware service worker, push foundation, offline fallback, install + cookie + tier polish (Session 27)
## Session 27 (2026-06-13) — SHIPPED ## Session 27 (2026-06-13) — SHIPPED
+21
View File
@@ -759,3 +759,24 @@
{"ts":"2026-06-13T14:45:08.483Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"} {"ts":"2026-06-13T14:45:08.483Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-13T14:45:08.484Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"} {"ts":"2026-06-13T14:45:08.484Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-13T14:45:08.569Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"} {"ts":"2026-06-13T14:45:08.569Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-13T15:25:31.069Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-13T15:25:31.143Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-13T15:25:31.238Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-13T15:25:31.651Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-13T15:25:31.651Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-13T15:25:31.651Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-13T15:25:31.899Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-13T15:53:25.341Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-13T15:53:25.343Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-13T15:53:25.343Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-13T15:53:25.423Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-13T15:53:26.214Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-13T15:53:26.372Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-13T15:53:26.404Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-13T15:56:53.174Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-13T15:56:53.299Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-13T15:56:53.752Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-13T15:56:53.880Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-13T15:56:53.881Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-13T15:56:53.881Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-13T15:56:53.940Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
+9
View File
@@ -150,6 +150,15 @@ 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);
// 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, // Session 18 — internal ops endpoints (admin dashboard triggers,
// shared-key auth via `VYNDR_INTERNAL_KEY`). Never reachable from // shared-key auth via `VYNDR_INTERNAL_KEY`). Never reachable from
// the public surface; the Next.js admin route proxies through with // the public surface; the Next.js admin route proxies through with
+78
View File
@@ -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;
+49
View File
@@ -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;
+46
View File
@@ -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;
+90
View File
@@ -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 },
};
+174
View File
@@ -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 },
};
+4
View File
@@ -346,6 +346,10 @@ async function getOdds(sport) {
movements = moveResult.movements || []; movements = moveResult.movements || [];
const cascadeResult = await cascade.detectScratches(sport, props); const cascadeResult = await cascade.detectScratches(sport, props);
scratchedPlayers = cascadeResult.scratchedPlayers || []; 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) { } catch (e) {
// Non-fatal — log and continue // Non-fatal — log and continue
console.warn('[VYNDR] Movement/cascade detection error:', e.message); console.warn('[VYNDR] Movement/cascade detection error:', e.message);
+193
View File
@@ -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 },
};
+106
View File
@@ -0,0 +1,106 @@
// Integration: parlay / lines / books routes (Session 28).
const express = require('express');
const request = require('supertest');
// Redis-backed services are mocked at the redis layer.
const mockStore = {};
const mockScan = jest.fn(async () => ['0', []]);
jest.mock('../../src/utils/redis', () => ({
cacheGet: jest.fn(async (k) => (k in mockStore ? mockStore[k] : null)),
getRedisClient: () => ({ scan: mockScan, lrange: async () => [], rpush: async () => 1, ltrim: async () => 'OK', expire: async () => 1 }),
isDegraded: () => false,
}));
function mount(routePath, file) {
delete require.cache[require.resolve(file)];
const app = express();
app.use(express.json());
app.use(routePath, require(file));
return app;
}
beforeEach(() => {
for (const k of Object.keys(mockStore)) delete mockStore[k];
jest.clearAllMocks();
});
describe('POST /api/parlay/calculate', () => {
const app = () => mount('/api/parlay', '../../src/routes/parlay');
test('returns combined odds + grade for valid legs', async () => {
const res = await request(app()).post('/api/parlay/calculate').send({
legs: [
{ player: 'A', stat: 'points', odds: 100, grade: 'A', gameId: 'g1' },
{ player: 'B', stat: 'hits', odds: 100, grade: 'A', gameId: 'g2' },
],
});
expect(res.status).toBe(200);
expect(res.body.combinedOdds).toBe(300); // 2×2 = 4.0 → +300
expect(res.body.combinedGrade).toBeDefined();
});
test('empty legs → 400', async () => {
const res = await request(app()).post('/api/parlay/calculate').send({ legs: [] });
expect(res.status).toBe(400);
});
test('suggestions endpoint returns combos', async () => {
const props = [
{ player: 'A', stat: 'points', odds: -110, grade: 'A', gameId: 'g1' },
{ player: 'B', stat: 'hits', odds: -110, grade: 'A', gameId: 'g2' },
{ player: 'C', stat: 'goals', odds: -110, grade: 'B', gameId: 'g3' },
];
const res = await request(app()).post('/api/parlay/suggestions').send({ props, legs: 3, max: 1 });
expect(res.status).toBe(200);
expect(res.body.suggestions).toHaveLength(1);
});
});
describe('GET /api/lines/:sport/movers', () => {
const app = () => mount('/api/lines', '../../src/routes/lineMovement');
test('empty when no snapshots cached', async () => {
const res = await request(app()).get('/api/lines/mlb/movers');
expect(res.status).toBe(200);
expect(res.body.movers).toEqual([]);
});
test('unsupported sport → 404', async () => {
const res = await request(app()).get('/api/lines/cricket/movers');
expect(res.status).toBe(404);
});
});
describe('GET /api/books/:sport', () => {
const app = () => mount('/api/books', '../../src/routes/bookComparison');
test('returns best lines from cached props', async () => {
mockStore[`odds:nba:${new Date().toISOString().split('T')[0]}`] = {
props: [
{
player: 'Wemby', stat_type: 'points',
lines: [
{ book: 'dk', over_odds: -110 },
{ book: 'fd', over_odds: -105 },
],
},
],
};
const res = await request(app()).get('/api/books/nba');
expect(res.status).toBe(200);
expect(res.body.bestLines).toHaveLength(1);
expect(res.body.bestLines[0].bestBook).toBe('fd');
});
test('empty when no cached props', async () => {
const res = await request(app()).get('/api/books/nba');
expect(res.status).toBe(200);
expect(res.body.bestLines).toEqual([]);
});
test('unsupported sport → 404', async () => {
const res = await request(app()).get('/api/books/cricket');
expect(res.status).toBe(404);
});
});
+68
View File
@@ -0,0 +1,68 @@
// Unit: book comparison service (Session 28). Pure functions.
const { compareProp, bestLines } = require('../../src/services/bookComparisonService');
const prop = {
player: 'Wembanyama',
stat_type: 'points',
lines: [
{ book: 'draftkings', line: 28.5, over_odds: -110, under_odds: -110 },
{ book: 'fanduel', line: 28.5, over_odds: -105, under_odds: -115 }, // best over
{ book: 'betmgm', line: 28.5, over_odds: -120, under_odds: -102 }, // best under
],
};
describe('bookComparisonService — compareProp', () => {
test('identifies the best OVER line (highest payout)', () => {
const c = compareProp(prop, 'over');
expect(c.bestBook).toBe('fanduel'); // -105 pays more than -110/-120
expect(c.bestOdds).toBe(-105);
expect(c.books.find((b) => b.book === 'fanduel').isBest).toBe(true);
expect(c.bookCount).toBe(3);
});
test('identifies the best UNDER line', () => {
const c = compareProp(prop, 'under');
expect(c.bestBook).toBe('betmgm'); // -102 pays more than -110/-115
expect(c.bestOdds).toBe(-102);
});
test('savings is positive (best beats the field average)', () => {
const c = compareProp(prop, 'over');
expect(c.savings).toBeGreaterThan(0);
});
test('single-book prop still compares (bookCount 1)', () => {
const c = compareProp({ player: 'X', stat_type: 'hits', lines: [{ book: 'dk', over_odds: -110 }] }, 'over');
expect(c.bookCount).toBe(1);
expect(c.bestBook).toBe('dk');
});
test('no usable lines → null, not crash', () => {
expect(compareProp({ player: 'X', stat_type: 'hits', lines: [] }, 'over')).toBeNull();
expect(compareProp({ player: 'X', stat_type: 'hits' }, 'over')).toBeNull();
});
});
describe('bookComparisonService — bestLines', () => {
test('drops single-book props and sorts by savings desc', () => {
const props = [
prop,
{ player: 'Solo', stat_type: 'reb', lines: [{ book: 'dk', over_odds: -110 }] }, // 1 book → dropped
{
player: 'BigEdge', stat_type: 'ast',
lines: [
{ book: 'dk', over_odds: -200 },
{ book: 'fd', over_odds: +120 }, // huge spread → big savings
],
},
];
const lines = bestLines(props, { side: 'over' });
expect(lines.map((l) => l.player)).not.toContain('Solo');
expect(lines[0].player).toBe('BigEdge'); // biggest savings first
});
test('non-array input → empty', () => {
expect(bestLines(null)).toEqual([]);
});
});
+104
View File
@@ -0,0 +1,104 @@
// Unit: line-snapshot service (Session 28). Redis-only; mocked.
const mockStore = {}; // key -> array of JSON strings (list)
const mockScan = jest.fn();
const mockRedis = {
rpush: jest.fn(async (k, v) => { (mockStore[k] = mockStore[k] || []).push(v); return mockStore[k].length; }),
ltrim: jest.fn(async (k, start, end) => {
if (mockStore[k]) mockStore[k] = mockStore[k].slice(start, end === -1 ? undefined : end + 1);
return 'OK';
}),
expire: jest.fn(async () => 1),
lrange: jest.fn(async (k) => mockStore[k] || []),
scan: mockScan,
};
jest.mock('../../src/utils/redis', () => ({
getRedisClient: () => mockRedis,
isDegraded: () => false,
}));
const svc = require('../../src/services/lineSnapshotService');
beforeEach(() => {
for (const k of Object.keys(mockStore)) delete mockStore[k];
jest.clearAllMocks();
});
describe('lineSnapshotService — recording', () => {
test('records a snapshot per prop into the right key', async () => {
const n = await svc.recordSnapshots('nba', [
{ gameId: 'g1', player: 'Wemby', stat: 'points', line: 28.5, book: 'dk' },
], 1000);
expect(n).toBe(1);
const hist = await svc.getLineHistory('nba', 'g1', 'Wemby', 'points');
expect(hist).toEqual([{ time: 1000, line: 28.5, book: 'dk' }]);
});
test('skips props missing required fields', async () => {
const n = await svc.recordSnapshots('nba', [{ player: 'X' }, { gameId: 'g', player: 'Y', stat: 's', line: 'NaN' }], 1);
expect(n).toBe(0);
});
});
describe('lineSnapshotService — classifyMovement', () => {
test('empty / single → stable, no error', () => {
expect(svc.classifyMovement([]).movement).toBe('stable');
expect(svc.classifyMovement([{ line: 5 }]).movement).toBe('stable');
expect(svc.classifyMovement([{ line: 5 }]).delta).toBe(0);
});
test('rising vs dropping', () => {
expect(svc.classifyMovement([{ line: 26.5 }, { line: 28.5 }]).movement).toBe('rising');
expect(svc.classifyMovement([{ line: 28.5 }, { line: 26.5 }]).movement).toBe('dropping');
});
test('sharp signal at >= 1.5 point move', () => {
expect(svc.classifyMovement([{ line: 28.5 }, { line: 26.5 }]).sharpSignal).toBe(true); // 2.0
expect(svc.classifyMovement([{ line: 28.5 }, { line: 27.5 }]).sharpSignal).toBe(false); // 1.0
});
test('< 0.5 move is stable', () => {
expect(svc.classifyMovement([{ line: 28.5 }, { line: 28.5 }]).movement).toBe('stable');
expect(svc.classifyMovement([{ line: 28.5 }, { line: 28.2 }]).movement).toBe('stable');
});
});
describe('lineSnapshotService — biggest movers', () => {
test('classifies + sorts by absolute delta desc', async () => {
mockScan.mockResolvedValueOnce(['0', [
'linehistory:mlb:g1:Acuna:hits',
'linehistory:mlb:g2:Soto:total_bases',
]]);
mockStore['linehistory:mlb:g1:Acuna:hits'] = [
JSON.stringify({ time: 1, line: 1.5 }),
JSON.stringify({ time: 2, line: 2.5 }), // +1.0
];
mockStore['linehistory:mlb:g2:Soto:total_bases'] = [
JSON.stringify({ time: 1, line: 3.5 }),
JSON.stringify({ time: 2, line: 1.0 }), // -2.5 (bigger, sharp)
];
const movers = await svc.getBiggestMovers('mlb');
expect(movers).toHaveLength(2);
expect(movers[0].player).toBe('Soto'); // bigger |delta| first
expect(movers[0].delta).toBe(-2.5);
expect(movers[0].sharpSignal).toBe(true);
expect(movers[1].player).toBe('Acuna');
});
test('filters out sub-threshold (stable) props', async () => {
mockScan.mockResolvedValueOnce(['0', ['linehistory:mlb:g1:Flat:hits']]);
mockStore['linehistory:mlb:g1:Flat:hits'] = [
JSON.stringify({ time: 1, line: 1.5 }),
JSON.stringify({ time: 2, line: 1.5 }),
];
const movers = await svc.getBiggestMovers('mlb');
expect(movers).toEqual([]);
});
test('parseKey extracts sport/game/player/stat', () => {
expect(svc.__internals.parseKey('linehistory:nba:g1:LeBron James:points')).toEqual({
sport: 'nba', gameId: 'g1', player: 'LeBron James', stat: 'points',
});
});
});
+132
View File
@@ -0,0 +1,132 @@
// Unit: parlay builder service (Session 28). Pure functions.
const { calculateParlay, detectCorrelation, suggestParlays, __internals } = require('../../src/services/parlayService');
describe('parlayService — odds conversions', () => {
const { americanToDecimal, decimalToAmerican } = __internals;
test('americanToDecimal: +100 → 2.0, -110 → ~1.909', () => {
expect(americanToDecimal(100)).toBeCloseTo(2.0, 5);
expect(americanToDecimal(-110)).toBeCloseTo(1.9091, 3);
});
test('decimalToAmerican round-trips', () => {
expect(decimalToAmerican(2.0)).toBe(100);
expect(decimalToAmerican(1.5)).toBe(-200);
});
});
describe('parlayService — calculateParlay', () => {
test('2-leg combined odds multiply correctly', () => {
// -110 (1.909) × -110 (1.909) = 3.645 → +264 American
const r = calculateParlay([
{ player: 'A', stat: 'points', side: 'over', line: 20, odds: -110, grade: 'B', confidence: 60 },
{ player: 'B', stat: 'hits', side: 'over', line: 1, odds: -110, grade: 'B', confidence: 60, gameId: 'g2' },
]);
expect(r.combinedDecimal).toBeCloseTo(3.645, 2);
expect(r.combinedOdds).toBe(264);
expect(r.legCount).toBe(2);
});
test('3-leg combined odds', () => {
const r = calculateParlay([
{ player: 'A', stat: 'points', odds: 100, grade: 'A', confidence: 70 },
{ player: 'B', stat: 'hits', odds: 100, grade: 'A', confidence: 70, gameId: 'g2' },
{ player: 'C', stat: 'goals', odds: 100, grade: 'A', confidence: 70, gameId: 'g3' },
]);
expect(r.combinedDecimal).toBeCloseTo(8.0, 5); // 2×2×2
expect(r.combinedOdds).toBe(700);
});
test('combined grade is confidence-weighted', () => {
const r = calculateParlay([
{ player: 'A', stat: 'points', odds: -110, grade: 'A', confidence: 90 },
{ player: 'B', stat: 'hits', odds: -110, grade: 'C', confidence: 10, gameId: 'g2' },
]);
// Heavily weighted toward the A leg.
expect(['A-', 'A', 'B+']).toContain(r.combinedGrade);
});
test('payoutPer10 computed', () => {
const r = calculateParlay([{ player: 'A', stat: 'points', odds: 100, grade: 'B' }]);
expect(r.payoutPer10).toBe(20); // $10 at +100 → $20 total
});
test('empty legs → 400 error, not crash', () => {
expect(() => calculateParlay([])).toThrow();
try { calculateParlay([]); } catch (e) { expect(e.statusCode).toBe(400); }
});
test('kill conditions aggregated', () => {
const r = calculateParlay([
{ player: 'A', stat: 'points', odds: -110, grade: 'B', killConditions: ['blowout risk'] },
{ player: 'B', stat: 'hits', odds: -110, grade: 'B', gameId: 'g2' },
]);
expect(r.hasKillCondition).toBe(true);
expect(r.killConditions[0].player).toBe('A');
});
});
describe('parlayService — correlation detection', () => {
test('different games → independent', () => {
const c = detectCorrelation(
{ player: 'A', stat: 'points', gameId: 'g1', team: 'X' },
{ player: 'B', stat: 'assists', gameId: 'g2', team: 'Y' },
);
expect(c.correlated).toBe(false);
expect(c.type).toBe('independent');
});
test('same-game teammates assists+points → positive', () => {
const c = detectCorrelation(
{ player: 'A', stat: 'assists', gameId: 'g1', team: 'X' },
{ player: 'B', stat: 'points', gameId: 'g1', team: 'X' },
);
expect(c.correlated).toBe(true);
expect(c.type).toBe('positive');
});
test('same-game opposing rebounds → negative (they fight)', () => {
const c = detectCorrelation(
{ player: 'A', stat: 'rebounds', gameId: 'g1', team: 'X' },
{ player: 'B', stat: 'rebounds', gameId: 'g1', team: 'Y' },
);
expect(c.correlated).toBe(true);
expect(c.type).toBe('negative');
});
test('same player opposite sides on same prop → negative conflict', () => {
const c = detectCorrelation(
{ player: 'A', stat: 'points', side: 'over', gameId: 'g1' },
{ player: 'A', stat: 'points', side: 'under', gameId: 'g1' },
);
expect(c.type).toBe('negative');
});
test('negative correlation surfaced on the parlay', () => {
const r = calculateParlay([
{ player: 'A', stat: 'rebounds', side: 'over', odds: -110, grade: 'B', gameId: 'g1', team: 'X' },
{ player: 'B', stat: 'rebounds', side: 'over', odds: -110, grade: 'B', gameId: 'g1', team: 'Y' },
]);
expect(r.hasNegativeCorrelation).toBe(true);
expect(r.correlations).toHaveLength(1);
});
});
describe('parlayService — suggestParlays', () => {
const pool = [
{ player: 'A', stat: 'points', odds: -110, grade: 'A', gameId: 'g1', team: 'X' },
{ player: 'B', stat: 'hits', odds: -110, grade: 'A-', gameId: 'g2', team: 'Y' },
{ player: 'C', stat: 'goals', odds: -110, grade: 'B+', gameId: 'g3', team: 'Z' },
{ player: 'D', stat: 'assists', odds: -110, grade: 'B', gameId: 'g4', team: 'W' },
];
test('returns a suggestion of the requested leg count', () => {
const s = suggestParlays(pool, { legs: 3, max: 1 });
expect(s).toHaveLength(1);
expect(s[0].legs).toHaveLength(3);
expect(s[0].combinedGrade).toBeDefined();
});
test('too few props → empty', () => {
expect(suggestParlays([pool[0]], { legs: 3 })).toEqual([]);
});
});
+1 -1
View File
File diff suppressed because one or more lines are too long
+25
View File
@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/**
* Book-comparison proxy (Session 28). Forwards /api/books/* to Express
* (best lines + per-prop book grid). Read-only, zero-credit.
*/
export async function GET(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const { path } = await params;
const segments = (path || []).map(encodeURIComponent).join('/');
const qs = req.nextUrl.search;
try {
const upstream = await fetch(`${BACKEND_URL}/api/books/${segments}${qs}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const data = await upstream.json().catch(() => ({}));
return NextResponse.json(data, { status: upstream.status });
} catch {
return NextResponse.json({ bestLines: [], books: [] }, { status: 200 });
}
}
+25
View File
@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/**
* Line-movement proxy (Session 28). Forwards /api/lines/* to Express
* (movers + per-prop history). Read-only, zero-credit.
*/
export async function GET(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const { path } = await params;
const segments = (path || []).map(encodeURIComponent).join('/');
const qs = req.nextUrl.search;
try {
const upstream = await fetch(`${BACKEND_URL}/api/lines/${segments}${qs}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const data = await upstream.json().catch(() => ({}));
return NextResponse.json(data, { status: upstream.status });
} catch {
return NextResponse.json({ movers: [], snapshots: [] }, { status: 200 });
}
}
+30
View File
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/**
* Parlay calculate proxy (Session 28). Forwards builder legs to Express
* `/api/parlay/calculate` for combined odds, grade, and correlation flags.
* Zero-credit pure math — no auth gate (the math reveals nothing private).
*/
export async function POST(req: NextRequest) {
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON.' }, { status: 400 });
}
try {
const upstream = await fetch(`${BACKEND_URL}/api/parlay/calculate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
});
const data = await upstream.json().catch(() => ({}));
return NextResponse.json(data, { status: upstream.status });
} catch {
return NextResponse.json({ error: 'Parlay service unreachable.' }, { status: 502 });
}
}
+105
View File
@@ -0,0 +1,105 @@
'use client';
import { useEffect, useState } from 'react';
import { getVisibleCount, getHiddenCount, type Tier } from '@/lib/tierGate';
/**
* BestLinesPanel (Session 28).
*
* "Best lines tonight" — the prop + sportsbook offering the highest payout,
* with the dollars-per-$100 a user saves by line-shopping. Reads
* /api/books/:sport (cached odds, zero credits). Self-hides when empty.
*/
interface BestLine {
player: string;
stat: string;
line: number | null;
bestBook: string;
bestOdds: number;
savings: number;
}
export interface BestLinesPanelProps {
sport: string;
tier?: Tier;
limit?: number;
}
export default function BestLinesPanel({ sport, tier = 'free', limit }: BestLinesPanelProps) {
const [lines, setLines] = useState<BestLine[] | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const res = await fetch(`/api/books/${sport}`);
if (!res.ok) { if (!cancelled) setLines([]); return; }
const data = await res.json();
if (!cancelled) setLines(Array.isArray(data?.bestLines) ? data.bestLines : []);
} catch {
if (!cancelled) setLines([]);
}
}
load();
return () => { cancelled = true; };
}, [sport]);
if (!lines || lines.length === 0) return null;
const tierCount = getVisibleCount(tier, lines.length);
const cap = limit && limit > 0 ? Math.min(limit, tierCount) : tierCount;
const visible = lines.slice(0, cap);
const hidden = limit ? lines.length - visible.length : getHiddenCount(tier, lines.length);
const fmtOdds = (o: number) => (o > 0 ? `+${o}` : `${o}`);
return (
<section className="best-lines-panel" style={{ margin: '16px 0' }}>
<h3 style={heading}>💰 BEST LINES TONIGHT</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{visible.map((bl) => (
<div key={`${bl.player}-${bl.stat}`} style={row}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={playerName}>{bl.player}</div>
<div style={propLine}>
{bl.stat.replace(/_/g, ' ')}{bl.line != null ? ` ${bl.line}` : ''}
</div>
</div>
<span style={book}>
{bl.bestBook} <span style={{ color: 'var(--grade-a, #00D4A0)' }}>{fmtOdds(bl.bestOdds)}</span>
</span>
{bl.savings > 0 && <span style={savings}>save ${bl.savings.toFixed(2)}</span>}
</div>
))}
</div>
{hidden > 0 && (
<a href="/pricing" style={upsell}>{hidden} more upgrade to compare every book </a>
)}
</section>
);
}
const heading: React.CSSProperties = {
fontSize: 12, letterSpacing: '0.12em', textTransform: 'uppercase',
color: 'var(--text-tertiary, #6B6B7B)', margin: '0 0 10px',
};
const row: React.CSSProperties = {
display: 'flex', alignItems: 'center', gap: 12,
padding: '8px 10px', borderRadius: 10,
background: 'var(--bg-2, #12121A)', border: '1px solid var(--border, #1A1A24)',
};
const playerName: React.CSSProperties = {
fontSize: 14, fontWeight: 700, color: 'var(--text-0, #F0F0F5)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
};
const propLine: React.CSSProperties = { fontSize: 12, color: 'var(--text-secondary, #8A8A9A)', textTransform: 'capitalize' };
const book: React.CSSProperties = { flex: '0 0 auto', fontSize: 12, fontWeight: 700, color: 'var(--text-0, #F0F0F5)', textTransform: 'capitalize' };
const savings: React.CSSProperties = {
flex: '0 0 auto', fontSize: 11, fontWeight: 700, padding: '2px 7px', borderRadius: 6,
background: 'rgba(0,212,160,0.12)', color: 'var(--grade-a, #00D4A0)', whiteSpace: 'nowrap',
};
const upsell: React.CSSProperties = {
display: 'inline-block', marginTop: 10, fontSize: 12, fontWeight: 600,
color: 'var(--grade-a, #00D4A0)', textDecoration: 'none',
};
+73
View File
@@ -0,0 +1,73 @@
'use client';
/**
* BookComparison (Session 28).
*
* Presentational book-by-book grid for a single prop with the best line
* highlighted. Fed by /api/books/:sport/:player/:stat (or the prop grid
* from a parent). Pure render — no fetching here, so it drops cleanly into
* a modal or an expanded prop row.
*/
export interface BookRow {
book: string;
line?: number | null;
over_odds?: number | null;
under_odds?: number | null;
isBest?: boolean;
}
export interface BookComparisonProps {
player: string;
stat: string;
line?: number | null;
side?: 'over' | 'under';
books: BookRow[];
savings?: number;
}
function fmt(o?: number | null) {
if (o == null) return '—';
return o > 0 ? `+${o}` : `${o}`;
}
export default function BookComparison({ player, stat, line, side = 'over', books, savings }: BookComparisonProps) {
if (!books || books.length === 0) return null;
return (
<div className="book-comparison" style={{ display: 'grid', gap: 8 }}>
<div className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary, #6B6B7B)', letterSpacing: '0.06em', textTransform: 'uppercase' }}>
{player} · {stat.replace(/_/g, ' ')}{line != null ? ` ${line}` : ''} · {side}
</div>
<div style={{ display: 'grid', gap: 4 }}>
{books.map((b) => (
<div
key={b.book}
style={{
display: 'grid',
gridTemplateColumns: '1fr auto auto',
gap: 10,
alignItems: 'center',
padding: '6px 10px',
borderRadius: 8,
background: b.isBest ? 'rgba(0,212,160,0.10)' : 'var(--bg-2, #12121A)',
border: `1px solid ${b.isBest ? 'var(--grade-a, #00D4A0)' : 'var(--border, #1A1A24)'}`,
}}
>
<span style={{ fontSize: 13, fontWeight: 700, textTransform: 'capitalize', color: 'var(--text-0, #F0F0F5)' }}>{b.book}</span>
<span className="mono" style={{ fontSize: 12, color: 'var(--text-secondary, #8A8A9A)' }}>
{fmt(b.over_odds)} / {fmt(b.under_odds)}
</span>
{b.isBest ? (
<span className="mono" style={{ fontSize: 9, fontWeight: 800, letterSpacing: '0.08em', color: '#06060B', background: 'var(--grade-a, #00D4A0)', padding: '2px 6px', borderRadius: 4 }}>BEST</span>
) : <span />}
</div>
))}
</div>
{savings != null && savings > 0 && (
<div style={{ fontSize: 12, color: 'var(--grade-a, #00D4A0)' }}>
Betting the best line saves ~${savings.toFixed(2)} per $100.
</div>
)}
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
'use client';
/**
* LineMovementChart (Session 28).
*
* Dependency-free SVG sparkline of a prop's line through the day. Green
* when the line rose, red when it dropped. Renders open + current labels.
* Degrades to a flat midline for <2 points.
*/
export interface Snapshot {
time?: number;
line: number;
}
export interface LineMovementChartProps {
snapshots: Snapshot[];
width?: number;
height?: number;
}
export default function LineMovementChart({ snapshots, width = 120, height = 32 }: LineMovementChartProps) {
const pts = (snapshots || []).map((s) => Number(s.line)).filter((n) => Number.isFinite(n));
if (pts.length === 0) return null;
const opening = pts[0];
const current = pts[pts.length - 1];
const delta = current - opening;
const stroke = Math.abs(delta) < 0.5 ? 'var(--text-tertiary, #6B6B7B)' : delta > 0 ? '#00D4A0' : '#FF4D4D';
const min = Math.min(...pts);
const max = Math.max(...pts);
const range = max - min || 1;
const pad = 4;
const innerH = height - pad * 2;
const stepX = pts.length > 1 ? width / (pts.length - 1) : 0;
const yScale = (v: number) => pad + innerH - ((v - min) / range) * innerH;
const polyline =
pts.length > 1
? pts.map((v, i) => `${(i * stepX).toFixed(1)},${yScale(v).toFixed(1)}`).join(' ')
: `0,${(height / 2).toFixed(1)} ${width},${(height / 2).toFixed(1)}`;
return (
<svg
viewBox={`0 0 ${width} ${height}`}
width={width}
height={height}
role="img"
aria-label={`Line moved from ${opening} to ${current}`}
style={{ display: 'block' }}
>
<polyline points={polyline} fill="none" stroke={stroke} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
{pts.length > 1 && <circle cx={width} cy={yScale(current)} r={2.5} fill={stroke} />}
</svg>
);
}
+103
View File
@@ -0,0 +1,103 @@
'use client';
import { useEffect, useState } from 'react';
import LineMovementChart, { Snapshot } from '@/components/LineMovementChart';
import { getVisibleCount, getHiddenCount, type Tier } from '@/lib/tierGate';
/**
* MoversPanel (Session 28).
*
* Biggest line moves for a sport — the market confirming (or contradicting)
* a grade. Reads /api/lines/:sport/movers (Redis snapshot history, zero
* credits). Self-hides when there's nothing moving. Free users see the top
* 3; paid see the full board.
*/
interface Mover {
player: string;
stat: string;
opening: number;
current: number;
delta: number;
movement: 'stable' | 'rising' | 'dropping';
sharpSignal: boolean;
snapshots: Snapshot[];
}
export interface MoversPanelProps {
sport: string;
tier?: Tier;
limit?: number;
}
export default function MoversPanel({ sport, tier = 'free', limit }: MoversPanelProps) {
const [movers, setMovers] = useState<Mover[] | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const res = await fetch(`/api/lines/${sport}/movers`);
if (!res.ok) { if (!cancelled) setMovers([]); return; }
const data = await res.json();
if (!cancelled) setMovers(Array.isArray(data?.movers) ? data.movers : []);
} catch {
if (!cancelled) setMovers([]);
}
}
load();
return () => { cancelled = true; };
}, [sport]);
if (!movers || movers.length === 0) return null;
const tierCount = getVisibleCount(tier, movers.length);
const cap = limit && limit > 0 ? Math.min(limit, tierCount) : tierCount;
const visible = movers.slice(0, cap);
const hidden = limit ? movers.length - visible.length : getHiddenCount(tier, movers.length);
return (
<section className="movers-panel" style={{ margin: '16px 0' }}>
<h3 style={heading}>📈 BIGGEST MOVERS</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{visible.map((m) => (
<div key={`${m.player}-${m.stat}`} style={row}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={playerName}>{m.player}</div>
<div style={propLine}>{m.stat.replace(/_/g, ' ')} · open {m.opening} {m.current}</div>
</div>
<LineMovementChart snapshots={m.snapshots} />
<span style={{ ...delta, color: m.delta > 0 ? '#00D4A0' : '#FF4D4D' }}>
{m.delta > 0 ? '+' : ''}{m.delta}
{m.sharpSignal ? <span style={sharp} title="Sharp money signal"> SHARP</span> : null}
</span>
</div>
))}
</div>
{hidden > 0 && (
<a href="/pricing" style={upsell}>{hidden} more upgrade for the full board </a>
)}
</section>
);
}
const heading: React.CSSProperties = {
fontSize: 12, letterSpacing: '0.12em', textTransform: 'uppercase',
color: 'var(--text-tertiary, #6B6B7B)', margin: '0 0 10px',
};
const row: React.CSSProperties = {
display: 'flex', alignItems: 'center', gap: 12,
padding: '8px 10px', borderRadius: 10,
background: 'var(--bg-2, #12121A)', border: '1px solid var(--border, #1A1A24)',
};
const playerName: React.CSSProperties = {
fontSize: 14, fontWeight: 700, color: 'var(--text-0, #F0F0F5)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
};
const propLine: React.CSSProperties = { fontSize: 12, color: 'var(--text-secondary, #8A8A9A)', textTransform: 'capitalize' };
const delta: React.CSSProperties = { flex: '0 0 auto', fontSize: 13, fontWeight: 800, whiteSpace: 'nowrap' };
const sharp: React.CSSProperties = { fontSize: 9, color: '#FFB347', letterSpacing: '0.06em' };
const upsell: React.CSSProperties = {
display: 'inline-block', marginTop: 10, fontSize: 12, fontWeight: 600,
color: 'var(--grade-a, #00D4A0)', textDecoration: 'none',
};
+9
View File
@@ -11,6 +11,10 @@ import { useAuth } from '@/contexts/AuthContext';
import StatFilterPills from '@/components/StatFilterPills'; import StatFilterPills from '@/components/StatFilterPills';
import StreaksPanel from '@/components/StreaksPanel'; import StreaksPanel from '@/components/StreaksPanel';
import HotListPanel from '@/components/HotListPanel'; import HotListPanel from '@/components/HotListPanel';
// Session 28 — line-movement + book-comparison read-only panels. Both
// self-hide when empty; free users see a top-3 teaser.
import MoversPanel from '@/components/MoversPanel';
import BestLinesPanel from '@/components/BestLinesPanel';
/** /**
* The Slate (Session 13). * The Slate (Session 13).
@@ -762,6 +766,11 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
an off-hours slate with no warm logs simply shows the games. */} an off-hours slate with no warm logs simply shows the games. */}
<StreaksPanel sport={tab === 'all' ? 'nba' : tab} tier={tier} stat={activeStat} /> <StreaksPanel sport={tab === 'all' ? 'nba' : tab} tier={tier} stat={activeStat} />
<HotListPanel sport={tab === 'all' ? 'mlb' : tab} tier={tier} stat={activeStat} /> <HotListPanel sport={tab === 'all' ? 'mlb' : tab} tier={tier} stat={activeStat} />
{/* Session 28 — market layers: how lines are MOVING and where the
BEST price sits. Both read cached data (zero credits) and
self-hide until there's something to show. */}
<MoversPanel sport={tab === 'all' ? 'mlb' : tab} tier={tier} />
<BestLinesPanel sport={tab === 'all' ? 'mlb' : tab} tier={tier} />
{/* Session 24 — removed the developer-facing "odds endpoint not {/* Session 24 — removed the developer-facing "odds endpoint not
configured yet" footer note. A sport with no data simply doesn't configured yet" footer note. A sport with no data simply doesn't