from __future__ import annotations """ Batch 12E — Rolling Upcoming Form Layer Computes game-based (5g / 10g) rolling batter and pitcher form metrics and translates them into bounded additive probability adjustments for the UPCOMING game engine only. Design principles: - Returns absolute rolling values; deltas are computed in the adjustment function against stable batter_features / pitcher_row baselines (NOT recomputed from the same narrow window). - Sample-aware: weak windows (< 2 games) produce zero adjustment. - 10g window used as confirmation/dampening of 5g signal. - Pitcher-side adjustments scaled by pitcher_rolling_confidence (match quality × sample availability). - Hard-capped adjustments; no runaway boosts. - Pull/direction metrics SKIPPED (spray_angle not in normalized statcast). - Zone/heart-rate metrics SKIPPED (not in normalized statcast). """ import logging import re import unicodedata from datetime import date, datetime from typing import Any import pandas as pd logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Shared helpers (same barrel definition and utils as batter_trend_model) # --------------------------------------------------------------------------- def _parse_reference_date(reference_date: Any) -> date | None: if reference_date is None: return None if isinstance(reference_date, datetime): return reference_date.date() if isinstance(reference_date, date): return reference_date if isinstance(reference_date, str): for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M:%S"): try: return datetime.strptime(reference_date[:19], fmt).date() except ValueError: continue return None def _percentile(series: pd.Series, q: float) -> float | None: numeric = pd.to_numeric(series, errors="coerce").dropna() if len(numeric) < 5: return None return float(numeric.quantile(q)) def _safe_mean(series: pd.Series) -> float | None: numeric = pd.to_numeric(series, errors="coerce").dropna() if len(numeric) < 5: return None return float(numeric.mean()) def _barrel_rate(launch_speed: pd.Series, launch_angle: pd.Series) -> float | None: valid = pd.DataFrame( { "ls": pd.to_numeric(launch_speed, errors="coerce"), "la": pd.to_numeric(launch_angle, errors="coerce"), } ).dropna() if len(valid) < 5: return None mask = ( ((valid["ls"] >= 98) & (valid["la"].between(26, 30))) | ((valid["ls"] >= 99) & (valid["la"].between(25, 31))) | ((valid["ls"] >= 100) & (valid["la"].between(23, 33))) | ((valid["ls"] >= 102) & (valid["la"].between(20, 35))) ) return float(mask.mean()) def _safe_rate_from_la( launch_angle: pd.Series, lo: float, hi: float | None = None, ) -> float | None: """Fraction of non-null LA rows where lo <= la < hi (or la >= lo if hi is None).""" la = pd.to_numeric(launch_angle, errors="coerce").dropna() if len(la) < 5: return None if hi is None: return float((la >= lo).mean()) return float(((la >= lo) & (la < hi)).mean()) def _n_games(df: pd.DataFrame) -> int: """Count unique game_pk values in a slice; fall back to row-count heuristic.""" if "game_pk" in df.columns: return int(df["game_pk"].nunique()) return len(df) # --------------------------------------------------------------------------- # Game-window helper # --------------------------------------------------------------------------- def _game_window_df(player_df: pd.DataFrame, ref: date, n_games: int) -> pd.DataFrame: """ Return rows for the last `n_games` unique games before `ref` (exclusive). Sorting is by `game_date` descending; unique `game_pk` values are taken in that order. Falls back to the last N×25 rows (rough PA estimate) if `game_pk` is unavailable. """ if player_df.empty: return player_df.iloc[0:0] if "game_date" not in player_df.columns: return player_df.iloc[0:0] game_dates = pd.to_datetime(player_df["game_date"], errors="coerce") cutoff = pd.Timestamp(ref) before_ref = player_df[game_dates < cutoff].copy() if before_ref.empty: return before_ref before_ref["_gd"] = pd.to_datetime(before_ref["game_date"], errors="coerce") if "game_pk" in before_ref.columns: before_ref["_gpk"] = pd.to_numeric(before_ref["game_pk"], errors="coerce") sorted_games = ( before_ref.groupby("_gpk")["_gd"] .max() .sort_values(ascending=False) .head(n_games) .index.tolist() ) result = before_ref[before_ref["_gpk"].isin(sorted_games)].drop( columns=["_gd", "_gpk"], errors="ignore" ) return result # Fallback: no game_pk — take last n_games*25 rows sorted by date fallback = before_ref.sort_values("_gd", ascending=False).head(n_games * 25) return fallback.drop(columns=["_gd"], errors="ignore") # --------------------------------------------------------------------------- # Pitcher name normalization (mirrors pitcher_adjustment.py) # --------------------------------------------------------------------------- def _normalize_name(name: str) -> str: text = str(name or "").strip().lower() text = unicodedata.normalize("NFKD", text) text = "".join(ch for ch in text if not unicodedata.combining(ch)) text = text.replace(",", " ") text = re.sub(r"\s+", " ", text).strip() return text def _name_variants(name: str) -> set[str]: normalized = _normalize_name(name) if not normalized: return set() parts = normalized.split() variants = {normalized} if len(parts) >= 2: first, last = parts[0], parts[-1] middle = " ".join(parts[1:-1]).strip() variants.add(f"{last} {first}".strip()) if middle: variants.add(f"{last} {first} {middle}".strip()) return variants # --------------------------------------------------------------------------- # Empty skeletons # --------------------------------------------------------------------------- _EMPTY_BATTER_ROLL: dict[str, Any] = { "batter_ev_5g": None, "batter_ev_10g": None, "batter_ev90_5g": None, "batter_ev90_10g": None, "batter_hard_hit_rate_5g": None, "batter_hard_hit_rate_10g": None, "batter_barrel_rate_5g": None, "batter_barrel_rate_10g": None, "batter_avg_launch_angle_5g": None, "batter_avg_launch_angle_10g": None, "batter_fb_rate_5g": None, "batter_fb_rate_10g": None, "batter_ld_rate_5g": None, "batter_gb_rate_5g": None, "batter_air_ball_rate_5g": None, "batter_hr_rate_5g": None, "batter_hr_rate_10g": None, # direction metrics deferred (spray_angle not in normalized statcast) "batter_pull_air_rate_5g": None, "batter_pulled_hard_air_rate_5g": None, "batter_pulled_barrel_rate_5g": None, "batter_games_in_window_5g": 0, "batter_games_in_window_10g": 0, "batter_recent_form_available": 0, } _EMPTY_PITCHER_ROLL: dict[str, Any] = { "pitcher_avg_release_speed_5g": None, "pitcher_avg_release_speed_10g": None, "pitcher_avg_release_spin_rate_5g": None, "pitcher_ev_allowed_5g": None, "pitcher_ev_allowed_10g": None, "pitcher_hard_hit_rate_allowed_5g": None, "pitcher_hard_hit_rate_allowed_10g": None, "pitcher_barrel_rate_allowed_5g": None, "pitcher_barrel_rate_allowed_10g": None, "pitcher_avg_launch_angle_allowed_5g": None, "pitcher_fb_rate_allowed_5g": None, "pitcher_ld_rate_allowed_5g": None, "pitcher_gb_rate_allowed_5g": None, "pitcher_hr_allowed_rate_5g": None, "pitcher_hr_allowed_rate_10g": None, "pitcher_games_in_window_5g": 0, "pitcher_games_in_window_10g": 0, "pitcher_recent_form_available": 0, "pitcher_rolling_confidence": 0.0, } # --------------------------------------------------------------------------- # Public API — batter rolling form # --------------------------------------------------------------------------- def build_batter_rolling_form_row( statcast_df: pd.DataFrame, player_name: str, reference_date: Any = None, ) -> dict[str, Any]: """ Compute game-based 5g / 10g rolling form metrics for *player_name*. Returns absolute rolling values only; delta vs. baseline is handled in compute_upcoming_rolling_adjustment() against stable batter_features values. """ if statcast_df is None or statcast_df.empty: return dict(_EMPTY_BATTER_ROLL) ref = _parse_reference_date(reference_date) if ref is None: return dict(_EMPTY_BATTER_ROLL) try: player_df = statcast_df[ statcast_df["player_name"].astype(str) == str(player_name) ].copy() except Exception: return dict(_EMPTY_BATTER_ROLL) if player_df.empty: return dict(_EMPTY_BATTER_ROLL) df5 = _game_window_df(player_df, ref, 5) df10 = _game_window_df(player_df, ref, 10) n5 = _n_games(df5) n10 = _n_games(df10) def _hr_rate(df: pd.DataFrame) -> float | None: if "events" not in df.columns or len(df) < 5: return None events = df["events"].dropna().astype(str) if events.empty: return None return float((events == "home_run").mean()) def _hh_rate(df: pd.DataFrame) -> float | None: ls = pd.to_numeric(df.get("launch_speed", pd.Series(dtype=float)), errors="coerce").dropna() if len(ls) < 5: return None return float((ls >= 95).mean()) ls5 = df5.get("launch_speed", pd.Series(dtype=float)) if not df5.empty else pd.Series(dtype=float) la5 = df5.get("launch_angle", pd.Series(dtype=float)) if not df5.empty else pd.Series(dtype=float) ls10 = df10.get("launch_speed", pd.Series(dtype=float)) if not df10.empty else pd.Series(dtype=float) la10 = df10.get("launch_angle", pd.Series(dtype=float)) if not df10.empty else pd.Series(dtype=float) return { "batter_ev_5g": _safe_mean(ls5), "batter_ev_10g": _safe_mean(ls10), "batter_ev90_5g": _percentile(ls5, 0.90), "batter_ev90_10g": _percentile(ls10, 0.90), "batter_hard_hit_rate_5g": _hh_rate(df5), "batter_hard_hit_rate_10g": _hh_rate(df10), "batter_barrel_rate_5g": _barrel_rate(ls5, la5), "batter_barrel_rate_10g": _barrel_rate(ls10, la10), "batter_avg_launch_angle_5g": _safe_mean(la5), "batter_avg_launch_angle_10g": _safe_mean(la10), "batter_fb_rate_5g": _safe_rate_from_la(la5, 25.0), "batter_fb_rate_10g": _safe_rate_from_la(la10, 25.0), "batter_ld_rate_5g": _safe_rate_from_la(la5, 10.0, 25.0), "batter_gb_rate_5g": _safe_rate_from_la(la5, -90.0, 10.0), "batter_air_ball_rate_5g": _safe_rate_from_la(la5, 10.0), "batter_hr_rate_5g": _hr_rate(df5), "batter_hr_rate_10g": _hr_rate(df10), # direction metrics deferred "batter_pull_air_rate_5g": None, "batter_pulled_hard_air_rate_5g": None, "batter_pulled_barrel_rate_5g": None, "batter_games_in_window_5g": n5, "batter_games_in_window_10g": n10, "batter_recent_form_available": 1 if n5 >= 4 else 0, } # --------------------------------------------------------------------------- # Public API — pitcher rolling form # --------------------------------------------------------------------------- def build_pitcher_rolling_form_row( statcast_df: pd.DataFrame, pitcher_name: str | None = None, pitcher_id: int | None = None, reference_date: Any = None, ) -> dict[str, Any]: """ Compute game-based 5g / 10g rolling form metrics for a pitcher. Follows the same fuzzy-name-match pattern as pitcher_adjustment.py. pitcher_rolling_confidence reflects match quality × sample availability. """ if statcast_df is None or statcast_df.empty: return dict(_EMPTY_PITCHER_ROLL) ref = _parse_reference_date(reference_date) if ref is None: return dict(_EMPTY_PITCHER_ROLL) pitcher_name = str(pitcher_name or "").strip() df = pd.DataFrame() match_quality = "none" # Attempt 1: pitcher ID column (present in some CSVs) if pitcher_id is not None and "pitcher" in statcast_df.columns: try: numeric_ids = pd.to_numeric(statcast_df["pitcher"], errors="coerce") df = statcast_df[numeric_ids == int(pitcher_id)].copy() if not df.empty: match_quality = "id" except Exception: df = pd.DataFrame() # Attempt 2: exact / variant name match on player_name if df.empty and pitcher_name and "player_name" in statcast_df.columns: variants = _name_variants(pitcher_name) normalized_series = statcast_df["player_name"].astype(str).map(_normalize_name) mask = normalized_series.isin(variants) df = statcast_df[mask].copy() if not df.empty: match_quality = "exact" # Attempt 3: loose contains-style match if df.empty and pitcher_name and "player_name" in statcast_df.columns: parts = _normalize_name(pitcher_name).split() if len(parts) >= 2: first, last = parts[0], parts[-1] normalized_series = statcast_df["player_name"].astype(str).map(_normalize_name) loose_mask = normalized_series.apply( lambda n: isinstance(n, str) and first in n and last in n ) df = statcast_df[loose_mask].copy() if not df.empty: match_quality = "loose" if df.empty: return dict(_EMPTY_PITCHER_ROLL) df5 = _game_window_df(df, ref, 5) df10 = _game_window_df(df, ref, 10) n5 = _n_games(df5) n10 = _n_games(df10) # pitcher_rolling_confidence: match quality × sample scale sample_scale_5g = ( 0.0 if n5 < 2 else 0.4 if n5 <= 3 else 0.7 if n5 == 4 else 1.0 ) match_scale = { "id": 1.0, "exact": 1.0, "loose": 0.4, "none": 0.0, }.get(match_quality, 0.0) confidence = round(match_scale * sample_scale_5g, 3) def _hh_rate(df: pd.DataFrame) -> float | None: ls = pd.to_numeric(df.get("launch_speed", pd.Series(dtype=float)), errors="coerce").dropna() if len(ls) < 5: return None return float((ls >= 95).mean()) def _hr_rate_allowed(df: pd.DataFrame) -> float | None: if "events" not in df.columns or len(df) < 5: return None events = df["events"].dropna().astype(str) if events.empty: return None return float((events == "home_run").mean()) ls5 = df5.get("launch_speed", pd.Series(dtype=float)) if not df5.empty else pd.Series(dtype=float) la5 = df5.get("launch_angle", pd.Series(dtype=float)) if not df5.empty else pd.Series(dtype=float) ls10 = df10.get("launch_speed", pd.Series(dtype=float)) if not df10.empty else pd.Series(dtype=float) la10 = df10.get("launch_angle", pd.Series(dtype=float)) if not df10.empty else pd.Series(dtype=float) rs5 = df5.get("release_speed", pd.Series(dtype=float)) if not df5.empty else pd.Series(dtype=float) rs10 = df10.get("release_speed", pd.Series(dtype=float)) if not df10.empty else pd.Series(dtype=float) spin5 = df5.get("release_spin_rate", pd.Series(dtype=float)) if not df5.empty else pd.Series(dtype=float) return { "pitcher_avg_release_speed_5g": _safe_mean(rs5), "pitcher_avg_release_speed_10g": _safe_mean(rs10), "pitcher_avg_release_spin_rate_5g": _safe_mean(spin5), "pitcher_ev_allowed_5g": _safe_mean(ls5), "pitcher_ev_allowed_10g": _safe_mean(ls10), "pitcher_hard_hit_rate_allowed_5g": _hh_rate(df5), "pitcher_hard_hit_rate_allowed_10g": _hh_rate(df10), "pitcher_barrel_rate_allowed_5g": _barrel_rate(ls5, la5), "pitcher_barrel_rate_allowed_10g": _barrel_rate(ls10, la10), "pitcher_avg_launch_angle_allowed_5g": _safe_mean(la5), "pitcher_fb_rate_allowed_5g": _safe_rate_from_la(la5, 25.0), "pitcher_ld_rate_allowed_5g": _safe_rate_from_la(la5, 10.0, 25.0), "pitcher_gb_rate_allowed_5g": _safe_rate_from_la(la5, -90.0, 10.0), "pitcher_hr_allowed_rate_5g": _hr_rate_allowed(df5), "pitcher_hr_allowed_rate_10g": _hr_rate_allowed(df10), "pitcher_games_in_window_5g": n5, "pitcher_games_in_window_10g": n10, "pitcher_recent_form_available": 1 if n5 >= 4 else 0, "pitcher_rolling_confidence": confidence, } # --------------------------------------------------------------------------- # Helpers for adjustment function # --------------------------------------------------------------------------- def _safe_delta(rolling_val: Any, baseline_val: Any) -> float | None: """rolling - baseline; returns None if either is None.""" if rolling_val is None or baseline_val is None: return None try: return float(rolling_val) - float(baseline_val) except (TypeError, ValueError): return None def _clamp(value: float, lo: float, hi: float) -> float: return max(lo, min(hi, value)) def _10g_confirmation_scale(delta_5g: float | None, delta_10g: float | None, threshold: float) -> float: """ 1.0 if 10g confirms 5g direction or is None (neutral). 0.5 if 10g conflicts with 5g direction. """ if delta_5g is None or delta_10g is None: return 1.0 aligned = (delta_5g > threshold) == (delta_10g > threshold) return 1.0 if aligned else 0.5 def _sample_scale(n_games: int) -> float: if n_games < 4: return 0.0 # raised gate to match recent_form_available (n >= 4) if n_games == 4: return 0.7 return 1.0 # --------------------------------------------------------------------------- # Public API — rolling adjustment # --------------------------------------------------------------------------- def compute_upcoming_rolling_adjustment( batter_roll: dict[str, Any], pitcher_roll: dict[str, Any], batter_features: dict[str, Any], pitcher_row: dict[str, Any], ) -> dict[str, Any]: """ Compute bounded additive probability adjustments from rolling form. Deltas are computed against the stable batter_features / pitcher_row baselines (not recomputed from the narrow rolling window). Returns a dict with rolling_hit_adjustment, rolling_hr_adjustment, rolling_tb2p_adjustment, scores, tags (pipe-delimited string), and pitcher_rolling_confidence. """ batter_n5 = int(batter_roll.get("batter_games_in_window_5g") or 0) pitcher_n5 = int(pitcher_roll.get("pitcher_games_in_window_5g") or 0) pitcher_confidence = float(pitcher_roll.get("pitcher_rolling_confidence") or 0.0) batter_scale = _sample_scale(batter_n5) pitcher_n5_scale = _sample_scale(pitcher_n5) pitcher_scale = pitcher_confidence * pitcher_n5_scale # ------------------------------------------------------------------ # Compute deltas vs stable engine baselines # ------------------------------------------------------------------ # Batter deltas ev90_delta_5g = _safe_delta(batter_roll.get("batter_ev90_5g"), batter_features.get("ev90")) ev90_delta_10g = _safe_delta(batter_roll.get("batter_ev90_10g"), batter_features.get("ev90")) barrel_delta_5g = _safe_delta(batter_roll.get("batter_barrel_rate_5g"), batter_features.get("barrel_rate")) barrel_delta_10g = _safe_delta(batter_roll.get("batter_barrel_rate_10g"), batter_features.get("barrel_rate")) hh_delta_5g = _safe_delta(batter_roll.get("batter_hard_hit_rate_5g"), batter_features.get("hard_hit_rate")) la_delta_5g = _safe_delta(batter_roll.get("batter_avg_launch_angle_5g"), batter_features.get("avg_launch_angle")) air_ball_5g = batter_roll.get("batter_air_ball_rate_5g") air_ball_baseline = batter_features.get("air_ball_rate") air_ball_delta_5g = _safe_delta(air_ball_5g, air_ball_baseline) # Pitcher deltas vs stable pitcher_row baselines velo_delta_5g = _safe_delta(pitcher_roll.get("pitcher_avg_release_speed_5g"), pitcher_row.get("avg_release_speed")) ev_allowed_delta_5g = _safe_delta(pitcher_roll.get("pitcher_ev_allowed_5g"), pitcher_row.get("ev_allowed")) ev_allowed_delta_10g = _safe_delta(pitcher_roll.get("pitcher_ev_allowed_10g"), pitcher_row.get("ev_allowed")) barrel_allowed_delta_5g = _safe_delta(pitcher_roll.get("pitcher_barrel_rate_allowed_5g"), pitcher_row.get("barrel_rate_allowed")) barrel_allowed_delta_10g = _safe_delta(pitcher_roll.get("pitcher_barrel_rate_allowed_10g"), pitcher_row.get("barrel_rate_allowed")) hh_allowed_delta_5g = _safe_delta(pitcher_roll.get("pitcher_hard_hit_rate_allowed_5g"), pitcher_row.get("hard_hit_rate_allowed")) # ------------------------------------------------------------------ # Batter form score # ------------------------------------------------------------------ batter_score = 0.0 active_batter_tags: list[str] = [] if ev90_delta_5g is not None: conf_10g = _10g_confirmation_scale(ev90_delta_5g, ev90_delta_10g, 2.0) if ev90_delta_5g > 2.0: batter_score += 0.25 * conf_10g active_batter_tags.append("batter_ev90_surge") elif ev90_delta_5g < -2.0: batter_score -= 0.25 * conf_10g active_batter_tags.append("batter_ev90_decline") if barrel_delta_5g is not None: conf_10g = _10g_confirmation_scale(barrel_delta_5g, barrel_delta_10g, 0.03) if barrel_delta_5g > 0.03: batter_score += 0.40 * conf_10g active_batter_tags.append("batter_barrel_spike") elif barrel_delta_5g < -0.03: batter_score -= 0.40 * conf_10g active_batter_tags.append("batter_barrel_drop") if hh_delta_5g is not None and hh_delta_5g > 0.05: batter_score += 0.20 active_batter_tags.append("batter_hard_hit_rising") if ( la_delta_5g is not None and batter_roll.get("batter_avg_launch_angle_5g") is not None and 20.0 < float(batter_roll["batter_avg_launch_angle_5g"]) < 30.0 and la_delta_5g > 3.0 ): batter_score += 0.20 active_batter_tags.append("batter_la_optimizing") if ( air_ball_5g is not None and air_ball_delta_5g is not None and float(air_ball_5g) > 0.45 and air_ball_delta_5g > 0.05 ): batter_score += 0.15 active_batter_tags.append("batter_air_ball_spike") batter_score = _clamp(batter_score * batter_scale, -1.0, 1.0) # ------------------------------------------------------------------ # Pitcher form score # ------------------------------------------------------------------ pitcher_score = 0.0 active_pitcher_tags: list[str] = [] if velo_delta_5g is not None: if velo_delta_5g < -3.0: pitcher_score += 0.50 # -1.5 and -3.0 contributions combined active_pitcher_tags.append("pitcher_velo_decline_hard") elif velo_delta_5g < -1.5: pitcher_score += 0.30 active_pitcher_tags.append("pitcher_velo_decline") if ev_allowed_delta_5g is not None: conf_10g = _10g_confirmation_scale(ev_allowed_delta_5g, ev_allowed_delta_10g, 2.0) if ev_allowed_delta_5g > 2.0: pitcher_score += 0.30 * conf_10g active_pitcher_tags.append("pitcher_ev_allowed_spiking") if barrel_allowed_delta_5g is not None: conf_10g = _10g_confirmation_scale(barrel_allowed_delta_5g, barrel_allowed_delta_10g, 0.03) if barrel_allowed_delta_5g > 0.03: pitcher_score += 0.40 * conf_10g active_pitcher_tags.append("pitcher_barrel_allowed_spiking") if hh_allowed_delta_5g is not None and hh_allowed_delta_5g > 0.05: pitcher_score += 0.20 active_pitcher_tags.append("pitcher_hard_hit_allowed_rising") # Pitcher sharp: velo up + EV allowed down + barrel allowed down pitcher_sharp = ( velo_delta_5g is not None and velo_delta_5g > 1.5 and ev_allowed_delta_5g is not None and ev_allowed_delta_5g < -2.0 and barrel_allowed_delta_5g is not None and barrel_allowed_delta_5g < -0.03 ) if pitcher_sharp: pitcher_score -= 0.35 active_pitcher_tags.append("pitcher_sharp_recently") pitcher_score = _clamp(pitcher_score * pitcher_scale, -1.0, 1.0) # ------------------------------------------------------------------ # Combined score and adjustments # ------------------------------------------------------------------ combined = _clamp(batter_score + pitcher_score, -1.0, 1.0) rolling_hr_adjustment = _clamp(combined * 0.012, -0.012, 0.012) rolling_hit_adjustment = _clamp(combined * 0.010, -0.010, 0.010) rolling_tb2p_adjustment = _clamp(combined * 0.011, -0.011, 0.011) adjustment_applied = abs(combined) > 0.05 # Compact pipe-delimited reason tags (up to 3 most active) all_tags = (active_batter_tags + active_pitcher_tags)[:3] reason_tags_str = "|".join(all_tags) return { "rolling_hit_adjustment": round(rolling_hit_adjustment, 5), "rolling_hr_adjustment": round(rolling_hr_adjustment, 5), "rolling_tb2p_adjustment": round(rolling_tb2p_adjustment, 5), "rolling_batter_form_score": round(batter_score, 4), "rolling_pitcher_form_score": round(pitcher_score, 4), "rolling_combined_form_score": round(combined, 4), "rolling_adjustment_applied": adjustment_applied, "rolling_adjustment_reason_tags": reason_tags_str, "pitcher_rolling_confidence": pitcher_confidence, }