Layer 3 Step 4: derived park factors, composable for weather
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
This commit is contained in:
@@ -100,25 +100,71 @@ function num(v) {
|
||||
* `adjustments` is empty — the challenger is the champion on those rows, by
|
||||
* construction, and the comparison stays clean.
|
||||
*/
|
||||
function adjust({ pWin, direction, statType, classification } = {}) {
|
||||
/**
|
||||
* Compose an ENVIRONMENT multiplier into the same log-odds space the archetype
|
||||
* nudges use. A multiplier of 1.0 is a no-op; >1 leans toward the over.
|
||||
*
|
||||
* Park is the BASE environment; weather will multiply onto it next order
|
||||
* (`env = park_base × weather_mod × …`) with no change here — that is why the
|
||||
* park layer emits a coefficient rather than a nudge.
|
||||
*
|
||||
* `Math.log(env)` converts a multiplicative environment into an additive
|
||||
* log-odds term, which is the correct composition: two independent 5% effects
|
||||
* become 1.05 × 1.05, not +5% +5%.
|
||||
*/
|
||||
const ENV_SCALE = Number(process.env.ENV_NUDGE_SCALE) || 1.0;
|
||||
const MAX_ENV_NUDGE = 0.30;
|
||||
function envNudge(env, dirSign) {
|
||||
const e = num(env);
|
||||
if (e == null || e <= 0 || e === 1) return 0;
|
||||
const raw = Math.log(e) * ENV_SCALE * dirSign;
|
||||
return clamp(raw, -MAX_ENV_NUDGE, MAX_ENV_NUDGE);
|
||||
}
|
||||
|
||||
function adjust({ pWin, direction, statType, classification, environment } = {}) {
|
||||
const p = num(pWin);
|
||||
const identical = (reason) => ({
|
||||
p_win_challenger: p, delta: 0, adjustments: [], reason, version: CHALLENGER_VERSION,
|
||||
});
|
||||
|
||||
if (p == null || p <= 0 || p >= 1) return identical('no_champion_probability');
|
||||
if (!classification || !classification.sufficient) return identical('archetype_absent_or_thin');
|
||||
const envPresent = num(environment && environment.multiplier) != null
|
||||
&& num(environment.multiplier) !== 1;
|
||||
if ((!classification || !classification.sufficient) && !envPresent) {
|
||||
return identical('archetype_absent_or_thin');
|
||||
}
|
||||
|
||||
const vector = classification.vector || {};
|
||||
const vector = (classification && classification.vector) || {};
|
||||
const stat = String(statType || '').toLowerCase();
|
||||
const map = (classification.role === 'pitcher' ? PITCHER_MAP : BATTER_MAP)[stat];
|
||||
if (!map) return identical('stat_not_mapped');
|
||||
const role = classification && classification.role === 'pitcher' ? 'pitcher' : 'batter';
|
||||
const map = ((classification && classification.sufficient)
|
||||
? (role === 'pitcher' ? PITCHER_MAP : BATTER_MAP)[stat]
|
||||
: null) || {};
|
||||
if (!Object.keys(map).length && !envPresent) return identical('stat_not_mapped');
|
||||
|
||||
// Direction: a trait that raises the stat raises P(over) and lowers P(under).
|
||||
const dirSign = String(direction || 'over').toLowerCase() === 'under' ? -1 : 1;
|
||||
|
||||
const adjustments = [];
|
||||
let total = 0;
|
||||
|
||||
// ── ENVIRONMENT (park now; weather composes onto it next order) ────────
|
||||
// Applied even when no archetype axis fires: a park effect is real whether or
|
||||
// not the player is distinctive. `environment` is the COMPOSED multiplier.
|
||||
const envMult = num(environment && environment.multiplier);
|
||||
if (envMult != null && envMult !== 1) {
|
||||
const n = envNudge(envMult, dirSign);
|
||||
if (n) {
|
||||
total += n;
|
||||
adjustments.push({
|
||||
axis: 'environment', label: (environment.label || 'PARK'),
|
||||
tier: 'env', nudge: Math.round(n * 1000) / 1000,
|
||||
multiplier: Math.round(envMult * 1000) / 1000,
|
||||
venue: environment.venue || null,
|
||||
weather_na: environment.weather_na ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [axisKey, sign] of Object.entries(map)) {
|
||||
if (!sign) continue;
|
||||
const hit = vector[axisKey];
|
||||
@@ -132,7 +178,7 @@ function adjust({ pWin, direction, statType, classification } = {}) {
|
||||
adjustments.push({ axis: axisKey, label: hit.label, tier: hit.tier, nudge: Math.round(signed * 1000) / 1000 });
|
||||
}
|
||||
|
||||
if (!adjustments.length) return identical('no_distinctive_axis_for_stat');
|
||||
if (!adjustments.length) return identical('no_distinctive_axis_or_environment');
|
||||
|
||||
const capped = clamp(total, -MAX_TOTAL_NUDGE, MAX_TOTAL_NUDGE);
|
||||
const challenger = clamp(fromLogOdds(toLogOdds(p) + capped), 0.01, 0.99);
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* PARK FACTORS (Layer 3, Step 4) — the first derive-it-ourselves adjuster.
|
||||
*
|
||||
* Commodity concept, PROPRIETARY derivation: everyone knows Coors inflates
|
||||
* offence; the value is in deriving OUR coefficient, with our sample floors,
|
||||
* our regime detection, and our honest-absent states.
|
||||
*
|
||||
* ── COMPOSABLE BY CONSTRUCTION (the whole architectural point) ────────────
|
||||
* A park factor is the BASE run/HR environment. Weather MODULATES it for a
|
||||
* given night. So this emits a **multiplicative coefficient** (1.0 = neutral),
|
||||
* never a fixed additive nudge:
|
||||
*
|
||||
* effective_environment = park_base × weather_mod × …
|
||||
*
|
||||
* Weather slots in next order by multiplying, with no rearchitecting. An
|
||||
* additive nudge could not compose: "+3% HR" and "+8% for wind out" do not
|
||||
* combine correctly, while 1.03 × 1.08 does.
|
||||
*
|
||||
* ── THE METHOD ───────────────────────────────────────────────────────────
|
||||
* The classic home/away construction, which controls for team quality: a park's
|
||||
* factor is the rate in games PLAYED THERE against the same teams' rate in
|
||||
* their games elsewhere. Computed per season, then combined with RECENCY
|
||||
* WEIGHTS across seasons — but only within the CURRENT REGIME.
|
||||
*
|
||||
* ── STRUCTURAL BREAKS ────────────────────────────────────────────────────
|
||||
* Parks change: the Coors humidor, moved walls, new dimensions. Blending
|
||||
* pre- and post-change seasons produces a number that describes a stadium that
|
||||
* no longer exists. `detectRegime` finds the most recent step and uses ONLY
|
||||
* post-break seasons — and if that leaves too few games, the factor is
|
||||
* HONEST-ABSENT rather than confidently wrong.
|
||||
*
|
||||
* ── HONEST STATES (three, deliberately distinct) ─────────────────────────
|
||||
* absent thin sample or unknown venue → adjust NOTHING
|
||||
* present stable factor → adjust
|
||||
* weather_na a DOME. The park factor still applies (a dome has a real run
|
||||
* environment); the venue is simply flagged 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 weather indoors.
|
||||
*/
|
||||
|
||||
/** Minimum HOME games in the current regime before a factor is emitted at all.
|
||||
* ~81 home games per season, so 150 ≈ two full seasons of the current park. */
|
||||
const MIN_GAMES = Number(process.env.PARK_MIN_GAMES) || 150;
|
||||
/** Fine conditioning (park × handedness, park × batted-ball) needs far more
|
||||
* than the coarse factor: a sliver overfits. Absent until it clears this. */
|
||||
const MIN_GAMES_FINE = Number(process.env.PARK_MIN_GAMES_FINE) || 400;
|
||||
/** A season whose factor deviates this far from the prior-regime mean is a
|
||||
* candidate structural break — confirmed only if the NEXT season agrees. */
|
||||
const BREAK_THRESHOLD = Number(process.env.PARK_BREAK_THRESHOLD) || 0.15;
|
||||
/** Recency weights, newest first. A five-year-old park is still the same
|
||||
* building, but the current configuration deserves more weight. */
|
||||
const RECENCY = [1.0, 0.75, 0.55, 0.4, 0.3];
|
||||
/** Regression to the mean: a park with exactly MIN_GAMES gets pulled halfway
|
||||
* to neutral; a park with many seasons barely moves. Prevents a two-season
|
||||
* sample from asserting a Coors-sized effect. */
|
||||
const REGRESSION_GAMES = Number(process.env.PARK_REGRESSION_GAMES) || 300;
|
||||
|
||||
/** Domes + retractable roofs. A fact about buildings, not a derived statistic —
|
||||
* and the flag exists so WEATHER does nothing here, not so park does. */
|
||||
const DOME_VENUES = Object.freeze(new Set([
|
||||
'Tropicana Field', 'Rogers Centre', 'Chase Field', 'Minute Maid Park',
|
||||
'Daikin Park', 'American Family Field', 'Globe Life Field', 'loanDepot park',
|
||||
'T-Mobile Park', 'Tokyo Dome',
|
||||
]));
|
||||
|
||||
const num = (v) => {
|
||||
if (v == null || v === '') return null;
|
||||
const n = typeof v === 'number' ? v : Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* tallyBySeason(games) — PURE. Reduces raw game rows into per-venue,
|
||||
* per-season home/away tallies for each team.
|
||||
*
|
||||
* game: { season, venue_id, venue_name, home_team, away_team, home_runs,
|
||||
* away_runs, home_hr, away_hr }
|
||||
*/
|
||||
function tallyBySeason(games) {
|
||||
const venues = new Map(); // venueKey -> season -> { g, runs, hr }
|
||||
const teamAway = new Map(); // season -> team -> { g, runs, hr }
|
||||
|
||||
const bumpTeamAway = (season, team, runs, hr) => {
|
||||
if (!teamAway.has(season)) teamAway.set(season, new Map());
|
||||
const m = teamAway.get(season);
|
||||
const t = m.get(team) || { g: 0, runs: 0, hr: 0 };
|
||||
t.g += 1; t.runs += runs; t.hr += hr;
|
||||
m.set(team, t);
|
||||
};
|
||||
|
||||
for (const g of games || []) {
|
||||
const season = String(g.season);
|
||||
const vid = g.venue_id != null ? String(g.venue_id) : g.venue_name;
|
||||
if (!vid) continue;
|
||||
const hr = (num(g.home_runs) ?? 0) + (num(g.away_runs) ?? 0);
|
||||
const hrs = (num(g.home_hr) ?? 0) + (num(g.away_hr) ?? 0);
|
||||
|
||||
if (!venues.has(vid)) venues.set(vid, { name: g.venue_name, seasons: new Map() });
|
||||
const v = venues.get(vid);
|
||||
const s = v.seasons.get(season) || { g: 0, runs: 0, hr: 0, teams: new Set() };
|
||||
s.g += 1; s.runs += hr; s.hr += hrs;
|
||||
if (g.home_team) s.teams.add(g.home_team);
|
||||
v.seasons.set(season, s);
|
||||
|
||||
// The AWAY team's road game — the control side of the comparison.
|
||||
if (g.away_team) bumpTeamAway(season, g.away_team, hr, hrs);
|
||||
}
|
||||
return { venues, teamAway };
|
||||
}
|
||||
|
||||
/**
|
||||
* detectRegime(seasonFactors) — PURE. Returns the first season of the CURRENT
|
||||
* regime. A candidate break needs CONFIRMATION from the following season: one
|
||||
* odd year is noise, two consecutive years on the same side is a new park.
|
||||
*
|
||||
* seasonFactors: [{ season, factor }] ascending by season.
|
||||
*/
|
||||
function detectRegime(seasonFactors) {
|
||||
const rows = (seasonFactors || []).filter((r) => Number.isFinite(r.factor));
|
||||
if (rows.length < 3) return { start: rows.length ? rows[0].season : null, broke: false };
|
||||
|
||||
let start = rows[0].season;
|
||||
let broke = false;
|
||||
for (let i = 1; i < rows.length - 1; i++) {
|
||||
const prior = rows.slice(0, i).filter((r) => r.season >= start);
|
||||
if (prior.length < 1) continue;
|
||||
const mean = prior.reduce((a, r) => a + r.factor, 0) / prior.length;
|
||||
const dev = rows[i].factor - mean;
|
||||
if (Math.abs(dev) < BREAK_THRESHOLD) continue;
|
||||
// Confirmation: the NEXT season must sit on the same side of the old mean.
|
||||
const nextDev = rows[i + 1].factor - mean;
|
||||
if (Math.sign(nextDev) === Math.sign(dev) && Math.abs(nextDev) >= BREAK_THRESHOLD * 0.6) {
|
||||
start = rows[i].season;
|
||||
broke = true;
|
||||
}
|
||||
}
|
||||
return { start, broke };
|
||||
}
|
||||
|
||||
/**
|
||||
* deriveParkFactors(games, opts) — PURE. games → { [venueKey]: factor record }.
|
||||
*
|
||||
* Each record: { venue, hr_base, run_base, games, seasons, regime_start,
|
||||
* regime_broke, weather_na, state, fine_available }
|
||||
* `*_base` are MULTIPLIERS around 1.0, ready to be composed with weather.
|
||||
*/
|
||||
function deriveParkFactors(games, opts = {}) {
|
||||
const minGames = opts.minGames || MIN_GAMES;
|
||||
const { venues, teamAway } = tallyBySeason(games);
|
||||
const out = {};
|
||||
|
||||
// League baseline per season = the average home game, so a park factor is
|
||||
// "relative to a typical park that year" rather than to an absolute.
|
||||
const leagueBySeason = new Map();
|
||||
for (const v of venues.values()) {
|
||||
for (const [season, s] of v.seasons) {
|
||||
const l = leagueBySeason.get(season) || { g: 0, runs: 0, hr: 0 };
|
||||
l.g += s.g; l.runs += s.runs; l.hr += s.hr;
|
||||
leagueBySeason.set(season, l);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [vid, v] of venues) {
|
||||
const seasonRows = [...v.seasons.entries()]
|
||||
.map(([season, s]) => {
|
||||
const l = leagueBySeason.get(season);
|
||||
if (!l || !l.g || !s.g) return null;
|
||||
const lgRuns = l.runs / l.g;
|
||||
const lgHr = l.hr / l.g;
|
||||
return {
|
||||
season,
|
||||
games: s.g,
|
||||
run_factor: lgRuns > 0 ? (s.runs / s.g) / lgRuns : null,
|
||||
hr_factor: lgHr > 0 ? (s.hr / s.g) / lgHr : null,
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => (a.season < b.season ? -1 : 1));
|
||||
|
||||
const regime = detectRegime(seasonRows.map((r) => ({ season: r.season, factor: r.hr_factor })));
|
||||
const current = seasonRows.filter((r) => r.season >= regime.start);
|
||||
const totalGames = current.reduce((a, r) => a + r.games, 0);
|
||||
|
||||
const weatherNa = DOME_VENUES.has(v.name);
|
||||
|
||||
if (totalGames < minGames) {
|
||||
out[vid] = {
|
||||
venue: v.name, venue_id: vid,
|
||||
hr_base: null, run_base: null,
|
||||
games: totalGames, seasons: current.length,
|
||||
regime_start: regime.start, regime_broke: regime.broke,
|
||||
weather_na: weatherNa,
|
||||
// ABSENT is not the same as N/A: absent means we will not adjust at all.
|
||||
state: 'absent',
|
||||
reason: `only ${totalGames} games in the current regime (floor ${minGames})`,
|
||||
fine_available: false,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
// Recency-weighted mean within the regime, newest season heaviest.
|
||||
const desc = [...current].reverse();
|
||||
const wsum = (pick) => {
|
||||
let n = 0; let d = 0;
|
||||
desc.forEach((r, i) => {
|
||||
const val = pick(r);
|
||||
if (val == null) return;
|
||||
const w = (RECENCY[i] ?? 0.2) * r.games;
|
||||
n += val * w; d += w;
|
||||
});
|
||||
return d > 0 ? n / d : null;
|
||||
};
|
||||
|
||||
const rawHr = wsum((r) => r.hr_factor);
|
||||
const rawRun = wsum((r) => r.run_factor);
|
||||
|
||||
// Regress toward neutral by sample size — a two-season park should not
|
||||
// assert a Coors-sized coefficient.
|
||||
const shrink = totalGames / (totalGames + REGRESSION_GAMES);
|
||||
const reg = (x) => (x == null ? null : Math.round((1 + (x - 1) * shrink) * 1000) / 1000);
|
||||
|
||||
out[vid] = {
|
||||
venue: v.name, venue_id: vid,
|
||||
hr_base: reg(rawHr),
|
||||
run_base: reg(rawRun),
|
||||
hr_raw: rawHr == null ? null : Math.round(rawHr * 1000) / 1000,
|
||||
run_raw: rawRun == null ? null : Math.round(rawRun * 1000) / 1000,
|
||||
games: totalGames, seasons: current.length,
|
||||
regime_start: regime.start, regime_broke: regime.broke,
|
||||
weather_na: weatherNa,
|
||||
state: 'present',
|
||||
reason: null,
|
||||
// Fine conditioning stays ABSENT until it earns its own, larger floor.
|
||||
fine_available: totalGames >= MIN_GAMES_FINE,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Which base a stat keys off. Absent = park says nothing about this stat. */
|
||||
const STAT_BASE = Object.freeze({
|
||||
home_runs: 'hr_base', home_runs_allowed: 'hr_base', total_bases: 'hr_base',
|
||||
runs: 'run_base', rbi: 'run_base', earned_runs: 'run_base', hits: 'run_base',
|
||||
hits_allowed: 'run_base',
|
||||
});
|
||||
|
||||
/**
|
||||
* parkMultiplier({ factor, statType, role }) — the COMPOSABLE coefficient for a
|
||||
* specific prop. Returns 1.0 (a no-op under multiplication) whenever the park
|
||||
* has nothing to say.
|
||||
*
|
||||
* DIRECTIONAL BY PROP-OWNER: Coors inflates home runs. For the HITTER's HR prop
|
||||
* that is UP; for the PITCHER's home-runs-allowed prop at the same park it is
|
||||
* ALSO up — the stat itself already encodes whose side it is. The sign lives in
|
||||
* the STAT, not in the park, which is why `home_runs` and `home_runs_allowed`
|
||||
* both key off `hr_base` in the same direction.
|
||||
*/
|
||||
function parkMultiplier({ factor, statType } = {}) {
|
||||
if (!factor || factor.state !== 'present') return 1;
|
||||
const key = STAT_BASE[String(statType || '').toLowerCase()];
|
||||
if (!key) return 1;
|
||||
const base = num(factor[key]);
|
||||
return base == null || base <= 0 ? 1 : base;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
deriveParkFactors,
|
||||
detectRegime,
|
||||
tallyBySeason,
|
||||
parkMultiplier,
|
||||
STAT_BASE,
|
||||
DOME_VENUES,
|
||||
MIN_GAMES,
|
||||
MIN_GAMES_FINE,
|
||||
BREAK_THRESHOLD,
|
||||
RECENCY,
|
||||
REGRESSION_GAMES,
|
||||
};
|
||||
@@ -26,7 +26,7 @@ const THIN = axes.classifyPlayer({ role: 'batter', sample_pa: 12, barrel_pct: 30
|
||||
|
||||
describe('identical where there is no signal — the clean-experiment property', () => {
|
||||
it.each([
|
||||
['unremarkable player', BELL, 'no_distinctive_axis_for_stat'],
|
||||
['unremarkable player', BELL, 'no_distinctive_axis_or_environment'],
|
||||
['thin sample', THIN, 'archetype_absent_or_thin'],
|
||||
['no classification', null, 'archetype_absent_or_thin'],
|
||||
])('%s → challenger === champion, exactly', (_n, cls, reason) => {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/* ============================================================
|
||||
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);
|
||||
});
|
||||
});
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user