3ac91c3d96
PHASE 0 GATE — the answer is BOTH, and the important half was already here. A STATIC FanGraphs park-factor table has existed since Session 15 (src/data/parkFactors.js) and computeFeatures already consumes it, so park is not a new idea in this codebase. What was missing is OUR derivation. I nearly built a second source of truth before finding it; the new service lives at src/services/parkFactors.js and the two are deliberately distinct. That discovery changes the point of this order rather than just its scope. If the champion already sees a park factor, adding one to the challenger risks double-counting — which is exactly the redundancy the Session-72 harness exists to catch. So park ships as a NOMINATED CHALLENGER whose job is to be tested for marginal contribution, not as an assumed improvement. Checked and worth noting: the static table reaches computeFeatures but NOT probabilityEstimator, so it does not currently touch p_win at all. DERIVATION, not ingestion. statsapi gives every game with venue, linescore and scoringPlays in one call per date range — and since every home run scores at least the batter, HR totals are fully recoverable from scoring plays. Derived from 5,055 real games across 2022-2025: Coors tops the run environment at 1.099, Dodger Stadium tops home runs at 1.106, Oracle Park and PNC suppress them at 0.923 and 0.917. Eighteen parks cleared the floor, eighteen did not and are honestly absent. COMPOSABLE BY CONSTRUCTION — the architectural point. Park emits a multiplier around 1.0, never an additive nudge, because weather has to modulate it next order: effective = park_base x weather_mod. Additive terms do not compose correctly (a 5% park and an 8% wind are 1.05 x 1.08, not +13%), and the challenger converts the multiplier to log-odds so stacking stays correct. A test multiplies a placeholder weather term onto the park base to prove the shape composes with no rearchitecting. DIRECTIONAL BY PROP-OWNER: home_runs and home_runs_allowed both key off hr_base in the same direction, because the sign lives in the STAT, not the park. Coors inflates the hitter's home run prop and the pitcher's home-runs-allowed prop identically. THREE HONEST STATES, deliberately distinct. Absent (thin sample, adjust nothing), present (adjust), and weather_na for domes — where the park factor STILL APPLIES because a dome has a real run environment, and the flag exists so next order's weather modulation correctly does nothing there. N/A is not absent; conflating them would either drop a valid park factor or apply wind indoors. Structural breaks: a season deviating past the threshold starts a new regime only if the FOLLOWING season confirms it — one odd year is noise, two consecutive years on the same side is a rebuilt park. Only post-break seasons are used, so a humidor or moved wall cannot be diluted by the stadium that preceded it. Factors regress toward neutral by sample size, so a two-season park cannot assert a Coors-sized coefficient, and fine conditioning stays unavailable until its own larger floor. Tests 3669 passed / 297 suites, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
96 lines
3.3 KiB
JavaScript
96 lines
3.3 KiB
JavaScript
// MLB park factors (Session 15) — pin the membership list + assert
|
|
// the expected magnitude of the headline parks. Park factors shift
|
|
// year-to-year but the directional signal (Coors hot, Oracle cold)
|
|
// is stable; the test catches obvious typos.
|
|
|
|
const pf = require('../../src/data/parkFactors');
|
|
|
|
describe('parkFactors', () => {
|
|
test('covers all 30 MLB teams', () => {
|
|
const codes = Object.keys(pf.PARK_FACTORS);
|
|
expect(codes.length).toBe(30);
|
|
});
|
|
|
|
test('every entry has hr/h/r as finite numbers', () => {
|
|
for (const [code, vals] of Object.entries(pf.PARK_FACTORS)) {
|
|
expect(typeof code).toBe('string');
|
|
expect(code).toMatch(/^[A-Z]{2,3}$/);
|
|
expect(Number.isFinite(vals.hr)).toBe(true);
|
|
expect(Number.isFinite(vals.h)).toBe(true);
|
|
expect(Number.isFinite(vals.r)).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('Coors Field is the most extreme HR park', () => {
|
|
const hrs = Object.values(pf.PARK_FACTORS).map((p) => p.hr);
|
|
const maxHr = Math.max(...hrs);
|
|
expect(pf.PARK_FACTORS.COL.hr).toBe(maxHr);
|
|
expect(pf.PARK_FACTORS.COL.hr).toBeGreaterThan(120);
|
|
});
|
|
|
|
test('Oracle Park (SF) suppresses HRs heavily', () => {
|
|
expect(pf.PARK_FACTORS.SF.hr).toBeLessThan(95);
|
|
});
|
|
|
|
test('Coors also boosts hits and runs', () => {
|
|
expect(pf.PARK_FACTORS.COL.h).toBeGreaterThan(100);
|
|
expect(pf.PARK_FACTORS.COL.r).toBeGreaterThan(100);
|
|
});
|
|
|
|
test('most parks land within ±15% of neutral', () => {
|
|
// Coors + SF are deliberate outliers; check the rest cluster.
|
|
const codes = Object.keys(pf.PARK_FACTORS).filter((c) => c !== 'COL' && c !== 'SF');
|
|
for (const code of codes) {
|
|
const { hr, h, r } = pf.PARK_FACTORS[code];
|
|
expect(hr).toBeGreaterThanOrEqual(85);
|
|
expect(hr).toBeLessThanOrEqual(115);
|
|
expect(h).toBeGreaterThanOrEqual(90);
|
|
expect(h).toBeLessThanOrEqual(110);
|
|
expect(r).toBeGreaterThanOrEqual(90);
|
|
expect(r).toBeLessThanOrEqual(110);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('getParkFactor', () => {
|
|
test('returns the row for known teams', () => {
|
|
expect(pf.getParkFactor('NYY').hr).toBe(pf.PARK_FACTORS.NYY.hr);
|
|
expect(pf.getParkFactor('COL').hr).toBeGreaterThan(120);
|
|
});
|
|
|
|
test('case-insensitive', () => {
|
|
expect(pf.getParkFactor('nyy').hr).toBeGreaterThan(0);
|
|
expect(pf.getParkFactor('Col')).toBe(pf.PARK_FACTORS.COL);
|
|
});
|
|
|
|
test('whitespace-tolerant', () => {
|
|
expect(pf.getParkFactor(' COL ')).toBeTruthy();
|
|
});
|
|
|
|
test('returns null for unknown / empty', () => {
|
|
expect(pf.getParkFactor('XYZ')).toBeNull();
|
|
expect(pf.getParkFactor('')).toBeNull();
|
|
expect(pf.getParkFactor(null)).toBeNull();
|
|
expect(pf.getParkFactor(undefined)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('getParkFactorOrNeutral', () => {
|
|
test('returns the row when known', () => {
|
|
expect(pf.getParkFactorOrNeutral('NYY')).toBe(pf.PARK_FACTORS.NYY);
|
|
});
|
|
test('falls back to neutral 100s when unknown', () => {
|
|
expect(pf.getParkFactorOrNeutral('XYZ')).toEqual({ hr: 100, h: 100, r: 100 });
|
|
expect(pf.getParkFactorOrNeutral(null)).toEqual({ hr: 100, h: 100, r: 100 });
|
|
});
|
|
});
|
|
|
|
describe('immutability', () => {
|
|
test('PARK_FACTORS is frozen at the top level', () => {
|
|
expect(Object.isFrozen(pf.PARK_FACTORS)).toBe(true);
|
|
});
|
|
test('NEUTRAL is frozen', () => {
|
|
expect(Object.isFrozen(pf.NEUTRAL)).toBe(true);
|
|
});
|
|
});
|