262 lines
8.6 KiB
JavaScript
262 lines
8.6 KiB
JavaScript
const { gradeMlbProp, calculateMlbEdge, isMlbStatType } = require('../../src/services/mlbGrader');
|
|
const { evaluateMlbKillConditions, classifyLineMove, checkWeather } = require('../../src/services/mlbKillConditions');
|
|
const { MLB_PARKS, getParkByTeam } = require('../../src/constants/mlbParks');
|
|
|
|
jest.mock('axios');
|
|
const axios = require('axios');
|
|
|
|
describe('mlbGrader', () => {
|
|
describe('grade thresholds', () => {
|
|
test('Grade A when edge >= 5%', () => {
|
|
const result = gradeMlbProp({
|
|
player: 'Aaron Judge',
|
|
stat_type: 'home_runs',
|
|
line: 0.5,
|
|
direction: 'over',
|
|
seasonAvg: 0.7,
|
|
recentAvg: 0.8,
|
|
});
|
|
expect(result.grade).toBe('A');
|
|
expect(result.edge_pct).toBeGreaterThanOrEqual(5);
|
|
});
|
|
|
|
test('Grade B when edge 3-4%', () => {
|
|
// seasonAvg=5.15, line=5, direction=over => seasonEdge=(5.15-5)/5*100=3%
|
|
// recentAvg=5.2, line=5 => recentEdge=(5.2-5)/5*100=4%
|
|
// composite = 3*0.6 + 4*0.4 = 1.8+1.6 = 3.4
|
|
const result = gradeMlbProp({
|
|
player: 'Test Player',
|
|
stat_type: 'strikeouts',
|
|
line: 5,
|
|
direction: 'over',
|
|
seasonAvg: 5.15,
|
|
recentAvg: 5.2,
|
|
});
|
|
expect(result.grade).toBe('B');
|
|
expect(result.edge_pct).toBeGreaterThanOrEqual(3);
|
|
expect(result.edge_pct).toBeLessThan(5);
|
|
});
|
|
|
|
test('Grade C when edge 1-2%', () => {
|
|
// seasonAvg=5.05, line=5, direction=over => seasonEdge=1%
|
|
// recentAvg=5.1 => recentEdge=2%
|
|
// composite = 1*0.6 + 2*0.4 = 0.6+0.8 = 1.4
|
|
const result = gradeMlbProp({
|
|
player: 'Test Player',
|
|
stat_type: 'hits',
|
|
line: 5,
|
|
direction: 'over',
|
|
seasonAvg: 5.05,
|
|
recentAvg: 5.1,
|
|
});
|
|
expect(result.grade).toBe('C');
|
|
expect(result.edge_pct).toBeGreaterThanOrEqual(1);
|
|
expect(result.edge_pct).toBeLessThan(3);
|
|
});
|
|
|
|
test('Grade D when negative edge', () => {
|
|
const result = gradeMlbProp({
|
|
player: 'Test Player',
|
|
stat_type: 'hits',
|
|
line: 2,
|
|
direction: 'over',
|
|
seasonAvg: 1.5,
|
|
recentAvg: 1.3,
|
|
});
|
|
expect(result.grade).toBe('D');
|
|
expect(result.edge_pct).toBeLessThan(1);
|
|
});
|
|
});
|
|
|
|
describe('isMlbStatType', () => {
|
|
test('returns true for valid hitting stat', () => {
|
|
expect(isMlbStatType('hits')).toBe(true);
|
|
expect(isMlbStatType('home_runs')).toBe(true);
|
|
expect(isMlbStatType('stolen_bases')).toBe(true);
|
|
});
|
|
|
|
test('returns true for valid pitching stat', () => {
|
|
expect(isMlbStatType('strikeouts')).toBe(true);
|
|
expect(isMlbStatType('earned_runs')).toBe(true);
|
|
expect(isMlbStatType('pitches_thrown')).toBe(true);
|
|
});
|
|
|
|
test('returns false for invalid stat type', () => {
|
|
expect(isMlbStatType('three_pointers')).toBe(false);
|
|
expect(isMlbStatType('touchdowns')).toBe(false);
|
|
expect(isMlbStatType('')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('calculateMlbEdge', () => {
|
|
test('calculates positive edge for over', () => {
|
|
const edge = calculateMlbEdge(6, 5, 'over');
|
|
expect(edge).toBe(20);
|
|
});
|
|
|
|
test('calculates positive edge for under', () => {
|
|
const edge = calculateMlbEdge(4, 5, 'under');
|
|
expect(edge).toBe(20);
|
|
});
|
|
|
|
test('returns 0 for null inputs', () => {
|
|
expect(calculateMlbEdge(null, 5, 'over')).toBe(0);
|
|
expect(calculateMlbEdge(5, null, 'over')).toBe(0);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('mlbKillConditions', () => {
|
|
function makeContext(overrides = {}) {
|
|
return {
|
|
inLineup: true,
|
|
pitcherScratched: false,
|
|
weather: { wind_speed: 5, wind_direction: 'OUT', temp: 75, humidity: 50 },
|
|
platoonDelta: 5,
|
|
paVsHandedness: 100,
|
|
lineMovement: 0,
|
|
hoursFromOpen: 1,
|
|
parkFactor: 1.0,
|
|
rainProbability: 10,
|
|
onInjuryReport: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
test('LINEUP_OUT triggers when player not in lineup', () => {
|
|
const result = evaluateMlbKillConditions(makeContext({ inLineup: false }));
|
|
expect(result.some(c => c.code === 'LINEUP_OUT')).toBe(true);
|
|
});
|
|
|
|
test('PITCHER_SCRATCH triggers when pitcher scratched', () => {
|
|
const result = evaluateMlbKillConditions(makeContext({ pitcherScratched: true }));
|
|
expect(result.some(c => c.code === 'PITCHER_SCRATCH')).toBe(true);
|
|
});
|
|
|
|
test('WIND_IN triggers at 15mph+ blowing in', () => {
|
|
const result = evaluateMlbKillConditions(makeContext({
|
|
weather: { wind_speed: 18, wind_direction: 'IN', temp: 75, humidity: 50 },
|
|
}));
|
|
expect(result.some(c => c.code === 'WIND_IN')).toBe(true);
|
|
});
|
|
|
|
test('PLATOON_DISADVANTAGE triggers when delta > 12%', () => {
|
|
const result = evaluateMlbKillConditions(makeContext({ platoonDelta: 15 }));
|
|
expect(result.some(c => c.code === 'PLATOON_DISADVANTAGE')).toBe(true);
|
|
});
|
|
|
|
test('SMALL_SAMPLE triggers under 50 PA', () => {
|
|
const result = evaluateMlbKillConditions(makeContext({ paVsHandedness: 30 }));
|
|
expect(result.some(c => c.code === 'SMALL_SAMPLE')).toBe(true);
|
|
});
|
|
|
|
test('LINE_MOVE_AGAINST triggers at 0.5+ movement', () => {
|
|
const result = evaluateMlbKillConditions(makeContext({ lineMovement: 0.7, hoursFromOpen: 1 }));
|
|
expect(result.some(c => c.code === 'LINE_MOVE_AGAINST')).toBe(true);
|
|
});
|
|
|
|
test('PARK_SUPPRESSOR triggers below 0.90', () => {
|
|
const result = evaluateMlbKillConditions(makeContext({ parkFactor: 0.85 }));
|
|
expect(result.some(c => c.code === 'PARK_SUPPRESSOR')).toBe(true);
|
|
});
|
|
|
|
test('WEATHER_RAIN triggers above 50% probability', () => {
|
|
const result = evaluateMlbKillConditions(makeContext({ rainProbability: 65 }));
|
|
expect(result.some(c => c.code === 'WEATHER_RAIN')).toBe(true);
|
|
});
|
|
|
|
test('INJURY_REPORT triggers when on injury report', () => {
|
|
const result = evaluateMlbKillConditions(makeContext({ onInjuryReport: true }));
|
|
expect(result.some(c => c.code === 'INJURY_REPORT')).toBe(true);
|
|
});
|
|
|
|
test('HUMIDITY_SUPPRESSOR triggers at humidity > 80% and temp < 60F', () => {
|
|
const result = evaluateMlbKillConditions(makeContext({
|
|
weather: { wind_speed: 5, wind_direction: 'OUT', temp: 55, humidity: 85 },
|
|
}));
|
|
expect(result.some(c => c.code === 'HUMIDITY_SUPPRESSOR')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('classifyLineMove', () => {
|
|
test('returns sharp for movement within first 2 hours', () => {
|
|
expect(classifyLineMove(0.7, 1)).toBe('sharp');
|
|
expect(classifyLineMove(-0.5, 0.5)).toBe('sharp');
|
|
});
|
|
|
|
test('returns public for movement after 4 hours', () => {
|
|
expect(classifyLineMove(0.6, 5)).toBe('public');
|
|
expect(classifyLineMove(-0.8, 6)).toBe('public');
|
|
});
|
|
|
|
test('returns null for movement under 0.5', () => {
|
|
expect(classifyLineMove(0.3, 1)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('checkWeather', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
test('falls back to open-meteo on api.weather.gov timeout', async () => {
|
|
// Mock weather.gov to timeout
|
|
axios.get.mockImplementation((url) => {
|
|
if (url.includes('weather.gov')) {
|
|
return Promise.reject(new Error('timeout of 3000ms exceeded'));
|
|
}
|
|
// open-meteo fallback
|
|
return Promise.resolve({
|
|
data: {
|
|
hourly: {
|
|
temperature_2m: Array(24).fill(72),
|
|
relative_humidity_2m: Array(24).fill(50),
|
|
wind_speed_10m: Array(24).fill(10),
|
|
wind_direction_10m: Array(24).fill(180),
|
|
precipitation_probability: Array(24).fill(20),
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
const result = await checkWeather([40.8296, -73.9262], 3000);
|
|
expect(result.wind_speed).toBe(10);
|
|
expect(result.temp).toBe(72);
|
|
// Verify weather.gov was attempted first
|
|
expect(axios.get).toHaveBeenCalledWith(
|
|
expect.stringContaining('weather.gov'),
|
|
expect.any(Object)
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('mlbParks', () => {
|
|
test('has exactly 30 entries', () => {
|
|
expect(Object.keys(MLB_PARKS).length).toBe(30);
|
|
});
|
|
|
|
test('getParkByTeam returns correct park for NYY', () => {
|
|
const park = getParkByTeam('NYY');
|
|
expect(park).not.toBeNull();
|
|
expect(park.name).toBe('Yankee Stadium');
|
|
expect(park.coords).toEqual([40.8296, -73.9262]);
|
|
});
|
|
|
|
test('getParkByTeam returns correct park for LAD', () => {
|
|
const park = getParkByTeam('LAD');
|
|
expect(park.name).toBe('Dodger Stadium');
|
|
});
|
|
|
|
test('getParkByTeam returns null for invalid team', () => {
|
|
expect(getParkByTeam('XXX')).toBeNull();
|
|
});
|
|
|
|
test('every park has name, coords, and team', () => {
|
|
for (const [key, park] of Object.entries(MLB_PARKS)) {
|
|
expect(park.name).toBeDefined();
|
|
expect(park.coords).toHaveLength(2);
|
|
expect(park.team).toBeDefined();
|
|
}
|
|
});
|
|
});
|