diff --git a/data/calibration_params.json b/data/calibration_params.json index a768a29..996375f 100644 --- a/data/calibration_params.json +++ b/data/calibration_params.json @@ -29,5 +29,19 @@ "hits_allowed": 0.4531, "overall": 0.2586 }, - "notes": "prop_min_prob_overrides: per-prop MIN_PROB gates stricter than global 0.57. fantasy_score set to 0.99 (effectively excluded). hits_allowed excluded pending more data (Brier 0.4531)." + "notes": "prop_min_prob_overrides: per-prop MIN_PROB gates stricter than global 0.57. fantasy_score set to 0.99 (effectively excluded). hits_allowed excluded pending more data (Brier 0.4531).", + "league_constants": { + "_comment": "60/40 blend 2026/2025 \u2014 May 15 2026. Source: BBRef totals through May 14.", + "woba": 0.31, + "obp": 0.317, + "slg": 0.397, + "avg": 0.241, + "iso": 0.155, + "babip": 0.292, + "k_pct": 0.224, + "bb_pct": 0.091, + "era": 4.12, + "hr_fb_pct": 0.109, + "o_swing": 0.287 + } } \ No newline at end of file diff --git a/fangraphs_layer.py b/fangraphs_layer.py index 2f4a865..32dbe95 100644 --- a/fangraphs_layer.py +++ b/fangraphs_layer.py @@ -242,25 +242,25 @@ def _pg_save_cache(season: int, batters: dict, pitchers: dict) -> None: "pitcher": { "csw_pct": 0.273, # FG 2026: ~27.3% through game 44 "swstr_pct": 0.110, # FG 2025: ~11.0% (unchanged) - "k_bb_pct": 0.130, # FG 2025: ~13.0% (unchanged) - "xfip": 4.10, # FG 2026: xFIP through game 44 - "siera": 4.10, # FG 2026: SIERA through game 44 - "fip": 4.10, # FG 2026: FIP through game 44 - "hr_fb_pct": 0.119, # FG 2025: 11.9% (confirmed VSiN Feb 2026): ~11.8% (was 0.120) + "k_bb_pct": 0.133, # blended 60/40 2026/2025: 13.3% + "xfip": 4.12, # blended 60/40 2026/2025 + "siera": 4.12, # blended 60/40 2026/2025 + "fip": 4.12, # blended 60/40 2026/2025 + "hr_fb_pct": 0.109, # blended 60/40 2026/2025 (2026 actual ~10.5%) "lob_pct": 0.720, # unchanged - "babip": 0.288, # FG 2025: .289 (confirmed VSiN Feb 2026): ~0.298 (was 0.300) + "babip": 0.292, # blended 60/40 2026/2025 (2026 BBRef: .288) }, "batter": { "wrc_plus": 100.0, # by definition - "woba": 0.308, # FG 2025 (was 0.312) - "iso": 0.156, # FG 2025: elevated power (was 0.158) - "babip": 0.288, # FG 2025: .289 (confirmed VSiN Feb 2026) - "o_swing": 0.316, # FG 2025 (was 0.318) + "woba": 0.310, # blended 60/40 2026/2025 (2026 actual .307) + "iso": 0.155, # blended 60/40 2026/2025 (2026 actual .149) + "babip": 0.292, # blended 60/40 2026/2025 (2026 BBRef: .288) + "o_swing": 0.287, # blended 60/40 2026/2025 (ABS robot ump → fewer chases) "z_contact": 0.848, # FG 2025: ~84.8% (was 0.850) - "hr_fb_pct": 0.119, # FG 2025: 11.9% (confirmed VSiN Feb 2026) - "k_pct": 0.223, # FG 2025: 22.2% (confirmed VSiN Feb 2026) - "bb_pct": 0.087, # FG 2025: 8.4% (confirmed VSiN Feb 2026) - "slg": 0.410, # FG 2025 (was 0.411) + "hr_fb_pct": 0.109, # blended 60/40 2026/2025 (2026 actual ~10.5%) + "k_pct": 0.224, # blended 60/40 2026/2025 (2026 actual 22.2%) + "bb_pct": 0.091, # blended 60/40 2026/2025 (2026 actual 9.5% — ABS effect) + "slg": 0.397, # blended 60/40 2026/2025 (2026 BBRef actual: .389) "xbh_per_game": 0.50, # extra base hits per game — #1 feature for TB (45% importance) }, } diff --git a/lineup_chase_layer.py b/lineup_chase_layer.py index 4e019c7..6dbb67c 100644 --- a/lineup_chase_layer.py +++ b/lineup_chase_layer.py @@ -41,8 +41,8 @@ logger = logging.getLogger(__name__) # League average baselines (2024) -_LEAGUE_O_SWING = 0.316 # FG 2025: O-Swing% (was 0.318 in 2024) -_LEAGUE_K_PCT = 0.228 # FG 2026: 22.8% K% through game 44 +_LEAGUE_O_SWING = 0.287 # blended 60/40 2026/2025 (ABS → fewer chases) +_LEAGUE_K_PCT = 0.224 # blended 60/40 2026/2025 _LEAGUE_Z_CONTACT = 0.850 # Chase thresholds diff --git a/pa_model.py b/pa_model.py index aec4d0c..42c1188 100644 --- a/pa_model.py +++ b/pa_model.py @@ -34,14 +34,16 @@ # ── 2025 MLB league-average PA outcome rates ────────────────────────────── # Source: FanGraphs 2025 season (confirmed via VSiN Feb 2026) LEAGUE_RATES: dict[str, float] = { - "K": 0.228, # strikeout — FG 2026: 22.8% through game 44 - "BB": 0.083, # walk — FG 2026: 8.3% through game 44 - "HBP": 0.011, # hit by pitch - "HR": 0.033, # home run - "3B": 0.004, # triple - "2B": 0.047, # double - "1B": 0.143, # single - "OUT": 0.452, # field out (K+0.005 BB-0.004 net rounded) + # Blended 60/40 2026/2025 — May 15 2026 update + # 2026 actuals: BBRef totals through May 14 (49,542 PA, 30 teams) + "K": 0.224, # strikeout — blended (2026: 22.2%, 2025: 22.6%) + "BB": 0.091, # walk — blended (2026: 9.5% ABS effect, 2025: 8.5%) + "HBP": 0.011, # hit by pitch — unchanged + "HR": 0.030, # home run — blended (2026: 2.8%, 2025: 3.3%) + "3B": 0.004, # triple — unchanged + "2B": 0.044, # double — blended (2026: 4.1%, 2025: 4.7%) + "1B": 0.141, # single — blended (2026: 14.0%, 2025: 14.3%) + "OUT": 0.455, # field out — residual to sum=1.000 } assert abs(sum(LEAGUE_RATES.values()) - 1.0) < 0.01, "LEAGUE_RATES must sum to 1" diff --git a/prop_enrichment_layer.py b/prop_enrichment_layer.py index 7d62bc2..3bdb572 100644 --- a/prop_enrichment_layer.py +++ b/prop_enrichment_layer.py @@ -207,15 +207,15 @@ def _get_fg_pitcher(name: str) -> dict: from fangraphs_layer import get_pitcher # noqa: PLC0415 stats = get_pitcher(name) or {} return { - "k_rate": stats.get("k_pct", stats.get("k_rate", 0.223)), - "bb_rate": stats.get("bb_pct", stats.get("bb_rate", 0.087)), + "k_rate": stats.get("k_pct", stats.get("k_rate", 0.224)), + "bb_rate": stats.get("bb_pct", stats.get("bb_rate", 0.091)), "era": stats.get("xfip", stats.get("fip", 4.06)), "whip": stats.get("whip", 1.28), "csw_pct": stats.get("csw_pct", 0.275), "swstr_pct": stats.get("swstr_pct", 0.110), "xfip": stats.get("xfip", 4.06), "siera": stats.get("siera", 4.06), - "k_bb_pct": stats.get("k_bb_pct", 0.139), + "k_bb_pct": stats.get("k_bb_pct", 0.133), } except Exception: return {} @@ -236,14 +236,14 @@ def _get_fg_batter(name: str) -> dict: stats = get_batter(name) or {} return { "wrc_plus": stats.get("wrc_plus", 100.0), - "woba": stats.get("woba", 0.308), - "iso": stats.get("iso", 0.156), + "woba": stats.get("woba", 0.310), + "iso": stats.get("iso", 0.155), "babip": stats.get("babip", 0.288), - "o_swing": stats.get("o_swing", 0.316), + "o_swing": stats.get("o_swing", 0.287), "z_contact": stats.get("z_contact", 0.850), - "hr_fb_pct": stats.get("hr_fb_pct", 0.105), - "k_pct": stats.get("k_pct", 0.228), - "bb_pct": stats.get("bb_pct", 0.083), + "hr_fb_pct": stats.get("hr_fb_pct", 0.109), + "k_pct": stats.get("k_pct", 0.224), + "bb_pct": stats.get("bb_pct", 0.091), } except Exception: return {} @@ -550,14 +550,14 @@ def _get_form_adj(player_name: str, prop_type: str, hub: dict) -> float: def _get_chase_score(opposing_team: str, hub: dict) -> dict: default = {"k_prob_adjustment": 0.0, "lineup_difficulty": "NEUTRAL", - "avg_chase_rate": 0.316, "_opp_o_swing_avg": 0.316} + "avg_chase_rate": 0.287, "_opp_o_swing_avg": 0.287} if not opposing_team: return default try: from lineup_chase_layer import get_lineup_chase_score # noqa: PLC0415 lineups = hub.get("context", {}).get("lineups", []) result = get_lineup_chase_score(opposing_team, lineups) - result["_opp_o_swing_avg"] = result.get("avg_chase_rate", 0.316) + result["_opp_o_swing_avg"] = result.get("avg_chase_rate", 0.287) return result except Exception as _lcs_err: logger.debug("[Enrich] lineup_chase_score failed: %s", _lcs_err) diff --git a/streak_agent.py b/streak_agent.py index 2b8846e..f9e2e85 100644 --- a/streak_agent.py +++ b/streak_agent.py @@ -575,7 +575,7 @@ def evaluate_props_for_streaks(raw_props: list[dict]) -> list[StreakCandidate]: # K-BB% quality adjustment kbb_delta = (kbb - _LG_KP) * 0.5 # FIP quality: elite <3.50 adds small boost, high >5.00 subtracts - fip_delta = max(-0.04, min(0.04, (4.06 - fip) * 0.015)) + fip_delta = max(-0.04, min(0.04, (4.12 - fip) * 0.015)) adj = csw_delta + kbb_delta + fip_delta adj = max(-0.12, min(0.12, adj)) # cap at ±12pp @@ -587,7 +587,7 @@ def evaluate_props_for_streaks(raw_props: list[dict]) -> list[StreakCandidate]: era = stats.get("era", 4.06) # Below-average ERA/FIP → more likely to go deeper (more outs) # or give up fewer earned runs - quality = (4.06 - ((fip + era) / 2)) * 0.02 + quality = (4.12 - ((fip + era) / 2)) * 0.02 adj = max(-0.08, min(0.08, quality)) if side == "Under": adj = -adj # inverse for Under on ER @@ -597,11 +597,11 @@ def evaluate_props_for_streaks(raw_props: list[dict]) -> list[StreakCandidate]: stats = _fg_bat(cand.player_name) if stats: wrc = stats.get("wrc_plus", _LG_WRC) - woba = stats.get("woba", 0.308) + woba = stats.get("woba", 0.310) xbh = stats.get("xbh_per_game", 0.50) # wRC+ above 100 → above-average hitter wrc_delta = (wrc - 100.0) * 0.001 # 120 wRC+ → +2pp - woba_delta = (woba - 0.308) * 0.15 + woba_delta = (woba - 0.310) * 0.15 adj = wrc_delta + woba_delta if pt in ("total_bases", "hits_runs_rbis"): # extra-base hit rate adds more weight for TB props diff --git a/tasklets.py b/tasklets.py index 5565b75..cae4d25 100644 --- a/tasklets.py +++ b/tasklets.py @@ -589,7 +589,7 @@ def _sigmoid(x: float) -> float: "S": 0.027, # switch: use favourable side } _STAT_DEFAULTS_PLATOON = { - "avg": 0.245, "obp": 0.315, "slg": 0.390, "ops": 0.705, "woba": 0.320 + "avg": 0.241, "obp": 0.317, "slg": 0.397, "ops": 0.714, "woba": 0.310 } @@ -3146,15 +3146,15 @@ def _clamp(v, lo=0.0, hi=1.0): # slot 2: SLG for TB/power props (16% feature importance) if _is_tb_prop: - _slg = float(prop.get("slg", 0.410) or 0.410) + _slg = float(prop.get("slg", 0.397) or 0.397) era = _clamp((_slg - 0.250) / 0.400) # 0.250=0, 0.410=avg(0.40), 0.650=elite(1.0) else: era = _clamp((float(prop.get("babip", 0.288) or 0.288) - 0.200) / 0.200) # slot 3: batter bb_pct (plate discipline) - whip = _clamp(float(prop.get("bb_pct", 0.087) or 0.087) / 0.20) + whip = _clamp(float(prop.get("bb_pct", 0.091) or 0.091) / 0.20) # slot 4: batter K% (inverse contact — higher K = worse contact) - shadow_whiff = _clamp(float(prop.get("k_pct", 0.223) or 0.223) / 0.35) + shadow_whiff = _clamp(float(prop.get("k_pct", 0.224) or 0.224) / 0.35) # Zone integrity multiplier (pitcher K-props only, 1.0 for batters) # Blended with pitcher type cluster: power=+0.05, command=-0.05, neutral=0