/* ============================================================ Session 73 — DERIVED PARK FACTORS (src/services/parkFactors.js). Distinct from the STATIC FanGraphs table (src/data/parkFactors.js, Session 15), which already feeds computeFeatures. This is OUR derivation, and it is wired as a CHALLENGER precisely so the harness can test whether it adds anything beyond what the champion already has. ============================================================ */ const pf = require('../../src/services/parkFactors'); const ch = require('../../src/services/challengerProjection'); function games({ venue = 'Test Park', vid = 1, seasons = ['2022', '2023', '2024', '2025'], per = 81, hrPerGame = 2, runsPerGame = 9, leagueHr = 2, leagueRuns = 9 } = {}) { const out = []; for (const season of seasons) { for (let i = 0; i < per; i++) { out.push({ season, venue_id: vid, venue_name: venue, home_team: 'H', away_team: `A${i % 8}`, home_runs: runsPerGame, away_runs: 0, home_hr: hrPerGame, away_hr: 0 }); out.push({ season, venue_id: 99, venue_name: 'Neutral Park', home_team: 'N', away_team: `A${i % 8}`, home_runs: leagueRuns, away_runs: 0, home_hr: leagueHr, away_hr: 0 }); } } return out; } describe('derivation — coarse, sample-floored, regressed', () => { it('a hitter park is above 1, a pitcher park below', () => { expect(pf.deriveParkFactors(games({ hrPerGame: 3 }))['1'].hr_base).toBeGreaterThan(1); expect(pf.deriveParkFactors(games({ vid: 2, hrPerGame: 1 }))['2'].hr_base).toBeLessThan(1); }); it('HONEST-ABSENT below the games floor', () => { const thin = pf.deriveParkFactors(games({ seasons: ['2025'], per: 40, hrPerGame: 4 }))['1']; expect(thin.state).toBe('absent'); expect(thin.hr_base).toBeNull(); expect(thin.reason).toMatch(/floor/); }); it('regresses toward neutral by sample size — a small park cannot shout', () => { const few = pf.deriveParkFactors(games({ seasons: ['2024', '2025'], hrPerGame: 4 }))['1']; const many = pf.deriveParkFactors(games({ seasons: ['2021', '2022', '2023', '2024', '2025'], hrPerGame: 4 }))['1']; expect(Math.abs(many.hr_base - 1)).toBeGreaterThan(Math.abs(few.hr_base - 1)); }); it('fine conditioning waits for its own larger floor', () => { expect(pf.deriveParkFactors(games({}))['1'].fine_available).toBe(false); expect(pf.deriveParkFactors(games({ seasons: ['2021', '2022', '2023', '2024', '2025', '2026'] }))['1'].fine_available).toBe(true); }); }); describe('structural breaks — a changed park is a NEW park', () => { it('detects a CONFIRMED step', () => { const r = pf.detectRegime([ { season: '2021', factor: 1.00 }, { season: '2022', factor: 1.02 }, { season: '2023', factor: 1.30 }, { season: '2024', factor: 1.28 }, { season: '2025', factor: 1.31 }]); expect(r.broke).toBe(true); expect(r.start).toBe('2023'); }); it('does NOT break on one odd season — a single year is noise', () => { expect(pf.detectRegime([ { season: '2021', factor: 1.00 }, { season: '2022', factor: 1.30 }, { season: '2023', factor: 1.01 }, { season: '2024', factor: 0.99 }]).broke).toBe(false); }); it('uses ONLY post-break seasons so the old park cannot dilute the new one', () => { const f = pf.deriveParkFactors([ ...games({ seasons: ['2021', '2022'], hrPerGame: 1 }), ...games({ seasons: ['2023', '2024', '2025'], hrPerGame: 4 })])['1']; expect(f.regime_broke).toBe(true); expect(f.regime_start).toBe('2023'); expect(f.hr_base).toBeGreaterThan(1); }); }); describe('three honest states — absent vs present vs dome', () => { it('a dome is weather_na but its park factor still APPLIES', () => { const f = pf.deriveParkFactors(games({ venue: 'Rogers Centre', hrPerGame: 3 }))['1']; expect(f.weather_na).toBe(true); expect(f.state).toBe('present'); // N/A is NOT absent }); it('absent and weather_na are independent', () => { const t = pf.deriveParkFactors(games({ venue: 'Rogers Centre', seasons: ['2025'], per: 30 }))['1']; expect(t.state).toBe('absent'); expect(t.weather_na).toBe(true); }); }); describe('composable coefficient — weather multiplies onto it next order', () => { const coors = { state: 'present', hr_base: 1.28, run_base: 1.18, venue: 'Coors Field', weather_na: false }; const env = (f, stat) => ({ multiplier: pf.parkMultiplier({ factor: f, statType: stat }), label: 'PARK', venue: f.venue, weather_na: f.weather_na }); it('emits a MULTIPLIER and 1.0 is a true no-op', () => { expect(pf.parkMultiplier({ factor: coors, statType: 'home_runs' })).toBe(1.28); expect(pf.parkMultiplier({ factor: coors, statType: 'strikeouts' })).toBe(1); expect(pf.parkMultiplier({ factor: { state: 'absent' }, statType: 'home_runs' })).toBe(1); }); it('composes multiplicatively — the property weather depends on', () => { const park = pf.parkMultiplier({ factor: coors, statType: 'home_runs' }); const a = ch.adjust({ pWin: 0.5, direction: 'over', statType: 'home_runs', environment: { multiplier: park * 1.08 } }); const b = ch.adjust({ pWin: 0.5, direction: 'over', statType: 'home_runs', environment: { multiplier: park } }); expect(a.delta).toBeGreaterThan(b.delta); }); it('DIRECTIONAL BY PROP-OWNER — the sign lives in the STAT, not the park', () => { expect(pf.parkMultiplier({ factor: coors, statType: 'home_runs' })) .toBe(pf.parkMultiplier({ factor: coors, statType: 'home_runs_allowed' })); }); it('fires at a strong park and mirrors on the under', () => { const over = ch.adjust({ pWin: 0.5, direction: 'over', statType: 'home_runs', environment: env(coors, 'home_runs') }); const under = ch.adjust({ pWin: 0.5, direction: 'under', statType: 'home_runs', environment: env(coors, 'home_runs') }); expect(over.delta).toBeGreaterThan(0); expect(under.delta).toBeCloseTo(-over.delta, 3); expect(over.adjustments[0].venue).toBe('Coors Field'); }); it('does NOTHING at a thin park, or for a stat the park is silent on', () => { const thin = { state: 'absent', venue: 'New Park', weather_na: false }; expect(ch.adjust({ pWin: 0.5, direction: 'over', statType: 'home_runs', environment: env(thin, 'home_runs') }).delta).toBe(0); expect(ch.adjust({ pWin: 0.5, direction: 'over', statType: 'strikeouts', environment: env(coors, 'strikeouts') }).delta).toBe(0); }); it('the environment nudge is capped — a park is a lean, not a re-forecast', () => { const absurd = { state: 'present', hr_base: 5, run_base: 5, venue: 'X', weather_na: false }; expect(Math.abs(ch.adjust({ pWin: 0.5, direction: 'over', statType: 'home_runs', environment: env(absurd, 'home_runs') }).delta)).toBeLessThan(0.09); }); });