Spaces:
Running
Running
Audit-confirmed fixes: matchup confidence blend + platoon unknown handling
Browse files- zone_matchup_model + matchup_model: sample_size now populated so
confidence_blend branch is reachable (was always max_fallback before)
- live_fair_simulator_v3: batter_stand/p_throws default to None →
unknown rows get multiplier 1.0 instead of false 0.92 suppression
- Supporting changes: props mapper, recommendation engine, schedule,
scores, bullpen model, pitcher adjustment/state, rolling form,
zone matchup, debug page, logger utility
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- analytics/props_mapper.py +108 -5
- analytics/recommendation_engine.py +13 -12
- app.py +47 -38
- config/settings.py +1 -1
- data/live_prop_odds.py +3 -1
- data/schedule.py +6 -19
- data/scores.py +1 -3
- models/bullpen_model.py +334 -6
- models/live_fair_simulator_v3.py +128 -10
- models/matchup_model.py +1 -0
- models/pitcher_adjustment.py +35 -9
- models/pitcher_live_state.py +8 -6
- models/rolling_form_model.py +4 -8
- models/zone_matchup_model.py +1 -0
- utils/logger.py +10 -0
- visualization/debug_page.py +57 -36
analytics/props_mapper.py
CHANGED
|
@@ -28,6 +28,79 @@ import pandas as pd
|
|
| 28 |
from analytics.no_vig_props import american_to_implied_prob, compute_edge
|
| 29 |
from data.odds_name_map import map_odds_name_to_model_name
|
| 30 |
from models.batter_baseline import build_batter_feature_row, compute_batter_baseline
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
|
| 33 |
def _build_statcast_name_index(statcast_df: pd.DataFrame) -> dict[str, str]:
|
|
@@ -79,6 +152,7 @@ def map_hr_props_to_model(
|
|
| 79 |
props_df: pd.DataFrame,
|
| 80 |
statcast_df: pd.DataFrame,
|
| 81 |
prob_fn: Callable[[str, pd.DataFrame, dict[str, str] | None], tuple[float | None, str]] | None = None,
|
|
|
|
| 82 |
) -> pd.DataFrame:
|
| 83 |
"""
|
| 84 |
Join HR prop rows to model HR probabilities and compute edge.
|
|
@@ -105,10 +179,17 @@ def map_hr_props_to_model(
|
|
| 105 |
# Build name index once for all players
|
| 106 |
name_index = _build_statcast_name_index(statcast_df)
|
| 107 |
|
|
|
|
|
|
|
|
|
|
| 108 |
implied_probs: list[float] = []
|
| 109 |
model_probs: list[float | None] = []
|
| 110 |
sources: list[str] = []
|
| 111 |
edges: list[float | None] = []
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
for _, row in hr_df.iterrows():
|
| 114 |
odds = row.get("odds_american")
|
|
@@ -120,28 +201,50 @@ def map_hr_props_to_model(
|
|
| 120 |
except Exception:
|
| 121 |
implied = None
|
| 122 |
|
| 123 |
-
# Model HR probability
|
| 124 |
if player_name:
|
| 125 |
model_prob, source = _prob_fn(player_name, statcast_df, name_index)
|
| 126 |
else:
|
| 127 |
model_prob, source = None, "unavailable"
|
| 128 |
|
| 129 |
-
#
|
| 130 |
-
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
else:
|
| 133 |
edge = None
|
| 134 |
|
| 135 |
implied_probs.append(implied) # type: ignore[arg-type]
|
| 136 |
-
model_probs.append(
|
| 137 |
sources.append(source)
|
| 138 |
edges.append(edge)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
|
| 140 |
hr_df = hr_df.copy()
|
| 141 |
hr_df["implied_prob"] = implied_probs
|
| 142 |
hr_df["model_hr_prob"] = model_probs
|
| 143 |
hr_df["model_hr_prob_source"] = sources
|
| 144 |
hr_df["edge"] = edges
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
|
| 146 |
# Sort: rows with edge first (highest edge first), then no-edge rows
|
| 147 |
has_edge = hr_df["edge"].notna()
|
|
|
|
| 28 |
from analytics.no_vig_props import american_to_implied_prob, compute_edge
|
| 29 |
from data.odds_name_map import map_odds_name_to_model_name
|
| 30 |
from models.batter_baseline import build_batter_feature_row, compute_batter_baseline
|
| 31 |
+
from models.pitcher_adjustment import build_pitcher_feature_row
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _get_pregame_context_adjustments(
|
| 35 |
+
props_row: Any,
|
| 36 |
+
statcast_df: pd.DataFrame,
|
| 37 |
+
) -> tuple[float, float, bool, str]:
|
| 38 |
+
"""
|
| 39 |
+
Derive pitcher quality + park context adjustments for a pre-game props row.
|
| 40 |
+
Returns (pitcher_adj, park_adj, context_applied, source_detail_str).
|
| 41 |
+
All adjustments are no-op safe — any missing data yields 0.0.
|
| 42 |
+
"""
|
| 43 |
+
pitcher_adj = 0.0
|
| 44 |
+
park_adj = 0.0
|
| 45 |
+
context_applied = False
|
| 46 |
+
source_parts: list[str] = ["baseline"]
|
| 47 |
+
|
| 48 |
+
# --- Pitcher context (only when pitcher_name is explicit in props row) ---
|
| 49 |
+
pitcher_name = None
|
| 50 |
+
for key in ("pitcher_name", "pitcher", "opposing_pitcher"):
|
| 51 |
+
val = props_row.get(key) if hasattr(props_row, "get") else None
|
| 52 |
+
if val and str(val).strip() not in ("", "nan", "None"):
|
| 53 |
+
pitcher_name = str(val).strip()
|
| 54 |
+
break
|
| 55 |
+
|
| 56 |
+
if pitcher_name and not statcast_df.empty:
|
| 57 |
+
try:
|
| 58 |
+
p_row = build_pitcher_feature_row(statcast_df, pitcher_name)
|
| 59 |
+
if p_row.get("sample_size", 0) > 0:
|
| 60 |
+
velo = p_row.get("avg_release_speed")
|
| 61 |
+
ev = p_row.get("ev_allowed")
|
| 62 |
+
barrel = p_row.get("barrel_rate_allowed")
|
| 63 |
+
|
| 64 |
+
quality_score = 0.0
|
| 65 |
+
if velo is not None:
|
| 66 |
+
quality_score += (float(velo) - 93.0) * (-0.15) # higher velo = better pitcher = negative for batter
|
| 67 |
+
if ev is not None:
|
| 68 |
+
quality_score += (float(ev) - 89.0) * 0.08 # higher EV allowed = worse pitcher
|
| 69 |
+
if barrel is not None:
|
| 70 |
+
quality_score += (float(barrel) - 0.07) * 1.0 # higher barrel = worse pitcher
|
| 71 |
+
|
| 72 |
+
pitcher_adj = max(-0.005, min(0.005, quality_score * 0.003))
|
| 73 |
+
if abs(pitcher_adj) > 0.0001:
|
| 74 |
+
context_applied = True
|
| 75 |
+
source_parts.append("pitcher_quality")
|
| 76 |
+
except Exception:
|
| 77 |
+
pass
|
| 78 |
+
|
| 79 |
+
# --- Park context (if venue available) ---
|
| 80 |
+
venue = None
|
| 81 |
+
for key in ("venue", "stadium", "venue_name", "park"):
|
| 82 |
+
val = props_row.get(key) if hasattr(props_row, "get") else None
|
| 83 |
+
if val and str(val).strip() not in ("", "nan", "None"):
|
| 84 |
+
venue = str(val).strip()
|
| 85 |
+
break
|
| 86 |
+
|
| 87 |
+
if venue:
|
| 88 |
+
try:
|
| 89 |
+
from models.environment_model import compute_environment_adjustment
|
| 90 |
+
env = compute_environment_adjustment(
|
| 91 |
+
game_row={"venue": venue, "stadium": venue},
|
| 92 |
+
weather_row=None,
|
| 93 |
+
)
|
| 94 |
+
raw_park = float(env.get("park_hr_boost", 0.0) or 0.0)
|
| 95 |
+
park_adj = max(-0.004, min(0.004, raw_park))
|
| 96 |
+
if abs(park_adj) > 0.0001:
|
| 97 |
+
context_applied = True
|
| 98 |
+
source_parts.append("park")
|
| 99 |
+
except Exception:
|
| 100 |
+
pass
|
| 101 |
+
|
| 102 |
+
source_detail = "+".join(source_parts)
|
| 103 |
+
return pitcher_adj, park_adj, context_applied, source_detail
|
| 104 |
|
| 105 |
|
| 106 |
def _build_statcast_name_index(statcast_df: pd.DataFrame) -> dict[str, str]:
|
|
|
|
| 152 |
props_df: pd.DataFrame,
|
| 153 |
statcast_df: pd.DataFrame,
|
| 154 |
prob_fn: Callable[[str, pd.DataFrame, dict[str, str] | None], tuple[float | None, str]] | None = None,
|
| 155 |
+
pitcher_stats_df: pd.DataFrame | None = None,
|
| 156 |
) -> pd.DataFrame:
|
| 157 |
"""
|
| 158 |
Join HR prop rows to model HR probabilities and compute edge.
|
|
|
|
| 179 |
# Build name index once for all players
|
| 180 |
name_index = _build_statcast_name_index(statcast_df)
|
| 181 |
|
| 182 |
+
# Use pitcher_stats_df if provided, else fall back to statcast_df for pitcher lookups
|
| 183 |
+
_pitcher_df = pitcher_stats_df if pitcher_stats_df is not None else statcast_df
|
| 184 |
+
|
| 185 |
implied_probs: list[float] = []
|
| 186 |
model_probs: list[float | None] = []
|
| 187 |
sources: list[str] = []
|
| 188 |
edges: list[float | None] = []
|
| 189 |
+
pitcher_context_adjs: list[float | None] = []
|
| 190 |
+
park_context_adjs: list[float | None] = []
|
| 191 |
+
context_applied_flags: list[bool] = []
|
| 192 |
+
source_details: list[str] = []
|
| 193 |
|
| 194 |
for _, row in hr_df.iterrows():
|
| 195 |
odds = row.get("odds_american")
|
|
|
|
| 201 |
except Exception:
|
| 202 |
implied = None
|
| 203 |
|
| 204 |
+
# Model HR probability (baseline only)
|
| 205 |
if player_name:
|
| 206 |
model_prob, source = _prob_fn(player_name, statcast_df, name_index)
|
| 207 |
else:
|
| 208 |
model_prob, source = None, "unavailable"
|
| 209 |
|
| 210 |
+
# Pregame context adjustments (pitcher quality + park)
|
| 211 |
+
try:
|
| 212 |
+
pitcher_adj, park_adj, ctx_applied, src_detail = _get_pregame_context_adjustments(
|
| 213 |
+
row, _pitcher_df
|
| 214 |
+
)
|
| 215 |
+
except Exception:
|
| 216 |
+
pitcher_adj, park_adj, ctx_applied, src_detail = 0.0, 0.0, False, "baseline"
|
| 217 |
+
|
| 218 |
+
# Apply context to model prob
|
| 219 |
+
if model_prob is not None and ctx_applied:
|
| 220 |
+
model_prob_adj: float | None = max(0.01, min(0.40, model_prob + pitcher_adj + park_adj))
|
| 221 |
+
else:
|
| 222 |
+
model_prob_adj = model_prob
|
| 223 |
+
|
| 224 |
+
# Edge (uses context-adjusted prob)
|
| 225 |
+
if model_prob_adj is not None and implied is not None:
|
| 226 |
+
edge = compute_edge(model_prob_adj, implied)
|
| 227 |
else:
|
| 228 |
edge = None
|
| 229 |
|
| 230 |
implied_probs.append(implied) # type: ignore[arg-type]
|
| 231 |
+
model_probs.append(model_prob_adj)
|
| 232 |
sources.append(source)
|
| 233 |
edges.append(edge)
|
| 234 |
+
pitcher_context_adjs.append(pitcher_adj if ctx_applied else None)
|
| 235 |
+
park_context_adjs.append(park_adj if ctx_applied else None)
|
| 236 |
+
context_applied_flags.append(ctx_applied)
|
| 237 |
+
source_details.append(src_detail)
|
| 238 |
|
| 239 |
hr_df = hr_df.copy()
|
| 240 |
hr_df["implied_prob"] = implied_probs
|
| 241 |
hr_df["model_hr_prob"] = model_probs
|
| 242 |
hr_df["model_hr_prob_source"] = sources
|
| 243 |
hr_df["edge"] = edges
|
| 244 |
+
hr_df["pregame_pitcher_context_adj"] = pitcher_context_adjs
|
| 245 |
+
hr_df["pregame_park_context_adj"] = park_context_adjs
|
| 246 |
+
hr_df["pregame_context_applied"] = context_applied_flags
|
| 247 |
+
hr_df["model_hr_prob_source_detail"] = source_details
|
| 248 |
|
| 249 |
# Sort: rows with edge first (highest edge first), then no-edge rows
|
| 250 |
has_edge = hr_df["edge"].notna()
|
analytics/recommendation_engine.py
CHANGED
|
@@ -4,10 +4,11 @@ import pandas as pd
|
|
| 4 |
|
| 5 |
from analytics.confidence import compute_confidence
|
| 6 |
from analytics.recommendation_rules import apply_recommendation_rules
|
| 7 |
-
from analytics.no_vig_props import compute_bet_ev, compute_edge
|
| 8 |
from models.fair_odds import probability_to_american
|
| 9 |
from models.live_fair_simulator_v3 import build_upcoming_simulated_rows
|
| 10 |
from models.opportunity_model import estimate_plate_appearance_probability
|
|
|
|
| 11 |
|
| 12 |
def _lineup_distance_from_slot(slot: str) -> int:
|
| 13 |
s = str(slot or "").strip().lower()
|
|
@@ -127,8 +128,8 @@ def build_upcoming_hitter_recommendations(
|
|
| 127 |
try:
|
| 128 |
raw_prob = float(row.get(prob_col))
|
| 129 |
row[prob_col] = min(0.95, max(0.001, raw_prob * expected_pa))
|
| 130 |
-
except Exception:
|
| 131 |
-
|
| 132 |
|
| 133 |
# Recalculate fair odds and edges after probability adjustment
|
| 134 |
if row.get("hit_prob") is not None:
|
|
@@ -140,24 +141,24 @@ def build_upcoming_hitter_recommendations(
|
|
| 140 |
|
| 141 |
try:
|
| 142 |
book_hit_odds = float(row.get("book_hit_odds"))
|
| 143 |
-
row["hit_edge"] = compute_edge(row["hit_prob"],
|
| 144 |
row["hit_bet_ev"] = compute_bet_ev(row["hit_prob"], int(book_hit_odds))
|
| 145 |
-
except Exception:
|
| 146 |
-
|
| 147 |
|
| 148 |
try:
|
| 149 |
book_hr_odds = float(row.get("book_hr_odds"))
|
| 150 |
-
row["hr_edge"] = compute_edge(row["hr_prob"],
|
| 151 |
row["hr_bet_ev"] = compute_bet_ev(row["hr_prob"], int(book_hr_odds))
|
| 152 |
-
except Exception:
|
| 153 |
-
|
| 154 |
|
| 155 |
try:
|
| 156 |
book_tb2p_odds = float(row.get("book_tb2p_odds"))
|
| 157 |
-
row["tb2p_edge"] = compute_edge(row["tb2p_prob"],
|
| 158 |
row["tb2p_bet_ev"] = compute_bet_ev(row["tb2p_prob"], int(book_tb2p_odds))
|
| 159 |
-
except Exception:
|
| 160 |
-
|
| 161 |
|
| 162 |
# Carry diagnostics forward
|
| 163 |
row["lineup_distance"] = lineup_distance
|
|
|
|
| 4 |
|
| 5 |
from analytics.confidence import compute_confidence
|
| 6 |
from analytics.recommendation_rules import apply_recommendation_rules
|
| 7 |
+
from analytics.no_vig_props import american_to_implied_prob, compute_bet_ev, compute_edge
|
| 8 |
from models.fair_odds import probability_to_american
|
| 9 |
from models.live_fair_simulator_v3 import build_upcoming_simulated_rows
|
| 10 |
from models.opportunity_model import estimate_plate_appearance_probability
|
| 11 |
+
from utils.logger import logger
|
| 12 |
|
| 13 |
def _lineup_distance_from_slot(slot: str) -> int:
|
| 14 |
s = str(slot or "").strip().lower()
|
|
|
|
| 128 |
try:
|
| 129 |
raw_prob = float(row.get(prob_col))
|
| 130 |
row[prob_col] = min(0.95, max(0.001, raw_prob * expected_pa))
|
| 131 |
+
except Exception as e:
|
| 132 |
+
logger.warning(f"[prob_opportunity_adjust] failure: {e}", exc_info=True)
|
| 133 |
|
| 134 |
# Recalculate fair odds and edges after probability adjustment
|
| 135 |
if row.get("hit_prob") is not None:
|
|
|
|
| 141 |
|
| 142 |
try:
|
| 143 |
book_hit_odds = float(row.get("book_hit_odds"))
|
| 144 |
+
row["hit_edge"] = compute_edge(row["hit_prob"], american_to_implied_prob(book_hit_odds))
|
| 145 |
row["hit_bet_ev"] = compute_bet_ev(row["hit_prob"], int(book_hit_odds))
|
| 146 |
+
except Exception as e:
|
| 147 |
+
logger.warning(f"[hit_edge_compute] failure: {e}", exc_info=True)
|
| 148 |
|
| 149 |
try:
|
| 150 |
book_hr_odds = float(row.get("book_hr_odds"))
|
| 151 |
+
row["hr_edge"] = compute_edge(row["hr_prob"], american_to_implied_prob(book_hr_odds))
|
| 152 |
row["hr_bet_ev"] = compute_bet_ev(row["hr_prob"], int(book_hr_odds))
|
| 153 |
+
except Exception as e:
|
| 154 |
+
logger.warning(f"[hr_edge_compute] failure: {e}", exc_info=True)
|
| 155 |
|
| 156 |
try:
|
| 157 |
book_tb2p_odds = float(row.get("book_tb2p_odds"))
|
| 158 |
+
row["tb2p_edge"] = compute_edge(row["tb2p_prob"], american_to_implied_prob(book_tb2p_odds))
|
| 159 |
row["tb2p_bet_ev"] = compute_bet_ev(row["tb2p_prob"], int(book_tb2p_odds))
|
| 160 |
+
except Exception as e:
|
| 161 |
+
logger.warning(f"[tb2p_edge_compute] failure: {e}", exc_info=True)
|
| 162 |
|
| 163 |
# Carry diagnostics forward
|
| 164 |
row["lineup_distance"] = lineup_distance
|
app.py
CHANGED
|
@@ -28,6 +28,7 @@ from analytics.batter_audit_metrics import (
|
|
| 28 |
)
|
| 29 |
from analytics.batter_realization import build_batter_realization_rows
|
| 30 |
from analytics.batter_prop_grader import build_batter_prop_outcome_rows_from_audit
|
|
|
|
| 31 |
from analytics.outcome_grader import build_game_outcome_rows_from_scores
|
| 32 |
from analytics.bankroll import bankroll_curve, grade_profit, summary_metrics
|
| 33 |
from analytics.edge import (
|
|
@@ -571,8 +572,8 @@ def load_scores_for_today() -> pd.DataFrame:
|
|
| 571 |
out = df.copy()
|
| 572 |
out["scores_source_date"] = candidate_date
|
| 573 |
candidates.append(out)
|
| 574 |
-
except Exception:
|
| 575 |
-
|
| 576 |
|
| 577 |
for df in candidates:
|
| 578 |
if _scores_df_has_live_or_final_content(df):
|
|
@@ -1067,8 +1068,8 @@ def split_games_for_scoreboard(
|
|
| 1067 |
if not scores.empty:
|
| 1068 |
try:
|
| 1069 |
scores = enrich_live_games_from_feeds(scores)
|
| 1070 |
-
except Exception:
|
| 1071 |
-
|
| 1072 |
|
| 1073 |
if scores is None or scores.empty:
|
| 1074 |
scores = pd.DataFrame()
|
|
@@ -1418,8 +1419,8 @@ def prepare_live_game_for_ui(game: dict) -> dict:
|
|
| 1418 |
try:
|
| 1419 |
enriched = enrich_game_from_live_feed(prepared, feed)
|
| 1420 |
prepared = merge_live_game_row(prepared, enriched)
|
| 1421 |
-
except Exception:
|
| 1422 |
-
|
| 1423 |
|
| 1424 |
# Second: direct fallback extraction from feed so UI fields are guaranteed
|
| 1425 |
live_data = feed.get("liveData", {}) or {}
|
|
@@ -1446,8 +1447,8 @@ def prepare_live_game_for_ui(game: dict) -> dict:
|
|
| 1446 |
lineup = offense.get("battingOrder", []) or []
|
| 1447 |
if isinstance(lineup, list) and len(lineup) >= 3:
|
| 1448 |
three_away_name = _extract_person_name(lineup[2])
|
| 1449 |
-
except Exception:
|
| 1450 |
-
|
| 1451 |
|
| 1452 |
prepared = merge_live_game_row(
|
| 1453 |
prepared,
|
|
@@ -1471,6 +1472,29 @@ def prepare_live_game_for_ui(game: dict) -> dict:
|
|
| 1471 |
},
|
| 1472 |
)
|
| 1473 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1474 |
# Prefer the most recent pitch event that actually has RPM/EXT.
|
| 1475 |
# If none exists, fall back to the most recent event with any pitchData.
|
| 1476 |
play_events = current_play.get("playEvents", []) or []
|
|
@@ -1938,8 +1962,8 @@ def prepare_live_game_for_ui(game: dict) -> dict:
|
|
| 1938 |
except Exception as e:
|
| 1939 |
st.session_state["batter_zone_store_error"] = str(e)
|
| 1940 |
|
| 1941 |
-
except Exception:
|
| 1942 |
-
|
| 1943 |
|
| 1944 |
return prepared
|
| 1945 |
|
|
@@ -2013,8 +2037,8 @@ def render_live_games_with_edge_strips(
|
|
| 2013 |
graded_at=timestamp,
|
| 2014 |
)
|
| 2015 |
insert_recommendation_outcomes(conn, outcome_df)
|
| 2016 |
-
except Exception:
|
| 2017 |
-
|
| 2018 |
|
| 2019 |
def normalize_game_pk(value: object) -> str:
|
| 2020 |
try:
|
|
@@ -2199,8 +2223,8 @@ def build_scores_from_schedule_via_live_feeds(schedule_df: pd.DataFrame) -> pd.D
|
|
| 2199 |
if isinstance(feed, dict) and feed:
|
| 2200 |
game["game_pk"] = game_pk
|
| 2201 |
game = enrich_game_from_live_feed(game, feed)
|
| 2202 |
-
except Exception:
|
| 2203 |
-
|
| 2204 |
|
| 2205 |
rows.append(game)
|
| 2206 |
|
|
@@ -2253,8 +2277,8 @@ def enrich_live_games_from_feeds(scores_df: pd.DataFrame) -> pd.DataFrame:
|
|
| 2253 |
# Preserve original completed-game status text
|
| 2254 |
if is_final_candidate:
|
| 2255 |
game["status"] = original_status if original_status else "Final"
|
| 2256 |
-
except Exception:
|
| 2257 |
-
|
| 2258 |
|
| 2259 |
rows.append(game)
|
| 2260 |
|
|
@@ -2594,31 +2618,16 @@ def render_dashboard() -> None:
|
|
| 2594 |
live_games = recovered_live_games
|
| 2595 |
final_games = recovered_final_games
|
| 2596 |
|
| 2597 |
-
|
| 2598 |
-
|
| 2599 |
-
|
| 2600 |
-
|
| 2601 |
-
|
| 2602 |
-
|
| 2603 |
-
|
| 2604 |
-
key="dashboard_filter",
|
| 2605 |
-
)
|
| 2606 |
-
|
| 2607 |
-
with col2:
|
| 2608 |
-
competition_filter = st.radio(
|
| 2609 |
-
"Competition",
|
| 2610 |
-
["All", "WBC", "MLB"],
|
| 2611 |
-
horizontal=True,
|
| 2612 |
-
key="competition_filter",
|
| 2613 |
-
)
|
| 2614 |
-
|
| 2615 |
live_games = sort_scoreboard_games(normalize_game_cards_df(live_games))
|
| 2616 |
final_games = sort_scoreboard_games(normalize_game_cards_df(final_games))
|
| 2617 |
scheduled_games = sort_scoreboard_games(normalize_game_cards_df(scheduled_games))
|
| 2618 |
-
# Apply competition filter to the actual scoreboard buckets
|
| 2619 |
-
live_games = filter_games_for_competition(live_games, competition_filter)
|
| 2620 |
-
final_games = filter_games_for_competition(final_games, competition_filter)
|
| 2621 |
-
scheduled_games = filter_games_for_competition(scheduled_games, competition_filter)
|
| 2622 |
|
| 2623 |
auto_refresh_live = st.sidebar.checkbox(
|
| 2624 |
"Full Page Auto Refresh Toggle",
|
|
|
|
| 28 |
)
|
| 29 |
from analytics.batter_realization import build_batter_realization_rows
|
| 30 |
from analytics.batter_prop_grader import build_batter_prop_outcome_rows_from_audit
|
| 31 |
+
from utils.logger import logger
|
| 32 |
from analytics.outcome_grader import build_game_outcome_rows_from_scores
|
| 33 |
from analytics.bankroll import bankroll_curve, grade_profit, summary_metrics
|
| 34 |
from analytics.edge import (
|
|
|
|
| 572 |
out = df.copy()
|
| 573 |
out["scores_source_date"] = candidate_date
|
| 574 |
candidates.append(out)
|
| 575 |
+
except Exception as e:
|
| 576 |
+
logger.warning(f"[scores_source_date_enrich] failure: {e}", exc_info=True)
|
| 577 |
|
| 578 |
for df in candidates:
|
| 579 |
if _scores_df_has_live_or_final_content(df):
|
|
|
|
| 1068 |
if not scores.empty:
|
| 1069 |
try:
|
| 1070 |
scores = enrich_live_games_from_feeds(scores)
|
| 1071 |
+
except Exception as e:
|
| 1072 |
+
logger.warning(f"[live_feed_enrich] failure: {e}", exc_info=True)
|
| 1073 |
|
| 1074 |
if scores is None or scores.empty:
|
| 1075 |
scores = pd.DataFrame()
|
|
|
|
| 1419 |
try:
|
| 1420 |
enriched = enrich_game_from_live_feed(prepared, feed)
|
| 1421 |
prepared = merge_live_game_row(prepared, enriched)
|
| 1422 |
+
except Exception as e:
|
| 1423 |
+
logger.warning(f"[live_feed_merge] failure: {e}", exc_info=True)
|
| 1424 |
|
| 1425 |
# Second: direct fallback extraction from feed so UI fields are guaranteed
|
| 1426 |
live_data = feed.get("liveData", {}) or {}
|
|
|
|
| 1447 |
lineup = offense.get("battingOrder", []) or []
|
| 1448 |
if isinstance(lineup, list) and len(lineup) >= 3:
|
| 1449 |
three_away_name = _extract_person_name(lineup[2])
|
| 1450 |
+
except Exception as e:
|
| 1451 |
+
logger.warning(f"[lineup_slot_extract] failure: {e}", exc_info=True)
|
| 1452 |
|
| 1453 |
prepared = merge_live_game_row(
|
| 1454 |
prepared,
|
|
|
|
| 1472 |
},
|
| 1473 |
)
|
| 1474 |
|
| 1475 |
+
# Task 3: Extract batting order lineup slots (fully fallback-safe)
|
| 1476 |
+
try:
|
| 1477 |
+
batting_order = offense.get("battingOrder") or []
|
| 1478 |
+
|
| 1479 |
+
def _find_slot(player_id: object, bo_list: list) -> int | None:
|
| 1480 |
+
if not player_id or not bo_list:
|
| 1481 |
+
return None
|
| 1482 |
+
for i, p in enumerate(bo_list):
|
| 1483 |
+
pid = p.get("id") if isinstance(p, dict) else p
|
| 1484 |
+
if str(pid) == str(player_id):
|
| 1485 |
+
return i + 1 # 1-based slot
|
| 1486 |
+
return None
|
| 1487 |
+
|
| 1488 |
+
on_deck_id = offense.get("onDeck", {}).get("id")
|
| 1489 |
+
in_hole_id = offense.get("inHole", {}).get("id")
|
| 1490 |
+
prepared["on_deck_lineup_slot"] = _find_slot(on_deck_id, batting_order)
|
| 1491 |
+
prepared["in_hole_lineup_slot"] = _find_slot(in_hole_id, batting_order)
|
| 1492 |
+
prepared["three_away_lineup_slot"] = None
|
| 1493 |
+
except Exception:
|
| 1494 |
+
prepared["on_deck_lineup_slot"] = None
|
| 1495 |
+
prepared["in_hole_lineup_slot"] = None
|
| 1496 |
+
prepared["three_away_lineup_slot"] = None
|
| 1497 |
+
|
| 1498 |
# Prefer the most recent pitch event that actually has RPM/EXT.
|
| 1499 |
# If none exists, fall back to the most recent event with any pitchData.
|
| 1500 |
play_events = current_play.get("playEvents", []) or []
|
|
|
|
| 1962 |
except Exception as e:
|
| 1963 |
st.session_state["batter_zone_store_error"] = str(e)
|
| 1964 |
|
| 1965 |
+
except Exception as e:
|
| 1966 |
+
logger.warning(f"[batter_zone_store_init] failure: {e}", exc_info=True)
|
| 1967 |
|
| 1968 |
return prepared
|
| 1969 |
|
|
|
|
| 2037 |
graded_at=timestamp,
|
| 2038 |
)
|
| 2039 |
insert_recommendation_outcomes(conn, outcome_df)
|
| 2040 |
+
except Exception as e:
|
| 2041 |
+
logger.warning(f"[recommendation_outcome_insert] failure: {e}", exc_info=True)
|
| 2042 |
|
| 2043 |
def normalize_game_pk(value: object) -> str:
|
| 2044 |
try:
|
|
|
|
| 2223 |
if isinstance(feed, dict) and feed:
|
| 2224 |
game["game_pk"] = game_pk
|
| 2225 |
game = enrich_game_from_live_feed(game, feed)
|
| 2226 |
+
except Exception as e:
|
| 2227 |
+
logger.warning(f"[feed_cache_load] failure: {e}", exc_info=True)
|
| 2228 |
|
| 2229 |
rows.append(game)
|
| 2230 |
|
|
|
|
| 2277 |
# Preserve original completed-game status text
|
| 2278 |
if is_final_candidate:
|
| 2279 |
game["status"] = original_status if original_status else "Final"
|
| 2280 |
+
except Exception as e:
|
| 2281 |
+
logger.warning(f"[game_status_preserve] failure: {e}", exc_info=True)
|
| 2282 |
|
| 2283 |
rows.append(game)
|
| 2284 |
|
|
|
|
| 2618 |
live_games = recovered_live_games
|
| 2619 |
final_games = recovered_final_games
|
| 2620 |
|
| 2621 |
+
filter_option = st.radio(
|
| 2622 |
+
"Game Status",
|
| 2623 |
+
["All", "Live", "Final", "Scheduled"],
|
| 2624 |
+
horizontal=True,
|
| 2625 |
+
key="dashboard_filter",
|
| 2626 |
+
)
|
| 2627 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2628 |
live_games = sort_scoreboard_games(normalize_game_cards_df(live_games))
|
| 2629 |
final_games = sort_scoreboard_games(normalize_game_cards_df(final_games))
|
| 2630 |
scheduled_games = sort_scoreboard_games(normalize_game_cards_df(scheduled_games))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2631 |
|
| 2632 |
auto_refresh_live = st.sidebar.checkbox(
|
| 2633 |
"Full Page Auto Refresh Toggle",
|
config/settings.py
CHANGED
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
import os
|
| 4 |
|
| 5 |
-
APP_TITLE = "
|
| 6 |
REFRESH_TTL_SECONDS = 30
|
| 7 |
|
| 8 |
LIVE_FEED_TTL_SECONDS = 5
|
|
|
|
| 2 |
|
| 3 |
import os
|
| 4 |
|
| 5 |
+
APP_TITLE = "Kasper"
|
| 6 |
REFRESH_TTL_SECONDS = 30
|
| 7 |
|
| 8 |
LIVE_FEED_TTL_SECONDS = 5
|
data/live_prop_odds.py
CHANGED
|
@@ -5,6 +5,7 @@ import pandas as pd
|
|
| 5 |
from config.settings import ENABLE_ENTERPRISE_PROVIDER
|
| 6 |
from data.provider_enterprise import EnterpriseMarketProvider
|
| 7 |
from data.provider_theoddsapi import TheOddsAPIProvider
|
|
|
|
| 8 |
|
| 9 |
|
| 10 |
def normalize_prop_odds(raw_df: pd.DataFrame) -> pd.DataFrame:
|
|
@@ -85,7 +86,8 @@ def fetch_all_upcoming_hr_props(
|
|
| 85 |
df = fetch_fn(sportsbooks=sportsbooks)
|
| 86 |
if not df.empty:
|
| 87 |
frames.append(df)
|
| 88 |
-
except Exception:
|
|
|
|
| 89 |
continue
|
| 90 |
|
| 91 |
if not frames:
|
|
|
|
| 5 |
from config.settings import ENABLE_ENTERPRISE_PROVIDER
|
| 6 |
from data.provider_enterprise import EnterpriseMarketProvider
|
| 7 |
from data.provider_theoddsapi import TheOddsAPIProvider
|
| 8 |
+
from utils.logger import logger
|
| 9 |
|
| 10 |
|
| 11 |
def normalize_prop_odds(raw_df: pd.DataFrame) -> pd.DataFrame:
|
|
|
|
| 86 |
df = fetch_fn(sportsbooks=sportsbooks)
|
| 87 |
if not df.empty:
|
| 88 |
frames.append(df)
|
| 89 |
+
except Exception as e:
|
| 90 |
+
logger.warning(f"[odds_provider_fetch] failure: {e}", exc_info=True)
|
| 91 |
continue
|
| 92 |
|
| 93 |
if not frames:
|
data/schedule.py
CHANGED
|
@@ -7,6 +7,8 @@ from typing import Any
|
|
| 7 |
import pandas as pd
|
| 8 |
import requests
|
| 9 |
|
|
|
|
|
|
|
| 10 |
WBC_SCHEDULE_URL_TEMPLATE = "https://www.mlb.com/world-baseball-classic/schedule/{date_str}"
|
| 11 |
SCHEDULE_API_URL = "https://statsapi.mlb.com/api/v1/schedule"
|
| 12 |
|
|
@@ -282,25 +284,10 @@ def _fetch_mlb_schedule_for_date(date_str: str) -> pd.DataFrame:
|
|
| 282 |
|
| 283 |
|
| 284 |
def fetch_schedule_for_date(date_str: str) -> pd.DataFrame:
|
| 285 |
-
parts: list[pd.DataFrame] = []
|
| 286 |
-
|
| 287 |
-
try:
|
| 288 |
-
wbc_df = _fetch_wbc_schedule_for_date(date_str)
|
| 289 |
-
if wbc_df is not None and not wbc_df.empty:
|
| 290 |
-
parts.append(wbc_df)
|
| 291 |
-
except Exception:
|
| 292 |
-
pass
|
| 293 |
-
|
| 294 |
try:
|
| 295 |
mlb_df = _fetch_mlb_schedule_for_date(date_str)
|
| 296 |
if mlb_df is not None and not mlb_df.empty:
|
| 297 |
-
|
| 298 |
-
except Exception:
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
if not parts:
|
| 302 |
-
return pd.DataFrame()
|
| 303 |
-
|
| 304 |
-
df = pd.concat(parts, ignore_index=True)
|
| 305 |
-
df = df.drop_duplicates(subset=["game_pk", "away_team", "home_team"], keep="last")
|
| 306 |
-
return df.reset_index(drop=True)
|
|
|
|
| 7 |
import pandas as pd
|
| 8 |
import requests
|
| 9 |
|
| 10 |
+
from utils.logger import logger
|
| 11 |
+
|
| 12 |
WBC_SCHEDULE_URL_TEMPLATE = "https://www.mlb.com/world-baseball-classic/schedule/{date_str}"
|
| 13 |
SCHEDULE_API_URL = "https://statsapi.mlb.com/api/v1/schedule"
|
| 14 |
|
|
|
|
| 284 |
|
| 285 |
|
| 286 |
def fetch_schedule_for_date(date_str: str) -> pd.DataFrame:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
try:
|
| 288 |
mlb_df = _fetch_mlb_schedule_for_date(date_str)
|
| 289 |
if mlb_df is not None and not mlb_df.empty:
|
| 290 |
+
return mlb_df
|
| 291 |
+
except Exception as e:
|
| 292 |
+
logger.warning(f"[schedule_fetch] failure: {e}", exc_info=True)
|
| 293 |
+
return pd.DataFrame()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
data/scores.py
CHANGED
|
@@ -13,9 +13,7 @@ HEADERS = {
|
|
| 13 |
|
| 14 |
SCORES_API_URL = "https://statsapi.mlb.com/api/v1/schedule"
|
| 15 |
|
| 16 |
-
|
| 17 |
-
# 1 = MLB
|
| 18 |
-
SPORT_IDS = [51, 1]
|
| 19 |
|
| 20 |
TEAM_NORMALIZATION = {
|
| 21 |
"Chinese Taipei": "Chinese Taipei",
|
|
|
|
| 13 |
|
| 14 |
SCORES_API_URL = "https://statsapi.mlb.com/api/v1/schedule"
|
| 15 |
|
| 16 |
+
SPORT_IDS = [1] # MLB only
|
|
|
|
|
|
|
| 17 |
|
| 18 |
TEAM_NORMALIZATION = {
|
| 19 |
"Chinese Taipei": "Chinese Taipei",
|
models/bullpen_model.py
CHANGED
|
@@ -1,9 +1,32 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
from typing import Any
|
| 4 |
|
|
|
|
| 5 |
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
def _safe_int(value: Any, default: int = 0) -> int:
|
| 8 |
try:
|
| 9 |
if value is None:
|
|
@@ -42,6 +65,41 @@ def build_bullpen_context(game_row: dict[str, Any], pitcher_row: dict[str, Any])
|
|
| 42 |
score_diff_abs = abs(away_score - home_score)
|
| 43 |
high_leverage = inning >= 7 and score_diff_abs <= 3
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
return {
|
| 46 |
"pitcher_name": pitcher_name,
|
| 47 |
"inning": inning,
|
|
@@ -55,9 +113,253 @@ def build_bullpen_context(game_row: dict[str, Any], pitcher_row: dict[str, Any])
|
|
| 55 |
"hard_hit_rate_allowed": hard_hit_rate_allowed,
|
| 56 |
"score_diff_abs": score_diff_abs,
|
| 57 |
"high_leverage": high_leverage,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
}
|
| 59 |
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
def compute_bullpen_adjustment(context: dict[str, Any]) -> dict[str, Any]:
|
| 62 |
"""
|
| 63 |
Bullpen model v2:
|
|
@@ -143,10 +445,6 @@ def compute_bullpen_adjustment(context: dict[str, Any]) -> dict[str, Any]:
|
|
| 143 |
|
| 144 |
bullpen_entry_prob = 1.0 - starter_stays_next_batter_prob
|
| 145 |
|
| 146 |
-
# v2 assumption:
|
| 147 |
-
# bullpen entry reduces certainty and can modestly improve or worsen outcomes.
|
| 148 |
-
# For v1-v2, we bias toward a slight suppression of power in true late leverage
|
| 149 |
-
# because quality relievers are more likely, but increase variance for hits/TB.
|
| 150 |
bullpen_risk_adj_hit = 0.0
|
| 151 |
bullpen_risk_adj_hr = 0.0
|
| 152 |
bullpen_risk_adj_tb2p = 0.0
|
|
@@ -168,6 +466,25 @@ def compute_bullpen_adjustment(context: dict[str, Any]) -> dict[str, Any]:
|
|
| 168 |
bullpen_risk_adj_tb2p += bullpen_entry_prob * 0.006
|
| 169 |
bullpen_risk_adj_hr += bullpen_entry_prob * 0.002
|
| 170 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
return {
|
| 172 |
"starter_stays_next_batter_prob": starter_stays_next_batter_prob,
|
| 173 |
"starter_stays_next_inning_prob": starter_stays_next_inning_prob,
|
|
@@ -176,4 +493,15 @@ def compute_bullpen_adjustment(context: dict[str, Any]) -> dict[str, Any]:
|
|
| 176 |
"bullpen_risk_adj_hr": bullpen_risk_adj_hr,
|
| 177 |
"bullpen_risk_adj_tb2p": bullpen_risk_adj_tb2p,
|
| 178 |
"reason_tags": reason_tags,
|
| 179 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
from datetime import datetime, timedelta
|
| 4 |
from typing import Any
|
| 5 |
|
| 6 |
+
import pandas as pd
|
| 7 |
|
| 8 |
+
|
| 9 |
+
def _parse_game_date(game_row: dict[str, Any]) -> datetime | None:
|
| 10 |
+
for key in ("game_datetime_utc", "game_date", "game_datetime"):
|
| 11 |
+
val = game_row.get(key)
|
| 12 |
+
if val is None:
|
| 13 |
+
continue
|
| 14 |
+
try:
|
| 15 |
+
if isinstance(val, datetime):
|
| 16 |
+
return val
|
| 17 |
+
s = str(val).strip()[:10]
|
| 18 |
+
return datetime.strptime(s, "%Y-%m-%d")
|
| 19 |
+
except Exception:
|
| 20 |
+
continue
|
| 21 |
+
return None
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def build_bullpen_context(
|
| 25 |
+
game_row: dict[str, Any],
|
| 26 |
+
pitcher_row: dict[str, Any],
|
| 27 |
+
statcast_df: pd.DataFrame | None = None,
|
| 28 |
+
upcoming_batter_stands: list[str] | None = None,
|
| 29 |
+
) -> dict[str, Any]:
|
| 30 |
def _safe_int(value: Any, default: int = 0) -> int:
|
| 31 |
try:
|
| 32 |
if value is None:
|
|
|
|
| 65 |
score_diff_abs = abs(away_score - home_score)
|
| 66 |
high_leverage = inning >= 7 and score_diff_abs <= 3
|
| 67 |
|
| 68 |
+
# --- Candidate scoring (Tasks 1A-1E) ---
|
| 69 |
+
candidates: list[dict[str, Any]] = []
|
| 70 |
+
bullpen_selection_mode = "fallback"
|
| 71 |
+
bullpen_availability_applied = False
|
| 72 |
+
|
| 73 |
+
_ref_date = _parse_game_date(game_row)
|
| 74 |
+
if (
|
| 75 |
+
statcast_df is not None
|
| 76 |
+
and not statcast_df.empty
|
| 77 |
+
and "player_name" in statcast_df.columns
|
| 78 |
+
and "game_date" in statcast_df.columns
|
| 79 |
+
and _ref_date is not None
|
| 80 |
+
):
|
| 81 |
+
try:
|
| 82 |
+
candidates, bullpen_selection_mode, bullpen_availability_applied = _score_bullpen_candidates(
|
| 83 |
+
statcast_df=statcast_df,
|
| 84 |
+
game_row=game_row,
|
| 85 |
+
pitcher_name=pitcher_name,
|
| 86 |
+
ref_date=_ref_date,
|
| 87 |
+
inning=inning,
|
| 88 |
+
score_diff_abs=score_diff_abs,
|
| 89 |
+
upcoming_batter_stands=upcoming_batter_stands or [],
|
| 90 |
+
)
|
| 91 |
+
except Exception:
|
| 92 |
+
candidates = []
|
| 93 |
+
bullpen_selection_mode = "fallback"
|
| 94 |
+
bullpen_availability_applied = False
|
| 95 |
+
|
| 96 |
+
top = candidates[0] if candidates else None
|
| 97 |
+
second = candidates[1] if len(candidates) > 1 else None
|
| 98 |
+
third = candidates[2] if len(candidates) > 2 else None
|
| 99 |
+
|
| 100 |
+
summary_parts = [f"{c['name']}({c['availability']:.2f})" for c in candidates[:5]]
|
| 101 |
+
bullpen_candidate_summary = "|".join(summary_parts) if summary_parts else ""
|
| 102 |
+
|
| 103 |
return {
|
| 104 |
"pitcher_name": pitcher_name,
|
| 105 |
"inning": inning,
|
|
|
|
| 113 |
"hard_hit_rate_allowed": hard_hit_rate_allowed,
|
| 114 |
"score_diff_abs": score_diff_abs,
|
| 115 |
"high_leverage": high_leverage,
|
| 116 |
+
# Candidate fields for compute_bullpen_adjustment modulation
|
| 117 |
+
"bullpen_candidates": candidates,
|
| 118 |
+
"bullpen_top_candidate": top["name"] if top else None,
|
| 119 |
+
"bullpen_top_candidate_availability": top["availability"] if top else None,
|
| 120 |
+
"bullpen_top_candidate_handedness_fit": top["handedness_boost"] if top else None,
|
| 121 |
+
"bullpen_top_candidate_role_fit": top["role_fit"] if top else None,
|
| 122 |
+
"bullpen_candidate_summary": bullpen_candidate_summary,
|
| 123 |
+
"bullpen_availability_applied": bullpen_availability_applied,
|
| 124 |
+
"bullpen_candidate_1": top["name"] if top else None,
|
| 125 |
+
"bullpen_candidate_2": second["name"] if second else None,
|
| 126 |
+
"bullpen_candidate_3": third["name"] if third else None,
|
| 127 |
+
"bullpen_selection_mode": bullpen_selection_mode,
|
| 128 |
}
|
| 129 |
|
| 130 |
|
| 131 |
+
def _score_bullpen_candidates(
|
| 132 |
+
statcast_df: pd.DataFrame,
|
| 133 |
+
game_row: dict[str, Any],
|
| 134 |
+
pitcher_name: str,
|
| 135 |
+
ref_date: datetime,
|
| 136 |
+
inning: int,
|
| 137 |
+
score_diff_abs: int,
|
| 138 |
+
upcoming_batter_stands: list[str],
|
| 139 |
+
) -> tuple[list[dict[str, Any]], str, bool]:
|
| 140 |
+
"""Score reliever candidates. Returns (candidates_sorted, mode, applied)."""
|
| 141 |
+
try:
|
| 142 |
+
game_dates = pd.to_datetime(statcast_df["game_date"], errors="coerce")
|
| 143 |
+
cutoff = pd.Timestamp(ref_date - timedelta(days=3))
|
| 144 |
+
recent_mask = game_dates >= cutoff
|
| 145 |
+
recent_df = statcast_df[recent_mask].copy()
|
| 146 |
+
except Exception:
|
| 147 |
+
return [], "fallback", False
|
| 148 |
+
|
| 149 |
+
if recent_df.empty:
|
| 150 |
+
return [], "fallback", False
|
| 151 |
+
|
| 152 |
+
# Attempt team-based filtering
|
| 153 |
+
pool_df, selection_mode = _filter_to_pitching_team(recent_df, game_row, pitcher_name)
|
| 154 |
+
|
| 155 |
+
# Exclude current starter
|
| 156 |
+
pitcher_name_lower = pitcher_name.strip().lower()
|
| 157 |
+
|
| 158 |
+
def _is_current_starter(name: str) -> bool:
|
| 159 |
+
n = name.strip().lower()
|
| 160 |
+
if n == pitcher_name_lower:
|
| 161 |
+
return True
|
| 162 |
+
parts = pitcher_name_lower.split()
|
| 163 |
+
if len(parts) >= 2 and parts[-1] in n and parts[0] in n:
|
| 164 |
+
return True
|
| 165 |
+
return False
|
| 166 |
+
|
| 167 |
+
all_pitchers = pool_df["player_name"].astype(str).unique().tolist()
|
| 168 |
+
candidates_pool = [p for p in all_pitchers if not _is_current_starter(p)]
|
| 169 |
+
|
| 170 |
+
if not candidates_pool:
|
| 171 |
+
return [], selection_mode, False
|
| 172 |
+
|
| 173 |
+
# 1B: Handedness boost direction
|
| 174 |
+
stands = [s for s in upcoming_batter_stands if s in ("L", "R")]
|
| 175 |
+
handedness_boost_lhp = 0.0
|
| 176 |
+
handedness_boost_rhp = 0.0
|
| 177 |
+
if len(stands) >= 2:
|
| 178 |
+
lhb_frac = stands.count("L") / len(stands)
|
| 179 |
+
rhb_frac = stands.count("R") / len(stands)
|
| 180 |
+
if lhb_frac >= 2 / 3:
|
| 181 |
+
handedness_boost_lhp = 0.10
|
| 182 |
+
elif rhb_frac >= 2 / 3:
|
| 183 |
+
handedness_boost_rhp = 0.10
|
| 184 |
+
|
| 185 |
+
# Pull p_throws per pitcher
|
| 186 |
+
pitcher_throws: dict[str, str] = {}
|
| 187 |
+
if "p_throws" in pool_df.columns:
|
| 188 |
+
for pname in candidates_pool:
|
| 189 |
+
prows = pool_df[pool_df["player_name"].astype(str) == pname]
|
| 190 |
+
mode_t = prows["p_throws"].dropna().mode()
|
| 191 |
+
pitcher_throws[pname] = str(mode_t.iloc[0]) if not mode_t.empty else "R"
|
| 192 |
+
else:
|
| 193 |
+
for pname in candidates_pool:
|
| 194 |
+
pitcher_throws[pname] = "R"
|
| 195 |
+
|
| 196 |
+
ref_norm = pd.Timestamp(ref_date).normalize()
|
| 197 |
+
|
| 198 |
+
def _availability_score(pname: str) -> float:
|
| 199 |
+
prows = pool_df[pool_df["player_name"].astype(str) == pname].copy()
|
| 200 |
+
if prows.empty:
|
| 201 |
+
return 0.75
|
| 202 |
+
try:
|
| 203 |
+
pdates = pd.to_datetime(prows["game_date"], errors="coerce").dt.normalize().dropna()
|
| 204 |
+
except Exception:
|
| 205 |
+
return 0.75
|
| 206 |
+
if pdates.empty:
|
| 207 |
+
return 0.75
|
| 208 |
+
|
| 209 |
+
appeared_yesterday = any((ref_norm - d).days == 1 for d in pdates.unique())
|
| 210 |
+
appeared_2ago = any((ref_norm - d).days == 2 for d in pdates.unique())
|
| 211 |
+
appeared_3ago = any((ref_norm - d).days == 3 for d in pdates.unique())
|
| 212 |
+
|
| 213 |
+
if not appeared_yesterday and not appeared_2ago and not appeared_3ago:
|
| 214 |
+
return 1.00 # fresh — no appearance in last 3 days
|
| 215 |
+
if appeared_3ago and not appeared_2ago and not appeared_yesterday:
|
| 216 |
+
return 0.85
|
| 217 |
+
if appeared_2ago and not appeared_yesterday:
|
| 218 |
+
return 0.70
|
| 219 |
+
if appeared_yesterday:
|
| 220 |
+
if appeared_2ago:
|
| 221 |
+
return 0.35 # two straight days
|
| 222 |
+
# Estimate pitch count via row count yesterday
|
| 223 |
+
yesterday_rows = prows[
|
| 224 |
+
(ref_norm - pd.to_datetime(prows["game_date"], errors="coerce").dt.normalize()).abs()
|
| 225 |
+
<= pd.Timedelta(days=1)
|
| 226 |
+
]
|
| 227 |
+
pitch_proxy = len(yesterday_rows)
|
| 228 |
+
return 0.65 if pitch_proxy < 20 else 0.45
|
| 229 |
+
return 0.75
|
| 230 |
+
|
| 231 |
+
def _infer_role(pname: str) -> str:
|
| 232 |
+
prows = pool_df[pool_df["player_name"].astype(str) == pname]
|
| 233 |
+
if prows.empty:
|
| 234 |
+
return "middle_tier"
|
| 235 |
+
try:
|
| 236 |
+
if "inning" not in prows.columns:
|
| 237 |
+
return "middle_tier"
|
| 238 |
+
innings = pd.to_numeric(prows["inning"], errors="coerce").dropna()
|
| 239 |
+
if innings.empty:
|
| 240 |
+
return "middle_tier"
|
| 241 |
+
avg_inning = float(innings.mean())
|
| 242 |
+
if "game_date" in prows.columns:
|
| 243 |
+
avg_rows_per_game = float(prows.groupby("game_date").size().mean())
|
| 244 |
+
else:
|
| 245 |
+
avg_rows_per_game = float(len(prows))
|
| 246 |
+
if avg_inning >= 7.5 and avg_rows_per_game <= 20:
|
| 247 |
+
return "closer_tier"
|
| 248 |
+
elif avg_inning >= 5.5 and avg_rows_per_game <= 40:
|
| 249 |
+
return "setup_tier"
|
| 250 |
+
elif avg_rows_per_game >= 60:
|
| 251 |
+
return "long_relief"
|
| 252 |
+
else:
|
| 253 |
+
return "middle_tier"
|
| 254 |
+
except Exception:
|
| 255 |
+
return "middle_tier"
|
| 256 |
+
|
| 257 |
+
def _role_fit_score(role: str) -> float:
|
| 258 |
+
if inning >= 8 and score_diff_abs <= 2:
|
| 259 |
+
return 1.0 if role == "closer_tier" else (0.7 if role == "setup_tier" else 0.5)
|
| 260 |
+
elif inning >= 6 and score_diff_abs <= 4:
|
| 261 |
+
return 1.0 if role == "setup_tier" else (0.7 if role in ("middle_tier", "closer_tier") else 0.5)
|
| 262 |
+
else:
|
| 263 |
+
return 1.0 if role in ("middle_tier", "long_relief") else 0.7
|
| 264 |
+
|
| 265 |
+
scored: list[dict[str, Any]] = []
|
| 266 |
+
for pname in candidates_pool:
|
| 267 |
+
avail = _availability_score(pname)
|
| 268 |
+
throws = pitcher_throws.get(pname, "R")
|
| 269 |
+
h_boost = (
|
| 270 |
+
handedness_boost_lhp if throws == "L"
|
| 271 |
+
else (handedness_boost_rhp if throws == "R" else 0.0)
|
| 272 |
+
)
|
| 273 |
+
role = _infer_role(pname)
|
| 274 |
+
r_fit = _role_fit_score(role)
|
| 275 |
+
raw_score = avail * (1.0 + h_boost) * r_fit
|
| 276 |
+
scored.append({
|
| 277 |
+
"name": pname,
|
| 278 |
+
"availability": avail,
|
| 279 |
+
"p_throws": throws,
|
| 280 |
+
"handedness_boost": h_boost,
|
| 281 |
+
"role": role,
|
| 282 |
+
"role_fit": r_fit,
|
| 283 |
+
"raw_score": raw_score,
|
| 284 |
+
})
|
| 285 |
+
|
| 286 |
+
if not scored:
|
| 287 |
+
return [], selection_mode, False
|
| 288 |
+
|
| 289 |
+
total_raw = sum(c["raw_score"] for c in scored)
|
| 290 |
+
for c in scored:
|
| 291 |
+
c["usage_prob"] = c["raw_score"] / total_raw if total_raw > 0 else (1.0 / len(scored))
|
| 292 |
+
|
| 293 |
+
scored.sort(key=lambda c: c["raw_score"], reverse=True)
|
| 294 |
+
return scored, selection_mode, True
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
def _filter_to_pitching_team(
|
| 298 |
+
recent_df: pd.DataFrame,
|
| 299 |
+
game_row: dict[str, Any],
|
| 300 |
+
pitcher_name: str,
|
| 301 |
+
) -> tuple[pd.DataFrame, str]:
|
| 302 |
+
"""
|
| 303 |
+
Try to return rows from the same team as the current pitcher.
|
| 304 |
+
Falls back to full recent_df (heuristic mode) on any failure.
|
| 305 |
+
"""
|
| 306 |
+
home_team = str(game_row.get("home_team", "") or "").strip().upper()
|
| 307 |
+
away_team = str(game_row.get("away_team", "") or "").strip().upper()
|
| 308 |
+
|
| 309 |
+
if not home_team and not away_team:
|
| 310 |
+
return recent_df, "heuristic"
|
| 311 |
+
if "home_team" not in recent_df.columns or "away_team" not in recent_df.columns:
|
| 312 |
+
return recent_df, "heuristic"
|
| 313 |
+
|
| 314 |
+
try:
|
| 315 |
+
pitcher_name_lower = pitcher_name.strip().lower()
|
| 316 |
+
pitcher_rows = recent_df[
|
| 317 |
+
recent_df["player_name"].astype(str).str.lower() == pitcher_name_lower
|
| 318 |
+
]
|
| 319 |
+
if pitcher_rows.empty:
|
| 320 |
+
parts = pitcher_name_lower.split()
|
| 321 |
+
if len(parts) >= 2:
|
| 322 |
+
pitcher_rows = recent_df[
|
| 323 |
+
recent_df["player_name"].astype(str).str.lower().str.contains(parts[-1], na=False)
|
| 324 |
+
]
|
| 325 |
+
if pitcher_rows.empty:
|
| 326 |
+
return recent_df, "heuristic"
|
| 327 |
+
|
| 328 |
+
pitcher_home_teams = pitcher_rows["home_team"].astype(str).str.upper().mode()
|
| 329 |
+
pitcher_away_teams = pitcher_rows["away_team"].astype(str).str.upper().mode()
|
| 330 |
+
|
| 331 |
+
pitcher_team = None
|
| 332 |
+
if not pitcher_home_teams.empty and not pitcher_away_teams.empty:
|
| 333 |
+
ht_val = pitcher_home_teams.iloc[0]
|
| 334 |
+
at_val = pitcher_away_teams.iloc[0]
|
| 335 |
+
if ht_val in (home_team, away_team):
|
| 336 |
+
# Pitcher appeared as home team pitcher (they're on that team or pitched against it)
|
| 337 |
+
# Use game_row teams to narrow — prefer the matching team
|
| 338 |
+
if ht_val == home_team:
|
| 339 |
+
pitcher_team = home_team
|
| 340 |
+
elif ht_val == away_team:
|
| 341 |
+
pitcher_team = away_team
|
| 342 |
+
elif at_val in (home_team, away_team):
|
| 343 |
+
if at_val == home_team:
|
| 344 |
+
pitcher_team = home_team
|
| 345 |
+
elif at_val == away_team:
|
| 346 |
+
pitcher_team = away_team
|
| 347 |
+
|
| 348 |
+
if pitcher_team is None:
|
| 349 |
+
return recent_df, "heuristic"
|
| 350 |
+
|
| 351 |
+
team_mask = (
|
| 352 |
+
(recent_df["home_team"].astype(str).str.upper() == pitcher_team)
|
| 353 |
+
| (recent_df["away_team"].astype(str).str.upper() == pitcher_team)
|
| 354 |
+
)
|
| 355 |
+
team_df = recent_df[team_mask]
|
| 356 |
+
if team_df.empty or len(team_df["player_name"].unique()) < 2:
|
| 357 |
+
return recent_df, "heuristic"
|
| 358 |
+
return team_df, "data_driven"
|
| 359 |
+
except Exception:
|
| 360 |
+
return recent_df, "heuristic"
|
| 361 |
+
|
| 362 |
+
|
| 363 |
def compute_bullpen_adjustment(context: dict[str, Any]) -> dict[str, Any]:
|
| 364 |
"""
|
| 365 |
Bullpen model v2:
|
|
|
|
| 445 |
|
| 446 |
bullpen_entry_prob = 1.0 - starter_stays_next_batter_prob
|
| 447 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 448 |
bullpen_risk_adj_hit = 0.0
|
| 449 |
bullpen_risk_adj_hr = 0.0
|
| 450 |
bullpen_risk_adj_tb2p = 0.0
|
|
|
|
| 466 |
bullpen_risk_adj_tb2p += bullpen_entry_prob * 0.006
|
| 467 |
bullpen_risk_adj_hr += bullpen_entry_prob * 0.002
|
| 468 |
|
| 469 |
+
# 1E: Modest risk adj modulation from top candidate quality (±10% max)
|
| 470 |
+
top_avail = context.get("bullpen_top_candidate_availability")
|
| 471 |
+
top_role_fit = context.get("bullpen_top_candidate_role_fit")
|
| 472 |
+
if top_avail is not None:
|
| 473 |
+
try:
|
| 474 |
+
top_avail_f = float(top_avail)
|
| 475 |
+
top_role_f = float(top_role_fit) if top_role_fit is not None else 0.7
|
| 476 |
+
if top_avail_f < 0.50:
|
| 477 |
+
scale = 1.10 # tired relievers — slightly more risk
|
| 478 |
+
elif top_avail_f >= 0.85 and top_role_f >= 1.0:
|
| 479 |
+
scale = 0.90 # fresh quality reliever — slightly less risk
|
| 480 |
+
else:
|
| 481 |
+
scale = 1.0
|
| 482 |
+
bullpen_risk_adj_hit *= scale
|
| 483 |
+
bullpen_risk_adj_hr *= scale
|
| 484 |
+
bullpen_risk_adj_tb2p *= scale
|
| 485 |
+
except Exception:
|
| 486 |
+
pass
|
| 487 |
+
|
| 488 |
return {
|
| 489 |
"starter_stays_next_batter_prob": starter_stays_next_batter_prob,
|
| 490 |
"starter_stays_next_inning_prob": starter_stays_next_inning_prob,
|
|
|
|
| 493 |
"bullpen_risk_adj_hr": bullpen_risk_adj_hr,
|
| 494 |
"bullpen_risk_adj_tb2p": bullpen_risk_adj_tb2p,
|
| 495 |
"reason_tags": reason_tags,
|
| 496 |
+
# Pass through candidate fields for output row
|
| 497 |
+
"bullpen_top_candidate": context.get("bullpen_top_candidate"),
|
| 498 |
+
"bullpen_top_candidate_availability": context.get("bullpen_top_candidate_availability"),
|
| 499 |
+
"bullpen_top_candidate_handedness_fit": context.get("bullpen_top_candidate_handedness_fit"),
|
| 500 |
+
"bullpen_top_candidate_role_fit": context.get("bullpen_top_candidate_role_fit"),
|
| 501 |
+
"bullpen_candidate_summary": context.get("bullpen_candidate_summary"),
|
| 502 |
+
"bullpen_availability_applied": context.get("bullpen_availability_applied", False),
|
| 503 |
+
"bullpen_candidate_1": context.get("bullpen_candidate_1"),
|
| 504 |
+
"bullpen_candidate_2": context.get("bullpen_candidate_2"),
|
| 505 |
+
"bullpen_candidate_3": context.get("bullpen_candidate_3"),
|
| 506 |
+
"bullpen_selection_mode": context.get("bullpen_selection_mode", "fallback"),
|
| 507 |
+
}
|
models/live_fair_simulator_v3.py
CHANGED
|
@@ -78,7 +78,23 @@ def build_upcoming_simulated_rows(
|
|
| 78 |
live_context["game_row"] = game_row
|
| 79 |
context_adj = compute_context_adjustment(live_context)
|
| 80 |
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
bullpen_adj = compute_bullpen_adjustment(bullpen_context)
|
| 83 |
|
| 84 |
# Batch 11: Environment overlay — computed once per game (venue/datetime don't change per batter)
|
|
@@ -364,10 +380,43 @@ def build_upcoming_simulated_rows(
|
|
| 364 |
fz_hit_eff = (family_zone_hit_boost * 0.06) - (family_zone_whiff_risk * 0.02)
|
| 365 |
fz_tb2p_eff = (family_zone_tb2p_boost * 0.06) + (family_zone_hit_boost * 0.02)
|
| 366 |
|
| 367 |
-
#
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 371 |
|
| 372 |
batter_baseline["hr_prob_base"] = min(0.25, max(0.005,
|
| 373 |
float(batter_baseline.get("hr_prob_base", 0.03) or 0.03) + primary_hr))
|
|
@@ -394,18 +443,22 @@ def build_upcoming_simulated_rows(
|
|
| 394 |
arsenal_tb2p_boost = float(arsenal_matchup_adj.get("arsenal_tb2p_boost", 0.0) or 0.0)
|
| 395 |
arsenal_whiff_risk = float(arsenal_matchup_adj.get("arsenal_whiff_risk", 0.0) or 0.0)
|
| 396 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 397 |
batter_baseline["hit_prob_base"] = min(0.55, max(0.05,
|
| 398 |
float(batter_baseline.get("hit_prob_base", 0.15) or 0.15)
|
| 399 |
-
+
|
| 400 |
-
- (arsenal_whiff_risk * 0.02)))
|
| 401 |
|
| 402 |
batter_baseline["hr_prob_base"] = min(0.25, max(0.005,
|
| 403 |
float(batter_baseline.get("hr_prob_base", 0.03) or 0.03)
|
| 404 |
-
+
|
| 405 |
|
| 406 |
batter_baseline["tb2p_prob_base"] = min(0.45, max(0.03,
|
| 407 |
float(batter_baseline.get("tb2p_prob_base", 0.10) or 0.10)
|
| 408 |
-
+
|
| 409 |
|
| 410 |
_snap_after_arsenal_hr = float(batter_baseline.get("hr_prob_base", 0.03) or 0.03)
|
| 411 |
_snap_after_arsenal_hit = float(batter_baseline.get("hit_prob_base", 0.15) or 0.15)
|
|
@@ -589,8 +642,15 @@ def build_upcoming_simulated_rows(
|
|
| 589 |
|
| 590 |
# Batch 13: Opportunity adjustment (multiplicative, upcoming-only)
|
| 591 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 592 |
opp_adj = compute_opportunity_adjustment(
|
| 593 |
-
lineup_slot=
|
| 594 |
team_total=game_row.get("team_total"),
|
| 595 |
pitcher_row=pitcher_row,
|
| 596 |
)
|
|
@@ -725,7 +785,21 @@ def build_upcoming_simulated_rows(
|
|
| 725 |
"starter_stays_next_batter_prob": bullpen_adj["starter_stays_next_batter_prob"],
|
| 726 |
"starter_stays_next_inning_prob": bullpen_adj["starter_stays_next_inning_prob"],
|
| 727 |
"bullpen_entry_prob": bullpen_adj["bullpen_entry_prob"],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 728 |
"reason_tags": reason_tags,
|
|
|
|
|
|
|
|
|
|
| 729 |
"fatigue_score": pitcher_adj.get("fatigue_score"),
|
| 730 |
"degradation_score": pitcher_adj.get("degradation_score"),
|
| 731 |
"trust_live_score": pitcher_adj.get("trust_live_score"),
|
|
@@ -737,6 +811,11 @@ def build_upcoming_simulated_rows(
|
|
| 737 |
"pitch_count": pitcher_adj.get("pitch_count"),
|
| 738 |
"times_through_order": pitcher_adj.get("times_through_order"),
|
| 739 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 740 |
"zone_hr_boost": zone_matchup_adj.get("hr_zone_boost"),
|
| 741 |
"zone_hit_boost": zone_matchup_adj.get("hit_zone_boost"),
|
| 742 |
"zone_tb2p_boost": zone_matchup_adj.get("tb2p_zone_boost"),
|
|
@@ -762,6 +841,14 @@ def build_upcoming_simulated_rows(
|
|
| 762 |
"rolling_pitch_pfx_x_sample_size": pitcher_adj.get("rolling_pitch_pfx_x_sample_size"),
|
| 763 |
"rolling_pitch_pfx_z_sample_size": pitcher_adj.get("rolling_pitch_pfx_z_sample_size"),
|
| 764 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 765 |
"deception_score": trajectory_row.get("deception_score"),
|
| 766 |
"tunnel_score": trajectory_row.get("tunnel_score"),
|
| 767 |
"release_consistency_score": trajectory_row.get("release_consistency_score"),
|
|
@@ -907,6 +994,37 @@ def build_upcoming_simulated_rows(
|
|
| 907 |
"arsenal_drift_score": drift_adj.get("arsenal_drift_score"),
|
| 908 |
"arsenal_reason_tags": drift_adj.get("arsenal_reason_tags"),
|
| 909 |
"arsenal_drift_applied_scale": drift_adj.get("arsenal_drift_applied_scale"),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 910 |
}
|
| 911 |
)
|
| 912 |
|
|
|
|
| 78 |
live_context["game_row"] = game_row
|
| 79 |
context_adj = compute_context_adjustment(live_context)
|
| 80 |
|
| 81 |
+
# Pre-compute upcoming batter stands for bullpen handedness context (Task 1)
|
| 82 |
+
_upcoming_stands: list[str | None] = []
|
| 83 |
+
for _s_lbl, _s_name in slots:
|
| 84 |
+
if _s_name:
|
| 85 |
+
try:
|
| 86 |
+
_s_feats = build_batter_feature_row(statcast_df, _s_name)
|
| 87 |
+
_upcoming_stands.append(_s_feats.get("batter_stand"))
|
| 88 |
+
except Exception:
|
| 89 |
+
_upcoming_stands.append(None)
|
| 90 |
+
else:
|
| 91 |
+
_upcoming_stands.append(None)
|
| 92 |
+
|
| 93 |
+
bullpen_context = build_bullpen_context(
|
| 94 |
+
game_row, pitcher_row,
|
| 95 |
+
statcast_df=statcast_df,
|
| 96 |
+
upcoming_batter_stands=[s for s in _upcoming_stands if s],
|
| 97 |
+
)
|
| 98 |
bullpen_adj = compute_bullpen_adjustment(bullpen_context)
|
| 99 |
|
| 100 |
# Batch 11: Environment overlay — computed once per game (venue/datetime don't change per batter)
|
|
|
|
| 380 |
fz_hit_eff = (family_zone_hit_boost * 0.06) - (family_zone_whiff_risk * 0.02)
|
| 381 |
fz_tb2p_eff = (family_zone_tb2p_boost * 0.06) + (family_zone_hit_boost * 0.02)
|
| 382 |
|
| 383 |
+
# Task 2: Matchup platoon multiplier — modulate zone/fz eff inside matchup scaling
|
| 384 |
+
batter_stand = batter_features.get("batter_stand")
|
| 385 |
+
p_throws = pitcher_row.get("p_throws")
|
| 386 |
+
if batter_stand is None or p_throws is None:
|
| 387 |
+
matchup_platoon_multiplier = 1.0
|
| 388 |
+
matchup_platoon_reason = "unknown"
|
| 389 |
+
elif (batter_stand == "L" and p_throws == "L") or (batter_stand == "R" and p_throws == "R"):
|
| 390 |
+
matchup_platoon_multiplier = 0.92
|
| 391 |
+
matchup_platoon_reason = "same_hand_suppressed"
|
| 392 |
+
else:
|
| 393 |
+
matchup_platoon_multiplier = 1.08
|
| 394 |
+
matchup_platoon_reason = "opposite_hand_enhanced"
|
| 395 |
+
|
| 396 |
+
zone_hr_eff *= matchup_platoon_multiplier
|
| 397 |
+
zone_hit_eff *= matchup_platoon_multiplier
|
| 398 |
+
zone_tb2p_eff *= matchup_platoon_multiplier
|
| 399 |
+
fz_hr_eff *= matchup_platoon_multiplier
|
| 400 |
+
fz_hit_eff *= matchup_platoon_multiplier
|
| 401 |
+
fz_tb2p_eff *= matchup_platoon_multiplier
|
| 402 |
+
|
| 403 |
+
# Task 9: Confidence-weighted blend (uses sample_size from adj dicts when available)
|
| 404 |
+
zone_sample_size = float(zone_matchup_adj.get("sample_size", 0) or 0)
|
| 405 |
+
fz_sample_size = float(family_zone_matchup_adj.get("sample_size", 0) or 0)
|
| 406 |
+
zone_conf = min(1.0, zone_sample_size / 30.0)
|
| 407 |
+
fz_conf = min(1.0, fz_sample_size / 30.0)
|
| 408 |
+
total_conf = zone_conf + fz_conf
|
| 409 |
+
|
| 410 |
+
if total_conf < 0.05 or (zone_sample_size == 0 and fz_sample_size == 0):
|
| 411 |
+
blend_mode = "max_fallback"
|
| 412 |
+
primary_hr = zone_hr_eff if abs(zone_hr_eff) >= abs(fz_hr_eff) else fz_hr_eff
|
| 413 |
+
primary_hit = zone_hit_eff if abs(zone_hit_eff) >= abs(fz_hit_eff) else fz_hit_eff
|
| 414 |
+
primary_tb2p = zone_tb2p_eff if abs(zone_tb2p_eff) >= abs(fz_tb2p_eff) else fz_tb2p_eff
|
| 415 |
+
else:
|
| 416 |
+
blend_mode = "confidence_blend"
|
| 417 |
+
primary_hr = (zone_hr_eff * zone_conf + fz_hr_eff * fz_conf) / total_conf
|
| 418 |
+
primary_hit = (zone_hit_eff * zone_conf + fz_hit_eff * fz_conf) / total_conf
|
| 419 |
+
primary_tb2p = (zone_tb2p_eff * zone_conf + fz_tb2p_eff * fz_conf) / total_conf
|
| 420 |
|
| 421 |
batter_baseline["hr_prob_base"] = min(0.25, max(0.005,
|
| 422 |
float(batter_baseline.get("hr_prob_base", 0.03) or 0.03) + primary_hr))
|
|
|
|
| 443 |
arsenal_tb2p_boost = float(arsenal_matchup_adj.get("arsenal_tb2p_boost", 0.0) or 0.0)
|
| 444 |
arsenal_whiff_risk = float(arsenal_matchup_adj.get("arsenal_whiff_risk", 0.0) or 0.0)
|
| 445 |
|
| 446 |
+
# Task 2: Apply matchup_platoon_multiplier to arsenal effective values
|
| 447 |
+
arsenal_hit_eff = (arsenal_hit_boost * 0.04 - arsenal_whiff_risk * 0.02) * matchup_platoon_multiplier
|
| 448 |
+
arsenal_hr_eff = (arsenal_hr_boost * 0.05) * matchup_platoon_multiplier
|
| 449 |
+
arsenal_tb2p_eff = (arsenal_tb2p_boost * 0.04) * matchup_platoon_multiplier
|
| 450 |
+
|
| 451 |
batter_baseline["hit_prob_base"] = min(0.55, max(0.05,
|
| 452 |
float(batter_baseline.get("hit_prob_base", 0.15) or 0.15)
|
| 453 |
+
+ arsenal_hit_eff))
|
|
|
|
| 454 |
|
| 455 |
batter_baseline["hr_prob_base"] = min(0.25, max(0.005,
|
| 456 |
float(batter_baseline.get("hr_prob_base", 0.03) or 0.03)
|
| 457 |
+
+ arsenal_hr_eff))
|
| 458 |
|
| 459 |
batter_baseline["tb2p_prob_base"] = min(0.45, max(0.03,
|
| 460 |
float(batter_baseline.get("tb2p_prob_base", 0.10) or 0.10)
|
| 461 |
+
+ arsenal_tb2p_eff))
|
| 462 |
|
| 463 |
_snap_after_arsenal_hr = float(batter_baseline.get("hr_prob_base", 0.03) or 0.03)
|
| 464 |
_snap_after_arsenal_hit = float(batter_baseline.get("hit_prob_base", 0.15) or 0.15)
|
|
|
|
| 642 |
|
| 643 |
# Batch 13: Opportunity adjustment (multiplicative, upcoming-only)
|
| 644 |
try:
|
| 645 |
+
# Task 3: Map slot label to lineup slot field in game_row
|
| 646 |
+
_slot_key_map = {
|
| 647 |
+
"On Deck": "on_deck_lineup_slot",
|
| 648 |
+
"In Hole": "in_hole_lineup_slot",
|
| 649 |
+
"3 Away": "three_away_lineup_slot",
|
| 650 |
+
}
|
| 651 |
+
_lineup_slot = game_row.get(_slot_key_map.get(slot))
|
| 652 |
opp_adj = compute_opportunity_adjustment(
|
| 653 |
+
lineup_slot=_lineup_slot,
|
| 654 |
team_total=game_row.get("team_total"),
|
| 655 |
pitcher_row=pitcher_row,
|
| 656 |
)
|
|
|
|
| 785 |
"starter_stays_next_batter_prob": bullpen_adj["starter_stays_next_batter_prob"],
|
| 786 |
"starter_stays_next_inning_prob": bullpen_adj["starter_stays_next_inning_prob"],
|
| 787 |
"bullpen_entry_prob": bullpen_adj["bullpen_entry_prob"],
|
| 788 |
+
# Task 1 — bullpen candidate debug fields
|
| 789 |
+
"bullpen_top_candidate": bullpen_adj.get("bullpen_top_candidate"),
|
| 790 |
+
"bullpen_top_candidate_availability": bullpen_adj.get("bullpen_top_candidate_availability"),
|
| 791 |
+
"bullpen_top_candidate_handedness_fit": bullpen_adj.get("bullpen_top_candidate_handedness_fit"),
|
| 792 |
+
"bullpen_top_candidate_role_fit": bullpen_adj.get("bullpen_top_candidate_role_fit"),
|
| 793 |
+
"bullpen_candidate_summary": bullpen_adj.get("bullpen_candidate_summary"),
|
| 794 |
+
"bullpen_availability_applied": bullpen_adj.get("bullpen_availability_applied", False),
|
| 795 |
+
"bullpen_candidate_1": bullpen_adj.get("bullpen_candidate_1"),
|
| 796 |
+
"bullpen_candidate_2": bullpen_adj.get("bullpen_candidate_2"),
|
| 797 |
+
"bullpen_candidate_3": bullpen_adj.get("bullpen_candidate_3"),
|
| 798 |
+
"bullpen_selection_mode": bullpen_adj.get("bullpen_selection_mode", "fallback"),
|
| 799 |
"reason_tags": reason_tags,
|
| 800 |
+
# Task 2 — matchup platoon multiplier
|
| 801 |
+
"matchup_platoon_multiplier": matchup_platoon_multiplier,
|
| 802 |
+
"matchup_platoon_reason": matchup_platoon_reason,
|
| 803 |
"fatigue_score": pitcher_adj.get("fatigue_score"),
|
| 804 |
"degradation_score": pitcher_adj.get("degradation_score"),
|
| 805 |
"trust_live_score": pitcher_adj.get("trust_live_score"),
|
|
|
|
| 811 |
"pitch_count": pitcher_adj.get("pitch_count"),
|
| 812 |
"times_through_order": pitcher_adj.get("times_through_order"),
|
| 813 |
|
| 814 |
+
# Task 9 — zone/fz confidence blend
|
| 815 |
+
"zone_confidence": zone_conf,
|
| 816 |
+
"family_zone_confidence": fz_conf,
|
| 817 |
+
"zone_family_blend_mode": blend_mode,
|
| 818 |
+
|
| 819 |
"zone_hr_boost": zone_matchup_adj.get("hr_zone_boost"),
|
| 820 |
"zone_hit_boost": zone_matchup_adj.get("hit_zone_boost"),
|
| 821 |
"zone_tb2p_boost": zone_matchup_adj.get("tb2p_zone_boost"),
|
|
|
|
| 841 |
"rolling_pitch_pfx_x_sample_size": pitcher_adj.get("rolling_pitch_pfx_x_sample_size"),
|
| 842 |
"rolling_pitch_pfx_z_sample_size": pitcher_adj.get("rolling_pitch_pfx_z_sample_size"),
|
| 843 |
|
| 844 |
+
# Task 5 — movement signal debug
|
| 845 |
+
"movement_signal_debug": pitcher_adj.get("movement_signal_debug"),
|
| 846 |
+
|
| 847 |
+
# Task 6 — pitcher adjustment pre-clamp debug
|
| 848 |
+
"pitcher_net_adj_pre_clamp_hit": pitcher_adj.get("pitcher_net_adj_pre_clamp_hit"),
|
| 849 |
+
"pitcher_net_adj_pre_clamp_hr": pitcher_adj.get("pitcher_net_adj_pre_clamp_hr"),
|
| 850 |
+
"pitcher_net_adj_pre_clamp_tb2p": pitcher_adj.get("pitcher_net_adj_pre_clamp_tb2p"),
|
| 851 |
+
|
| 852 |
"deception_score": trajectory_row.get("deception_score"),
|
| 853 |
"tunnel_score": trajectory_row.get("tunnel_score"),
|
| 854 |
"release_consistency_score": trajectory_row.get("release_consistency_score"),
|
|
|
|
| 994 |
"arsenal_drift_score": drift_adj.get("arsenal_drift_score"),
|
| 995 |
"arsenal_reason_tags": drift_adj.get("arsenal_reason_tags"),
|
| 996 |
"arsenal_drift_applied_scale": drift_adj.get("arsenal_drift_applied_scale"),
|
| 997 |
+
|
| 998 |
+
# Task 10 — model version metadata
|
| 999 |
+
"model_version": "v3.2",
|
| 1000 |
+
"feature_version": "v1.0",
|
| 1001 |
+
"weighting_mode": "static_rule_based",
|
| 1002 |
+
|
| 1003 |
+
# Task 10 — layer contribution deltas (derived from snap ladder)
|
| 1004 |
+
"contrib_trend_hr": _snap_after_trend_hr - _snap_baseline_hr,
|
| 1005 |
+
"contrib_matchup_hr": _snap_after_arsenal_hr - _snap_after_trend_hr,
|
| 1006 |
+
"contrib_env_hr": _snap_after_env_hr - _snap_after_arsenal_hr,
|
| 1007 |
+
"contrib_platoon_hr": _snap_after_platoon_hr - _snap_after_env_hr,
|
| 1008 |
+
"contrib_traj_hr": _snap_after_traj_hr - _snap_after_platoon_hr,
|
| 1009 |
+
"contrib_rolling_hr": _snap_after_rolling_hr - _snap_after_traj_hr,
|
| 1010 |
+
"contrib_opp_hr": _snap_after_opportunity_hr - _snap_after_rolling_hr,
|
| 1011 |
+
"contrib_drift_hr": _snap_after_drift_hr - _snap_after_opportunity_hr,
|
| 1012 |
+
"contrib_trend_hit": _snap_after_trend_hit - _snap_baseline_hit,
|
| 1013 |
+
"contrib_matchup_hit": _snap_after_arsenal_hit - _snap_after_trend_hit,
|
| 1014 |
+
"contrib_env_hit": _snap_after_env_hit - _snap_after_arsenal_hit,
|
| 1015 |
+
"contrib_platoon_hit": _snap_after_platoon_hit - _snap_after_env_hit,
|
| 1016 |
+
"contrib_traj_hit": _snap_after_traj_hit - _snap_after_platoon_hit,
|
| 1017 |
+
"contrib_rolling_hit": _snap_after_rolling_hit - _snap_after_traj_hit,
|
| 1018 |
+
"contrib_opp_hit": _snap_after_opportunity_hit - _snap_after_rolling_hit,
|
| 1019 |
+
"contrib_drift_hit": _snap_after_drift_hit - _snap_after_opportunity_hit,
|
| 1020 |
+
"contrib_trend_tb2p": _snap_after_trend_tb2p - _snap_baseline_tb2p,
|
| 1021 |
+
"contrib_matchup_tb2p": _snap_after_arsenal_tb2p - _snap_after_trend_tb2p,
|
| 1022 |
+
"contrib_env_tb2p": _snap_after_env_tb2p - _snap_after_arsenal_tb2p,
|
| 1023 |
+
"contrib_platoon_tb2p": _snap_after_platoon_tb2p - _snap_after_env_tb2p,
|
| 1024 |
+
"contrib_traj_tb2p": _snap_after_traj_tb2p - _snap_after_platoon_tb2p,
|
| 1025 |
+
"contrib_rolling_tb2p": _snap_after_rolling_tb2p - _snap_after_traj_tb2p,
|
| 1026 |
+
"contrib_opp_tb2p": _snap_after_opportunity_tb2p - _snap_after_rolling_tb2p,
|
| 1027 |
+
"contrib_drift_tb2p": _snap_after_drift_tb2p - _snap_after_opportunity_tb2p,
|
| 1028 |
}
|
| 1029 |
)
|
| 1030 |
|
models/matchup_model.py
CHANGED
|
@@ -410,4 +410,5 @@ def compute_family_zone_matchup_adjustment(
|
|
| 410 |
adjustment["family_zone_tb2p_boost"] /= total_weight
|
| 411 |
adjustment["family_zone_whiff_risk"] /= total_weight
|
| 412 |
|
|
|
|
| 413 |
return adjustment
|
|
|
|
| 410 |
adjustment["family_zone_tb2p_boost"] /= total_weight
|
| 411 |
adjustment["family_zone_whiff_risk"] /= total_weight
|
| 412 |
|
| 413 |
+
adjustment["sample_size"] = total_weight
|
| 414 |
return adjustment
|
models/pitcher_adjustment.py
CHANGED
|
@@ -315,23 +315,31 @@ def compute_pitcher_adjustment(
|
|
| 315 |
tb2p_adj += 0.002
|
| 316 |
reason_tags.append("Short release extension")
|
| 317 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
try:
|
| 319 |
-
if avg_pfx_x is not None and abs(float(avg_pfx_x)) >=
|
| 320 |
-
hit_adj
|
| 321 |
-
tb2p_adj -= 0.
|
| 322 |
-
reason_tags.append("
|
|
|
|
| 323 |
except Exception as e:
|
| 324 |
logger.debug(f"[pitcher_adjustment] pfx_x movement block skipped: {e}")
|
| 325 |
|
| 326 |
try:
|
| 327 |
-
if avg_pfx_z is not None and abs(float(avg_pfx_z)) >=
|
| 328 |
-
hit_adj
|
| 329 |
-
hr_adj
|
| 330 |
-
tb2p_adj -= 0.
|
| 331 |
-
reason_tags.append("
|
|
|
|
| 332 |
except Exception as e:
|
| 333 |
logger.debug(f"[pitcher_adjustment] pfx_z movement block skipped: {e}")
|
| 334 |
|
|
|
|
|
|
|
| 335 |
# G1: Velocity-band precision segmentation
|
| 336 |
if avg_release_speed is not None:
|
| 337 |
avg_velo = float(avg_release_speed)
|
|
@@ -380,6 +388,16 @@ def compute_pitcher_adjustment(
|
|
| 380 |
|
| 381 |
reason_tags.extend(live_state.get("reason_tags", []))
|
| 382 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 383 |
return {
|
| 384 |
"hit_adj": hit_adj,
|
| 385 |
"hr_adj": hr_adj,
|
|
@@ -406,4 +424,12 @@ def compute_pitcher_adjustment(
|
|
| 406 |
"rolling_pitch_extension_sample_size": live_state.get("rolling_pitch_extension_sample_size"),
|
| 407 |
"rolling_pitch_pfx_x_sample_size": live_state.get("rolling_pitch_pfx_x_sample_size"),
|
| 408 |
"rolling_pitch_pfx_z_sample_size": live_state.get("rolling_pitch_pfx_z_sample_size"),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
}
|
|
|
|
| 315 |
tb2p_adj += 0.002
|
| 316 |
reason_tags.append("Short release extension")
|
| 317 |
|
| 318 |
+
movement_fired = False
|
| 319 |
+
_pfx_x_str = f"{avg_pfx_x:.3f}" if avg_pfx_x is not None else "N/A"
|
| 320 |
+
_pfx_z_str = f"{avg_pfx_z:.3f}" if avg_pfx_z is not None else "N/A"
|
| 321 |
+
|
| 322 |
try:
|
| 323 |
+
if avg_pfx_x is not None and abs(float(avg_pfx_x)) >= 0.75: # ~9 inches in feet
|
| 324 |
+
hit_adj -= 0.003
|
| 325 |
+
tb2p_adj -= 0.003
|
| 326 |
+
reason_tags.append("strong_horizontal_break")
|
| 327 |
+
movement_fired = True
|
| 328 |
except Exception as e:
|
| 329 |
logger.debug(f"[pitcher_adjustment] pfx_x movement block skipped: {e}")
|
| 330 |
|
| 331 |
try:
|
| 332 |
+
if avg_pfx_z is not None and abs(float(avg_pfx_z)) >= 1.17: # ~14 inches in feet
|
| 333 |
+
hit_adj -= 0.003
|
| 334 |
+
hr_adj -= 0.003
|
| 335 |
+
tb2p_adj -= 0.003
|
| 336 |
+
reason_tags.append("strong_vertical_break")
|
| 337 |
+
movement_fired = True
|
| 338 |
except Exception as e:
|
| 339 |
logger.debug(f"[pitcher_adjustment] pfx_z movement block skipped: {e}")
|
| 340 |
|
| 341 |
+
movement_signal_debug = f"pfx_x={_pfx_x_str} pfx_z={_pfx_z_str} fired={'Y' if movement_fired else 'N'}"
|
| 342 |
+
|
| 343 |
# G1: Velocity-band precision segmentation
|
| 344 |
if avg_release_speed is not None:
|
| 345 |
avg_velo = float(avg_release_speed)
|
|
|
|
| 388 |
|
| 389 |
reason_tags.extend(live_state.get("reason_tags", []))
|
| 390 |
|
| 391 |
+
# Capture pre-clamp values for debug
|
| 392 |
+
_hit_adj_pre = hit_adj
|
| 393 |
+
_hr_adj_pre = hr_adj
|
| 394 |
+
_tb2p_adj_pre = tb2p_adj
|
| 395 |
+
|
| 396 |
+
# Final net clamp — prevents extreme multi-signal stacking
|
| 397 |
+
hit_adj = max(-0.030, min(0.030, hit_adj))
|
| 398 |
+
hr_adj = max(-0.025, min(0.025, hr_adj))
|
| 399 |
+
tb2p_adj = max(-0.025, min(0.025, tb2p_adj))
|
| 400 |
+
|
| 401 |
return {
|
| 402 |
"hit_adj": hit_adj,
|
| 403 |
"hr_adj": hr_adj,
|
|
|
|
| 424 |
"rolling_pitch_extension_sample_size": live_state.get("rolling_pitch_extension_sample_size"),
|
| 425 |
"rolling_pitch_pfx_x_sample_size": live_state.get("rolling_pitch_pfx_x_sample_size"),
|
| 426 |
"rolling_pitch_pfx_z_sample_size": live_state.get("rolling_pitch_pfx_z_sample_size"),
|
| 427 |
+
|
| 428 |
+
# Task 5 — movement signal debug
|
| 429 |
+
"movement_signal_debug": movement_signal_debug,
|
| 430 |
+
|
| 431 |
+
# Task 6 — pre-clamp values for transparency
|
| 432 |
+
"pitcher_net_adj_pre_clamp_hit": _hit_adj_pre,
|
| 433 |
+
"pitcher_net_adj_pre_clamp_hr": _hr_adj_pre,
|
| 434 |
+
"pitcher_net_adj_pre_clamp_tb2p": _tb2p_adj_pre,
|
| 435 |
}
|
models/pitcher_live_state.py
CHANGED
|
@@ -2,6 +2,8 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
from typing import Any
|
| 4 |
|
|
|
|
|
|
|
| 5 |
|
| 6 |
def _safe_float(value: Any, default: float | None = None) -> float | None:
|
| 7 |
try:
|
|
@@ -75,8 +77,8 @@ def build_pitcher_live_state(
|
|
| 75 |
elif velo_delta is not None and velo_delta >= 1.0:
|
| 76 |
fatigue_score -= 0.10
|
| 77 |
reason_tags.append("velo_up")
|
| 78 |
-
except Exception:
|
| 79 |
-
|
| 80 |
|
| 81 |
try:
|
| 82 |
if spin_delta is not None and spin_delta <= -120:
|
|
@@ -85,8 +87,8 @@ def build_pitcher_live_state(
|
|
| 85 |
elif spin_delta is not None and spin_delta >= 120:
|
| 86 |
fatigue_score -= 0.05
|
| 87 |
reason_tags.append("spin_up")
|
| 88 |
-
except Exception:
|
| 89 |
-
|
| 90 |
|
| 91 |
try:
|
| 92 |
if extension_delta is not None and extension_delta <= -0.25:
|
|
@@ -95,8 +97,8 @@ def build_pitcher_live_state(
|
|
| 95 |
elif extension_delta is not None and extension_delta >= 0.25:
|
| 96 |
fatigue_score -= 0.03
|
| 97 |
reason_tags.append("extension_up")
|
| 98 |
-
except Exception:
|
| 99 |
-
|
| 100 |
|
| 101 |
fatigue_score = max(0.0, min(1.0, fatigue_score))
|
| 102 |
|
|
|
|
| 2 |
|
| 3 |
from typing import Any
|
| 4 |
|
| 5 |
+
from utils.logger import logger
|
| 6 |
+
|
| 7 |
|
| 8 |
def _safe_float(value: Any, default: float | None = None) -> float | None:
|
| 9 |
try:
|
|
|
|
| 77 |
elif velo_delta is not None and velo_delta >= 1.0:
|
| 78 |
fatigue_score -= 0.10
|
| 79 |
reason_tags.append("velo_up")
|
| 80 |
+
except Exception as e:
|
| 81 |
+
logger.warning(f"[fatigue_velo_delta] failure: {e}", exc_info=True)
|
| 82 |
|
| 83 |
try:
|
| 84 |
if spin_delta is not None and spin_delta <= -120:
|
|
|
|
| 87 |
elif spin_delta is not None and spin_delta >= 120:
|
| 88 |
fatigue_score -= 0.05
|
| 89 |
reason_tags.append("spin_up")
|
| 90 |
+
except Exception as e:
|
| 91 |
+
logger.warning(f"[fatigue_spin_delta] failure: {e}", exc_info=True)
|
| 92 |
|
| 93 |
try:
|
| 94 |
if extension_delta is not None and extension_delta <= -0.25:
|
|
|
|
| 97 |
elif extension_delta is not None and extension_delta >= 0.25:
|
| 98 |
fatigue_score -= 0.03
|
| 99 |
reason_tags.append("extension_up")
|
| 100 |
+
except Exception as e:
|
| 101 |
+
logger.warning(f"[fatigue_extension_delta] failure: {e}", exc_info=True)
|
| 102 |
|
| 103 |
fatigue_score = max(0.0, min(1.0, fatigue_score))
|
| 104 |
|
models/rolling_form_model.py
CHANGED
|
@@ -316,7 +316,7 @@ def build_batter_rolling_form_row(
|
|
| 316 |
"batter_pulled_barrel_rate_5g": None,
|
| 317 |
"batter_games_in_window_5g": n5,
|
| 318 |
"batter_games_in_window_10g": n10,
|
| 319 |
-
"batter_recent_form_available": 1 if n5 >=
|
| 320 |
}
|
| 321 |
|
| 322 |
|
|
@@ -445,7 +445,7 @@ def build_pitcher_rolling_form_row(
|
|
| 445 |
"pitcher_hr_allowed_rate_10g": _hr_rate_allowed(df10),
|
| 446 |
"pitcher_games_in_window_5g": n5,
|
| 447 |
"pitcher_games_in_window_10g": n10,
|
| 448 |
-
"pitcher_recent_form_available": 1 if n5 >=
|
| 449 |
"pitcher_rolling_confidence": confidence,
|
| 450 |
}
|
| 451 |
|
|
@@ -481,12 +481,8 @@ def _10g_confirmation_scale(delta_5g: float | None, delta_10g: float | None, thr
|
|
| 481 |
|
| 482 |
|
| 483 |
def _sample_scale(n_games: int) -> float:
|
| 484 |
-
if n_games <
|
| 485 |
-
|
| 486 |
-
if n_games <= 3:
|
| 487 |
-
return 0.4
|
| 488 |
-
if n_games == 4:
|
| 489 |
-
return 0.7
|
| 490 |
return 1.0
|
| 491 |
|
| 492 |
|
|
|
|
| 316 |
"batter_pulled_barrel_rate_5g": None,
|
| 317 |
"batter_games_in_window_5g": n5,
|
| 318 |
"batter_games_in_window_10g": n10,
|
| 319 |
+
"batter_recent_form_available": 1 if n5 >= 4 else 0,
|
| 320 |
}
|
| 321 |
|
| 322 |
|
|
|
|
| 445 |
"pitcher_hr_allowed_rate_10g": _hr_rate_allowed(df10),
|
| 446 |
"pitcher_games_in_window_5g": n5,
|
| 447 |
"pitcher_games_in_window_10g": n10,
|
| 448 |
+
"pitcher_recent_form_available": 1 if n5 >= 4 else 0,
|
| 449 |
"pitcher_rolling_confidence": confidence,
|
| 450 |
}
|
| 451 |
|
|
|
|
| 481 |
|
| 482 |
|
| 483 |
def _sample_scale(n_games: int) -> float:
|
| 484 |
+
if n_games < 4: return 0.0 # raised gate to match recent_form_available (n >= 4)
|
| 485 |
+
if n_games == 4: return 0.7
|
|
|
|
|
|
|
|
|
|
|
|
|
| 486 |
return 1.0
|
| 487 |
|
| 488 |
|
models/zone_matchup_model.py
CHANGED
|
@@ -56,4 +56,5 @@ def compute_zone_matchup_adjustment(
|
|
| 56 |
for k in adjustment:
|
| 57 |
adjustment[k] = adjustment[k] / total_weight
|
| 58 |
|
|
|
|
| 59 |
return adjustment
|
|
|
|
| 56 |
for k in adjustment:
|
| 57 |
adjustment[k] = adjustment[k] / total_weight
|
| 58 |
|
| 59 |
+
adjustment["sample_size"] = total_weight
|
| 60 |
return adjustment
|
utils/logger.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
|
| 3 |
+
logger = logging.getLogger("kasper")
|
| 4 |
+
if not logger.handlers:
|
| 5 |
+
handler = logging.StreamHandler()
|
| 6 |
+
handler.setFormatter(logging.Formatter(
|
| 7 |
+
"[%(asctime)s] [%(levelname)s] %(message)s"
|
| 8 |
+
))
|
| 9 |
+
logger.addHandler(handler)
|
| 10 |
+
logger.setLevel(logging.INFO)
|
visualization/debug_page.py
CHANGED
|
@@ -49,48 +49,48 @@ from utils.dates import current_wbc_date_str
|
|
| 49 |
# ---------------------------------------------------------------------------
|
| 50 |
|
| 51 |
_LADDER_HR_FIELDS = [
|
| 52 |
-
("Baseline",
|
| 53 |
-
("After
|
| 54 |
-
("After Family
|
| 55 |
-
("After Arsenal",
|
| 56 |
-
("After Pulled Contact",
|
| 57 |
-
("After Env",
|
| 58 |
-
("After Platoon",
|
| 59 |
-
("After Trajectory",
|
| 60 |
-
("After Rolling",
|
| 61 |
-
("After Opportunity",
|
| 62 |
-
("After Drift",
|
| 63 |
-
("Final (simulated)",
|
| 64 |
]
|
| 65 |
|
| 66 |
_LADDER_HIT_FIELDS = [
|
| 67 |
-
("Baseline",
|
| 68 |
-
("After
|
| 69 |
-
("After Family
|
| 70 |
-
("After Arsenal",
|
| 71 |
-
("After Pulled Contact",
|
| 72 |
-
("After Env",
|
| 73 |
-
("After Platoon",
|
| 74 |
-
("After Trajectory",
|
| 75 |
-
("After Rolling",
|
| 76 |
-
("After Opportunity",
|
| 77 |
-
("After Drift",
|
| 78 |
-
("Final (simulated)",
|
| 79 |
]
|
| 80 |
|
| 81 |
_LADDER_TB2P_FIELDS = [
|
| 82 |
-
("Baseline",
|
| 83 |
-
("After
|
| 84 |
-
("After Family
|
| 85 |
-
("After Arsenal",
|
| 86 |
-
("After Pulled Contact",
|
| 87 |
-
("After Env",
|
| 88 |
-
("After Platoon",
|
| 89 |
-
("After Trajectory",
|
| 90 |
-
("After Rolling",
|
| 91 |
-
("After Opportunity",
|
| 92 |
-
("After Drift",
|
| 93 |
-
("Final (simulated)",
|
| 94 |
]
|
| 95 |
|
| 96 |
|
|
@@ -223,6 +223,7 @@ def render_debug(
|
|
| 223 |
"fair_hr_odds", "book_hr_odds", "hr_edge",
|
| 224 |
"pa_multiplier", "pitcher_quality_score", "opportunity_mode",
|
| 225 |
"rolling_combined_form_score", "arsenal_drift_score",
|
|
|
|
| 226 |
] if c in sim_df.columns
|
| 227 |
]
|
| 228 |
|
|
@@ -369,6 +370,26 @@ def render_debug(
|
|
| 369 |
else:
|
| 370 |
st.info("No active signal tags for filtered batters.")
|
| 371 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 372 |
# ------------------------------------------------------------------
|
| 373 |
# SECTION 6 — Admin Tools
|
| 374 |
# ------------------------------------------------------------------
|
|
|
|
| 49 |
# ---------------------------------------------------------------------------
|
| 50 |
|
| 51 |
_LADDER_HR_FIELDS = [
|
| 52 |
+
("Baseline", "snap_baseline_hr"),
|
| 53 |
+
("After Trend", "snap_after_trend_hr"),
|
| 54 |
+
("After Zone/Family Dedup", "snap_after_zone_dedup_hr"),
|
| 55 |
+
("After Arsenal", "snap_after_arsenal_hr"),
|
| 56 |
+
("After Pulled Contact", "snap_after_pulled_contact_hr"),
|
| 57 |
+
("After Env", "snap_after_env_hr"),
|
| 58 |
+
("After Platoon", "snap_after_platoon_hr"),
|
| 59 |
+
("After Trajectory", "snap_after_traj_hr"),
|
| 60 |
+
("After Rolling", "snap_after_rolling_hr"),
|
| 61 |
+
("After Opportunity", "snap_after_opportunity_hr"),
|
| 62 |
+
("After Drift", "snap_after_drift_hr"),
|
| 63 |
+
("Final (simulated)", "hr_prob"),
|
| 64 |
]
|
| 65 |
|
| 66 |
_LADDER_HIT_FIELDS = [
|
| 67 |
+
("Baseline", "snap_baseline_hit"),
|
| 68 |
+
("After Trend", "snap_after_trend_hit"),
|
| 69 |
+
("After Zone/Family Dedup", "snap_after_zone_dedup_hit"),
|
| 70 |
+
("After Arsenal", "snap_after_arsenal_hit"),
|
| 71 |
+
("After Pulled Contact", "snap_after_pulled_contact_hit"),
|
| 72 |
+
("After Env", "snap_after_env_hit"),
|
| 73 |
+
("After Platoon", "snap_after_platoon_hit"),
|
| 74 |
+
("After Trajectory", "snap_after_traj_hit"),
|
| 75 |
+
("After Rolling", "snap_after_rolling_hit"),
|
| 76 |
+
("After Opportunity", "snap_after_opportunity_hit"),
|
| 77 |
+
("After Drift", "snap_after_drift_hit"),
|
| 78 |
+
("Final (simulated)", "hit_prob"),
|
| 79 |
]
|
| 80 |
|
| 81 |
_LADDER_TB2P_FIELDS = [
|
| 82 |
+
("Baseline", "snap_baseline_tb2p"),
|
| 83 |
+
("After Trend", "snap_after_trend_tb2p"),
|
| 84 |
+
("After Zone/Family Dedup", "snap_after_zone_dedup_tb2p"),
|
| 85 |
+
("After Arsenal", "snap_after_arsenal_tb2p"),
|
| 86 |
+
("After Pulled Contact", "snap_after_pulled_contact_tb2p"),
|
| 87 |
+
("After Env", "snap_after_env_tb2p"),
|
| 88 |
+
("After Platoon", "snap_after_platoon_tb2p"),
|
| 89 |
+
("After Trajectory", "snap_after_traj_tb2p"),
|
| 90 |
+
("After Rolling", "snap_after_rolling_tb2p"),
|
| 91 |
+
("After Opportunity", "snap_after_opportunity_tb2p"),
|
| 92 |
+
("After Drift", "snap_after_drift_tb2p"),
|
| 93 |
+
("Final (simulated)", "tb2p_prob"),
|
| 94 |
]
|
| 95 |
|
| 96 |
|
|
|
|
| 223 |
"fair_hr_odds", "book_hr_odds", "hr_edge",
|
| 224 |
"pa_multiplier", "pitcher_quality_score", "opportunity_mode",
|
| 225 |
"rolling_combined_form_score", "arsenal_drift_score",
|
| 226 |
+
"bullpen_top_candidate", "bullpen_entry_prob",
|
| 227 |
] if c in sim_df.columns
|
| 228 |
]
|
| 229 |
|
|
|
|
| 370 |
else:
|
| 371 |
st.info("No active signal tags for filtered batters.")
|
| 372 |
|
| 373 |
+
# ------------------------------------------------------------------
|
| 374 |
+
# SECTION 5b — Bullpen Candidates
|
| 375 |
+
# ------------------------------------------------------------------
|
| 376 |
+
if not sim_df.empty:
|
| 377 |
+
with st.expander("Bullpen Candidates", expanded=False):
|
| 378 |
+
bullpen_cols = [
|
| 379 |
+
"batter_name", "pitcher_name",
|
| 380 |
+
"bullpen_top_candidate", "bullpen_top_candidate_availability",
|
| 381 |
+
"bullpen_top_candidate_handedness_fit", "bullpen_top_candidate_role_fit",
|
| 382 |
+
"bullpen_candidate_1", "bullpen_candidate_2", "bullpen_candidate_3",
|
| 383 |
+
"bullpen_candidate_summary", "bullpen_selection_mode",
|
| 384 |
+
"bullpen_availability_applied", "bullpen_entry_prob",
|
| 385 |
+
"starter_stays_next_batter_prob",
|
| 386 |
+
]
|
| 387 |
+
available_cols = [c for c in bullpen_cols if c in sim_df.columns]
|
| 388 |
+
if available_cols:
|
| 389 |
+
st.dataframe(sim_df[available_cols], use_container_width=True)
|
| 390 |
+
else:
|
| 391 |
+
st.info("Bullpen candidate data not available.")
|
| 392 |
+
|
| 393 |
# ------------------------------------------------------------------
|
| 394 |
# SECTION 6 — Admin Tools
|
| 395 |
# ------------------------------------------------------------------
|