Syntrex Claude Sonnet 4.6 commited on
Commit
95e27f5
·
1 Parent(s): dca8dd7

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 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
- # Edge
130
- if model_prob is not None and implied is not None:
131
- edge = compute_edge(model_prob, implied)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  else:
133
  edge = None
134
 
135
  implied_probs.append(implied) # type: ignore[arg-type]
136
- model_probs.append(model_prob)
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
- pass
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"], 100 / (book_hit_odds + 100))
144
  row["hit_bet_ev"] = compute_bet_ev(row["hit_prob"], int(book_hit_odds))
145
- except Exception:
146
- pass
147
 
148
  try:
149
  book_hr_odds = float(row.get("book_hr_odds"))
150
- row["hr_edge"] = compute_edge(row["hr_prob"], 100 / (book_hr_odds + 100))
151
  row["hr_bet_ev"] = compute_bet_ev(row["hr_prob"], int(book_hr_odds))
152
- except Exception:
153
- pass
154
 
155
  try:
156
  book_tb2p_odds = float(row.get("book_tb2p_odds"))
157
- row["tb2p_edge"] = compute_edge(row["tb2p_prob"], 100 / (book_tb2p_odds + 100))
158
  row["tb2p_bet_ev"] = compute_bet_ev(row["tb2p_prob"], int(book_tb2p_odds))
159
- except Exception:
160
- pass
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
- pass
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
- pass
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
- pass
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
- pass
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
- pass
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
- pass
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
- pass
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
- pass
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
- col1, col2 = st.columns([2, 1])
2598
-
2599
- with col1:
2600
- filter_option = st.radio(
2601
- "Game Status",
2602
- ["All", "Live", "Final", "Scheduled"],
2603
- horizontal=True,
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 = "WBC Analytics Assistant"
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
- parts.append(mlb_df)
298
- except Exception:
299
- pass
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
- # 51 = WBC in your current usage
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
- def build_bullpen_context(game_row: dict[str, Any], pitcher_row: dict[str, Any]) -> dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- bullpen_context = build_bullpen_context(game_row, pitcher_row)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # Apply whichever signal is stronger per dimension (no double-counting)
368
- primary_hr = zone_hr_eff if abs(zone_hr_eff) >= abs(fz_hr_eff) else fz_hr_eff
369
- primary_hit = zone_hit_eff if abs(zone_hit_eff) >= abs(fz_hit_eff) else fz_hit_eff
370
- primary_tb2p = zone_tb2p_eff if abs(zone_tb2p_eff) >= abs(fz_tb2p_eff) else fz_tb2p_eff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- + (arsenal_hit_boost * 0.04)
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
- + (arsenal_hr_boost * 0.05)))
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
- + (arsenal_tb2p_boost * 0.04)))
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=None, # batting order not available in game_row currently
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)) >= 9:
320
- hit_adj -= 0.002
321
- tb2p_adj -= 0.002
322
- reason_tags.append("Strong horizontal movement")
 
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)) >= 14:
328
- hit_adj -= 0.002
329
- hr_adj -= 0.002
330
- tb2p_adj -= 0.002
331
- reason_tags.append("Strong vertical movement")
 
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
- pass
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
- pass
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
- pass
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 >= 2 else 0,
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 >= 2 else 0,
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 < 2:
485
- return 0.0
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", "snap_baseline_hr"),
53
- ("After Zone", "snap_after_zone_hr"),
54
- ("After Family Zone", "snap_after_family_zone_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 Zone", "snap_after_zone_hit"),
69
- ("After Family Zone", "snap_after_family_zone_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 Zone", "snap_after_zone_tb2p"),
84
- ("After Family Zone", "snap_after_family_zone_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,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
  # ------------------------------------------------------------------