Session 28: Parlay builder, line movement tracker, book comparison — 3 features, zero credits (1623 tests)
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 || [];
|
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);
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user