2026_MLB_Model / models /rolling_form_model.py
Syntrex's picture
Audit-confirmed fixes: matchup confidence blend + platoon unknown handling
95e27f5
raw
history blame
25.9 kB
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,
}