Session 28: Parlay builder, line movement tracker, book comparison — 3 features, zero credits (1623 tests)
This commit is contained in:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user