Syntrex commited on
Commit
bb05a74
·
1 Parent(s): 7ac6efd

Stabilize props baseline and starter resolution

Browse files
analytics/props_mapper.py CHANGED
@@ -84,6 +84,16 @@ def _to_display_name(value: Any) -> str:
84
  return str(value or "").strip()
85
 
86
 
 
 
 
 
 
 
 
 
 
 
87
  def _compute_verdict(
88
  bet_ev: float | None,
89
  edge: float | None,
@@ -257,8 +267,9 @@ def _infer_batter_team(
257
  ):
258
  return ""
259
 
 
260
  player_rows = batter_statcast_df[
261
- batter_statcast_df["player_name"].astype(str).str.casefold() == batter_name.casefold()
262
  ].copy()
263
  if player_rows.empty:
264
  return ""
@@ -289,6 +300,60 @@ def _infer_batter_team(
289
  return pd.Series(normalized).mode().iloc[0]
290
 
291
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
292
  def _resolve_batter_team_from_row_context(
293
  row: Any,
294
  batter_name: str,
@@ -313,7 +378,7 @@ def _resolve_batter_team_from_row_context(
313
  for lineup_key in ("lineup_vs_rhp", "lineup_vs_lhp"):
314
  for entry in payload.get(lineup_key) or []:
315
  player_name = _to_display_name(entry.get("player_name"))
316
- if player_name and player_name.casefold() == batter_name.casefold():
317
  return True
318
  return False
319
 
@@ -365,7 +430,7 @@ def _resolve_pitcher_hand(
365
  return ("", "unavailable")
366
  if {"player_name", "p_throws"}.issubset(pitcher_statcast_df.columns):
367
  direct_rows = pitcher_statcast_df[
368
- pitcher_statcast_df["player_name"].astype(str).str.casefold() == str(pitcher_name).casefold()
369
  ].copy()
370
  if not direct_rows.empty:
371
  direct_hand = str(direct_rows.iloc[0].get("p_throws") or "").strip().upper()
@@ -458,8 +523,7 @@ def _resolve_pitcher_name(
458
  away_pitcher = str(starters.get("away_pitcher") or "").strip()
459
 
460
  if explicit_pitcher:
461
- explicit_norm = explicit_pitcher.casefold()
462
- if (home_pitcher and home_pitcher.casefold() == explicit_norm) or (away_pitcher and away_pitcher.casefold() == explicit_norm):
463
  return (explicit_pitcher, "row_explicit_validated", "resolved")
464
 
465
  if batter_team_norm and batter_team_norm == away_norm:
@@ -531,10 +595,9 @@ def _projected_starter_match_status(
531
  return "projected_starter_unavailable"
532
  if not resolved:
533
  return "projected_starter_available_but_unresolved"
534
- resolved_norm = resolved.casefold()
535
- if projected_home_pitcher and projected_home_pitcher.casefold() == resolved_norm:
536
  return "matched_projected_home"
537
- if projected_away_pitcher and projected_away_pitcher.casefold() == resolved_norm:
538
  return "matched_projected_away"
539
  return "resolved_pitcher_mismatch"
540
 
@@ -559,9 +622,9 @@ def _resolve_pitcher_team_and_opponent(
559
 
560
  away_pitcher = str(starters.get("away_pitcher") or "").strip()
561
  home_pitcher = str(starters.get("home_pitcher") or "").strip()
562
- if away_pitcher and away_pitcher.casefold() == pitcher_name.casefold():
563
  return (away_team, home_team)
564
- if home_pitcher and home_pitcher.casefold() == pitcher_name.casefold():
565
  return (home_team, away_team)
566
  return ("", "")
567
 
@@ -588,10 +651,9 @@ def _resolve_strikeout_pitcher_name(
588
 
589
  projected_home = str(starters.get("home_pitcher") or "").strip()
590
  projected_away = str(starters.get("away_pitcher") or "").strip()
591
- explicit_norm = explicit_pitcher.casefold()
592
  if explicit_pitcher and (
593
- (projected_home and projected_home.casefold() == explicit_norm)
594
- or (projected_away and projected_away.casefold() == explicit_norm)
595
  ):
596
  return (explicit_pitcher, "row_explicit_validated", "resolved")
597
  if projected_home and not projected_away:
@@ -645,11 +707,11 @@ def _lookup_baseline_metadata(
645
  ):
646
  return default
647
 
648
- normalized_target = normalize_for_matching(player_name)
649
  if not normalized_target:
650
  return default
651
 
652
- normalized_series = statcast_df["player_name"].astype(str).map(normalize_for_matching)
653
  rows = statcast_df[normalized_series == normalized_target].copy()
654
  if rows.empty:
655
  return default
@@ -750,10 +812,11 @@ def map_hr_props_to_model(
750
  str(row.get("event_id") or "").strip(),
751
  )
752
  if batter_team_key not in batter_team_cache:
753
- batter_team_cache[batter_team_key] = _resolve_batter_team_from_row_context(
754
  row=row,
755
  batter_name=batter_name,
756
  projected_lineups=projected_lineups,
 
757
  )
758
  batter_team, batter_team_source = batter_team_cache[batter_team_key]
759
 
@@ -768,12 +831,6 @@ def map_hr_props_to_model(
768
  probable_starters=probable_starters,
769
  )
770
  projected_starter_context = projected_starter_cache[starter_key]
771
- if not batter_team:
772
- inferred_team = _infer_batter_team(batter_name=batter_name, batter_statcast_df=statcast_df)
773
- if inferred_team:
774
- batter_team = inferred_team
775
- batter_team_source = "historical_statcast"
776
-
777
  pitcher_resolution_key = (
778
  starter_key[0],
779
  starter_key[1],
 
84
  return str(value or "").strip()
85
 
86
 
87
+ def _normalize_person_name(value: Any) -> str:
88
+ return normalize_for_matching(to_canonical_name(str(value or "").strip()))
89
+
90
+
91
+ def _names_match(left: Any, right: Any) -> bool:
92
+ left_norm = _normalize_person_name(left)
93
+ right_norm = _normalize_person_name(right)
94
+ return bool(left_norm and right_norm and left_norm == right_norm)
95
+
96
+
97
  def _compute_verdict(
98
  bet_ev: float | None,
99
  edge: float | None,
 
267
  ):
268
  return ""
269
 
270
+ normalized_target = _normalize_person_name(batter_name)
271
  player_rows = batter_statcast_df[
272
+ batter_statcast_df["player_name"].astype(str).map(_normalize_person_name) == normalized_target
273
  ].copy()
274
  if player_rows.empty:
275
  return ""
 
300
  return pd.Series(normalized).mode().iloc[0]
301
 
302
 
303
+ def _resolve_batter_team(
304
+ row: Any,
305
+ batter_name: str,
306
+ projected_lineups: dict[str, dict[str, Any]] | None,
307
+ batter_statcast_df: pd.DataFrame,
308
+ ) -> tuple[str, str]:
309
+ row_team, row_source = _resolve_batter_team_from_row_context(
310
+ row=row,
311
+ batter_name=batter_name,
312
+ projected_lineups=projected_lineups,
313
+ )
314
+ if row_team:
315
+ return (row_team, row_source)
316
+
317
+ away_team = _to_display_name(row.get("away_team"))
318
+ home_team = _to_display_name(row.get("home_team"))
319
+ away_norm = _normalize_team_name(away_team)
320
+ home_norm = _normalize_team_name(home_team)
321
+
322
+ if (
323
+ batter_statcast_df is None
324
+ or batter_statcast_df.empty
325
+ or not batter_name
326
+ or "player_name" not in batter_statcast_df.columns
327
+ ):
328
+ return ("", "unresolved")
329
+
330
+ normalized_target = _normalize_person_name(batter_name)
331
+ player_rows = batter_statcast_df[
332
+ batter_statcast_df["player_name"].astype(str).map(_normalize_person_name) == normalized_target
333
+ ].copy()
334
+ if player_rows.empty:
335
+ return ("", "unresolved")
336
+
337
+ if "source_season" in player_rows.columns:
338
+ current_rows = player_rows[pd.to_numeric(player_rows["source_season"], errors="coerce") == 2026].copy()
339
+ current_team = _infer_batter_team(batter_name=batter_name, batter_statcast_df=current_rows)
340
+ if current_team:
341
+ if current_team == away_norm and away_team:
342
+ return (away_team, "current_season_statcast")
343
+ if current_team == home_norm and home_team:
344
+ return (home_team, "current_season_statcast")
345
+
346
+ historical_team = _infer_batter_team(batter_name=batter_name, batter_statcast_df=player_rows)
347
+ if historical_team:
348
+ if historical_team == away_norm and away_team:
349
+ return (away_team, "historical_statcast")
350
+ if historical_team == home_norm and home_team:
351
+ return (home_team, "historical_statcast")
352
+ return (historical_team, "historical_statcast")
353
+
354
+ return ("", "unresolved")
355
+
356
+
357
  def _resolve_batter_team_from_row_context(
358
  row: Any,
359
  batter_name: str,
 
378
  for lineup_key in ("lineup_vs_rhp", "lineup_vs_lhp"):
379
  for entry in payload.get(lineup_key) or []:
380
  player_name = _to_display_name(entry.get("player_name"))
381
+ if _names_match(player_name, batter_name):
382
  return True
383
  return False
384
 
 
430
  return ("", "unavailable")
431
  if {"player_name", "p_throws"}.issubset(pitcher_statcast_df.columns):
432
  direct_rows = pitcher_statcast_df[
433
+ pitcher_statcast_df["player_name"].astype(str).map(_normalize_person_name) == _normalize_person_name(pitcher_name)
434
  ].copy()
435
  if not direct_rows.empty:
436
  direct_hand = str(direct_rows.iloc[0].get("p_throws") or "").strip().upper()
 
523
  away_pitcher = str(starters.get("away_pitcher") or "").strip()
524
 
525
  if explicit_pitcher:
526
+ if _names_match(home_pitcher, explicit_pitcher) or _names_match(away_pitcher, explicit_pitcher):
 
527
  return (explicit_pitcher, "row_explicit_validated", "resolved")
528
 
529
  if batter_team_norm and batter_team_norm == away_norm:
 
595
  return "projected_starter_unavailable"
596
  if not resolved:
597
  return "projected_starter_available_but_unresolved"
598
+ if _names_match(projected_home_pitcher, resolved):
 
599
  return "matched_projected_home"
600
+ if _names_match(projected_away_pitcher, resolved):
601
  return "matched_projected_away"
602
  return "resolved_pitcher_mismatch"
603
 
 
622
 
623
  away_pitcher = str(starters.get("away_pitcher") or "").strip()
624
  home_pitcher = str(starters.get("home_pitcher") or "").strip()
625
+ if _names_match(away_pitcher, pitcher_name):
626
  return (away_team, home_team)
627
+ if _names_match(home_pitcher, pitcher_name):
628
  return (home_team, away_team)
629
  return ("", "")
630
 
 
651
 
652
  projected_home = str(starters.get("home_pitcher") or "").strip()
653
  projected_away = str(starters.get("away_pitcher") or "").strip()
 
654
  if explicit_pitcher and (
655
+ _names_match(projected_home, explicit_pitcher)
656
+ or _names_match(projected_away, explicit_pitcher)
657
  ):
658
  return (explicit_pitcher, "row_explicit_validated", "resolved")
659
  if projected_home and not projected_away:
 
707
  ):
708
  return default
709
 
710
+ normalized_target = _normalize_person_name(player_name)
711
  if not normalized_target:
712
  return default
713
 
714
+ normalized_series = statcast_df["player_name"].astype(str).map(_normalize_person_name)
715
  rows = statcast_df[normalized_series == normalized_target].copy()
716
  if rows.empty:
717
  return default
 
812
  str(row.get("event_id") or "").strip(),
813
  )
814
  if batter_team_key not in batter_team_cache:
815
+ batter_team_cache[batter_team_key] = _resolve_batter_team(
816
  row=row,
817
  batter_name=batter_name,
818
  projected_lineups=projected_lineups,
819
+ batter_statcast_df=statcast_df,
820
  )
821
  batter_team, batter_team_source = batter_team_cache[batter_team_key]
822
 
 
831
  probable_starters=probable_starters,
832
  )
833
  projected_starter_context = projected_starter_cache[starter_key]
 
 
 
 
 
 
834
  pitcher_resolution_key = (
835
  starter_key[0],
836
  starter_key[1],
app.py CHANGED
@@ -651,10 +651,29 @@ def load_probable_starters() -> dict:
651
  try:
652
  cached_meta = read_cached_probable_starters_meta(conn)
653
  if not cached_meta.empty:
 
 
 
 
 
 
 
 
654
  cached = read_cached_probable_starters(conn)
655
  if cached:
656
- if _is_fetched_at_fresh(cached_meta.iloc[0]["fetched_at"], 60 * 60):
 
 
657
  return cached
 
 
 
 
 
 
 
 
 
658
  _queue_async_refresh(
659
  "probable_starters",
660
  lambda: _run_with_fresh_conn(
@@ -664,6 +683,8 @@ def load_probable_starters() -> dict:
664
  )
665
  ),
666
  )
 
 
667
  return cached
668
  except Exception:
669
  pass
@@ -673,6 +694,8 @@ def load_probable_starters() -> dict:
673
  replace_cached_probable_starters(conn, fresh)
674
  except Exception as exc:
675
  logger.warning("[load_probable_starters] cache persist failure: %s", exc)
 
 
676
  return fresh
677
 
678
 
 
651
  try:
652
  cached_meta = read_cached_probable_starters_meta(conn)
653
  if not cached_meta.empty:
654
+ fetched_at = cached_meta.iloc[0]["fetched_at"]
655
+ fetched_ts = pd.to_datetime(fetched_at, errors="coerce", utc=True)
656
+ cache_age_seconds = None
657
+ if pd.notna(fetched_ts):
658
+ cache_age_seconds = max(
659
+ 0,
660
+ int((pd.Timestamp.now(tz="UTC") - fetched_ts).total_seconds()),
661
+ )
662
  cached = read_cached_probable_starters(conn)
663
  if cached:
664
+ if _is_fetched_at_fresh(fetched_at, 60 * 60):
665
+ st.session_state["probable_starters_refresh_mode"] = "cache_fresh"
666
+ st.session_state["probable_starters_cache_age_seconds"] = cache_age_seconds
667
  return cached
668
+ try:
669
+ fresh = fetch_probable_starters_for_props()
670
+ if fresh:
671
+ replace_cached_probable_starters(conn, fresh)
672
+ st.session_state["probable_starters_refresh_mode"] = "stale_sync_refresh"
673
+ st.session_state["probable_starters_cache_age_seconds"] = cache_age_seconds
674
+ return fresh
675
+ except Exception as exc:
676
+ logger.warning("[load_probable_starters] synchronous stale refresh failed: %s", exc)
677
  _queue_async_refresh(
678
  "probable_starters",
679
  lambda: _run_with_fresh_conn(
 
683
  )
684
  ),
685
  )
686
+ st.session_state["probable_starters_refresh_mode"] = "stale_cache_served_async_refresh"
687
+ st.session_state["probable_starters_cache_age_seconds"] = cache_age_seconds
688
  return cached
689
  except Exception:
690
  pass
 
694
  replace_cached_probable_starters(conn, fresh)
695
  except Exception as exc:
696
  logger.warning("[load_probable_starters] cache persist failure: %s", exc)
697
+ st.session_state["probable_starters_refresh_mode"] = "fresh_network_load"
698
+ st.session_state["probable_starters_cache_age_seconds"] = 0
699
  return fresh
700
 
701
 
data/shared_baseline.py CHANGED
@@ -15,7 +15,7 @@ from models.rolling_form_model import (
15
  build_pitcher_rolling_form_row,
16
  )
17
  from utils.helpers import utc_now_iso
18
- from visualization.cards.player_identity import load_identity_map, normalize_for_matching
19
 
20
  PRIOR_SEASONS = (2021, 2022, 2023, 2024, 2025)
21
  CURRENT_SEASON = 2026
@@ -207,7 +207,131 @@ def _coerce_bool(value: Any) -> bool:
207
 
208
 
209
  def _normalize_name(value: Any) -> str:
210
- return normalize_for_matching(str(value or "").strip())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
 
212
 
213
  def _clamp(value: float, lo: float, hi: float) -> float:
@@ -357,10 +481,10 @@ def _normalize_names_tuple(values: tuple[str, ...] | None) -> tuple[str, ...]:
357
  cleaned = str(value or "").strip()
358
  if not cleaned:
359
  continue
360
- lowered = cleaned.lower()
361
- if lowered in seen:
362
  continue
363
- seen.add(lowered)
364
  out.append(cleaned)
365
  return tuple(sorted(out))
366
 
@@ -1386,36 +1510,27 @@ def load_or_build_shared_baseline_bundle(
1386
  )
1387
  snapshot_status = snapshot_bundle.get("snapshot_status", pd.DataFrame())
1388
 
1389
- available_hitters: set[str] = set()
1390
- if isinstance(snapshot_bundle.get("batter_baseline_meta", pd.DataFrame()), pd.DataFrame):
1391
- available_hitters = {
1392
- str(name).strip().lower()
1393
- for name in snapshot_bundle.get("batter_baseline_meta", pd.DataFrame())
1394
- .get("player_name", pd.Series(dtype="object"))
1395
- .dropna()
1396
- .astype(str)
1397
- .tolist()
1398
- }
1399
-
1400
- available_pitchers: set[str] = set()
1401
- if isinstance(snapshot_bundle.get("pitcher_baseline_meta", pd.DataFrame()), pd.DataFrame):
1402
- available_pitchers = {
1403
- str(name).strip().lower()
1404
- for name in snapshot_bundle.get("pitcher_baseline_meta", pd.DataFrame())
1405
- .get("player_name", pd.Series(dtype="object"))
1406
- .dropna()
1407
- .astype(str)
1408
- .tolist()
1409
- }
1410
 
1411
- missing_hitter_names = [
1412
- name for name in snapshot_batter_names
1413
- if str(name).strip().lower() not in available_hitters
1414
- ]
1415
- missing_pitcher_names = [
1416
- name for name in snapshot_pitcher_names
1417
- if str(name).strip().lower() not in available_pitchers
1418
- ]
1419
 
1420
  requested_hitter_covered = True
1421
  if snapshot_batter_names:
@@ -1439,15 +1554,13 @@ def load_or_build_shared_baseline_bundle(
1439
  background_refresh_queued = False
1440
 
1441
  if snapshot_has_data and requested_hitter_covered and requested_pitcher_covered and not snapshot_stale:
1442
- snapshot_bundle["requested_hitter_count"] = int(len(snapshot_batter_names))
1443
- snapshot_bundle["requested_pitcher_count"] = int(len(snapshot_pitcher_names))
1444
- snapshot_bundle["resolved_hitter_count"] = int(len(snapshot_batter_names) - len(missing_hitter_names))
1445
- snapshot_bundle["resolved_pitcher_count"] = int(len(snapshot_pitcher_names) - len(missing_pitcher_names))
1446
- snapshot_bundle["missing_hitter_names"] = missing_hitter_names
1447
- snapshot_bundle["missing_pitcher_names"] = missing_pitcher_names
1448
- snapshot_bundle["snapshot_coverage_mode"] = coverage_mode
1449
- snapshot_bundle["background_refresh_queued"] = background_refresh_queued
1450
- return snapshot_bundle
1451
 
1452
  if snapshot_has_data and (
1453
  (requested_hitter_covered and requested_pitcher_covered and snapshot_stale)
@@ -1460,15 +1573,13 @@ def load_or_build_shared_baseline_bundle(
1460
  )
1461
  snapshot_bundle["snapshot_source_status"] = "snapshot_partial_served" if coverage_mode == "partial" else "snapshot_stale_served"
1462
  snapshot_bundle["runtime_fallback_used"] = False
1463
- snapshot_bundle["requested_hitter_count"] = int(len(snapshot_batter_names))
1464
- snapshot_bundle["requested_pitcher_count"] = int(len(snapshot_pitcher_names))
1465
- snapshot_bundle["resolved_hitter_count"] = int(len(snapshot_batter_names) - len(missing_hitter_names))
1466
- snapshot_bundle["resolved_pitcher_count"] = int(len(snapshot_pitcher_names) - len(missing_pitcher_names))
1467
- snapshot_bundle["missing_hitter_names"] = missing_hitter_names
1468
- snapshot_bundle["missing_pitcher_names"] = missing_pitcher_names
1469
- snapshot_bundle["snapshot_coverage_mode"] = coverage_mode
1470
- snapshot_bundle["background_refresh_queued"] = background_refresh_queued
1471
- return snapshot_bundle
1472
 
1473
  runtime_bundle = build_shared_baseline_bundle(
1474
  batter_names=snapshot_batter_names,
@@ -1482,40 +1593,127 @@ def load_or_build_shared_baseline_bundle(
1482
  runtime_bundle,
1483
  source_status="runtime_refreshed",
1484
  )
 
1485
 
1486
  if "snapshot_status" not in runtime_bundle:
1487
  runtime_bundle["snapshot_status"] = snapshot_status
1488
- runtime_available_hitters = {
1489
- str(name).strip().lower()
1490
- for name in runtime_bundle.get("batter_baseline_meta", pd.DataFrame())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1491
  .get("player_name", pd.Series(dtype="object"))
1492
  .dropna()
1493
  .astype(str)
1494
  .tolist()
1495
- }
1496
- runtime_available_pitchers = {
1497
- str(name).strip().lower()
1498
- for name in runtime_bundle.get("pitcher_baseline_meta", pd.DataFrame())
 
1499
  .get("player_name", pd.Series(dtype="object"))
1500
  .dropna()
1501
  .astype(str)
1502
  .tolist()
1503
- }
1504
- runtime_missing_hitter_names = [
1505
- name for name in snapshot_batter_names if str(name).strip().lower() not in runtime_available_hitters
1506
- ]
1507
- runtime_missing_pitcher_names = [
1508
- name for name in snapshot_pitcher_names if str(name).strip().lower() not in runtime_available_pitchers
1509
- ]
1510
- runtime_bundle["requested_hitter_count"] = int(len(snapshot_batter_names))
1511
- runtime_bundle["requested_pitcher_count"] = int(len(snapshot_pitcher_names))
1512
- runtime_bundle["resolved_hitter_count"] = int(len(snapshot_batter_names) - len(runtime_missing_hitter_names))
1513
- runtime_bundle["resolved_pitcher_count"] = int(len(snapshot_pitcher_names) - len(runtime_missing_pitcher_names))
1514
- runtime_bundle["missing_hitter_names"] = runtime_missing_hitter_names
1515
- runtime_bundle["missing_pitcher_names"] = runtime_missing_pitcher_names
1516
- runtime_bundle["snapshot_coverage_mode"] = "runtime_fallback"
1517
- runtime_bundle["background_refresh_queued"] = background_refresh_queued
1518
- return runtime_bundle
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1519
 
1520
 
1521
  def build_shared_baseline_bundle(
 
15
  build_pitcher_rolling_form_row,
16
  )
17
  from utils.helpers import utc_now_iso
18
+ from visualization.cards.player_identity import load_identity_map, normalize_for_matching, to_canonical_name
19
 
20
  PRIOR_SEASONS = (2021, 2022, 2023, 2024, 2025)
21
  CURRENT_SEASON = 2026
 
207
 
208
 
209
  def _normalize_name(value: Any) -> str:
210
+ return normalize_for_matching(to_canonical_name(str(value or "").strip()))
211
+
212
+
213
+ def _normalized_name_set(values: list[Any] | tuple[Any, ...] | set[Any] | pd.Series | None) -> set[str]:
214
+ if values is None:
215
+ return set()
216
+ return {
217
+ _normalize_name(value)
218
+ for value in list(values)
219
+ if _normalize_name(value)
220
+ }
221
+
222
+
223
+ def _compute_missing_requested_names(
224
+ requested_names: tuple[str, ...] | list[str] | None,
225
+ available_names: set[str],
226
+ ) -> list[str]:
227
+ if not requested_names:
228
+ return []
229
+ out: list[str] = []
230
+ for raw_name in requested_names:
231
+ cleaned = str(raw_name or "").strip()
232
+ if not cleaned:
233
+ continue
234
+ if _normalize_name(cleaned) not in available_names:
235
+ out.append(cleaned)
236
+ return out
237
+
238
+
239
+ def _annotate_request_coverage(
240
+ bundle: dict[str, pd.DataFrame],
241
+ *,
242
+ requested_hitter_names: tuple[str, ...],
243
+ requested_pitcher_names: tuple[str, ...],
244
+ coverage_mode: str,
245
+ background_refresh_queued: bool,
246
+ ) -> dict[str, pd.DataFrame]:
247
+ available_hitters = _normalized_name_set(
248
+ bundle.get("batter_baseline_meta", pd.DataFrame()).get("player_name", pd.Series(dtype="object")).dropna().astype(str).tolist()
249
+ if isinstance(bundle.get("batter_baseline_meta", pd.DataFrame()), pd.DataFrame)
250
+ else []
251
+ )
252
+ available_pitchers = _normalized_name_set(
253
+ bundle.get("pitcher_baseline_meta", pd.DataFrame()).get("player_name", pd.Series(dtype="object")).dropna().astype(str).tolist()
254
+ if isinstance(bundle.get("pitcher_baseline_meta", pd.DataFrame()), pd.DataFrame)
255
+ else []
256
+ )
257
+ missing_hitter_names = _compute_missing_requested_names(requested_hitter_names, available_hitters)
258
+ missing_pitcher_names = _compute_missing_requested_names(requested_pitcher_names, available_pitchers)
259
+ bundle["requested_hitter_count"] = int(len(requested_hitter_names))
260
+ bundle["requested_pitcher_count"] = int(len(requested_pitcher_names))
261
+ bundle["resolved_hitter_count"] = int(len(requested_hitter_names) - len(missing_hitter_names))
262
+ bundle["resolved_pitcher_count"] = int(len(requested_pitcher_names) - len(missing_pitcher_names))
263
+ bundle["missing_hitter_names"] = missing_hitter_names
264
+ bundle["missing_pitcher_names"] = missing_pitcher_names
265
+ bundle["snapshot_coverage_mode"] = coverage_mode
266
+ bundle["background_refresh_queued"] = background_refresh_queued
267
+ return bundle
268
+
269
+
270
+ def _merge_bundle_frames(
271
+ left: pd.DataFrame | None,
272
+ right: pd.DataFrame | None,
273
+ *,
274
+ subset_candidates: list[str],
275
+ ) -> pd.DataFrame:
276
+ left_df = left if isinstance(left, pd.DataFrame) else pd.DataFrame()
277
+ right_df = right if isinstance(right, pd.DataFrame) else pd.DataFrame()
278
+ if left_df.empty:
279
+ return right_df.copy()
280
+ if right_df.empty:
281
+ return left_df.copy()
282
+ merged = pd.concat([left_df, right_df], ignore_index=True, sort=False)
283
+ subset = [col for col in subset_candidates if col in merged.columns]
284
+ if subset:
285
+ merged = merged.drop_duplicates(subset=subset, keep="last")
286
+ else:
287
+ merged = merged.drop_duplicates(keep="last")
288
+ return merged.reset_index(drop=True)
289
+
290
+
291
+ def _merge_shared_baseline_bundles(
292
+ snapshot_bundle: dict[str, pd.DataFrame],
293
+ patch_bundle: dict[str, pd.DataFrame],
294
+ ) -> dict[str, pd.DataFrame]:
295
+ merged = dict(snapshot_bundle)
296
+ merged["blended_batter_df"] = _merge_bundle_frames(
297
+ snapshot_bundle.get("blended_batter_df"),
298
+ patch_bundle.get("blended_batter_df"),
299
+ subset_candidates=["player_name", "event_key", "game_pk", "at_bat_number", "pitch_number"],
300
+ )
301
+ merged["blended_pitcher_df"] = _merge_bundle_frames(
302
+ snapshot_bundle.get("blended_pitcher_df"),
303
+ patch_bundle.get("blended_pitcher_df"),
304
+ subset_candidates=["player_name", "event_key", "game_pk", "at_bat_number", "pitch_number"],
305
+ )
306
+ merged["batter_baseline_meta"] = _merge_bundle_frames(
307
+ snapshot_bundle.get("batter_baseline_meta"),
308
+ patch_bundle.get("batter_baseline_meta"),
309
+ subset_candidates=["player_name"],
310
+ )
311
+ merged["pitcher_baseline_meta"] = _merge_bundle_frames(
312
+ snapshot_bundle.get("pitcher_baseline_meta"),
313
+ patch_bundle.get("pitcher_baseline_meta"),
314
+ subset_candidates=["player_name"],
315
+ )
316
+ merged["hitter_rolling_snapshot"] = _merge_bundle_frames(
317
+ snapshot_bundle.get("hitter_rolling_snapshot"),
318
+ patch_bundle.get("hitter_rolling_snapshot"),
319
+ subset_candidates=["player_name"],
320
+ )
321
+ merged["pitcher_rolling_snapshot"] = _merge_bundle_frames(
322
+ snapshot_bundle.get("pitcher_rolling_snapshot"),
323
+ patch_bundle.get("pitcher_rolling_snapshot"),
324
+ subset_candidates=["player_name"],
325
+ )
326
+ merged["snapshot_status"] = _merge_bundle_frames(
327
+ snapshot_bundle.get("snapshot_status"),
328
+ patch_bundle.get("snapshot_status"),
329
+ subset_candidates=["table_name"],
330
+ )
331
+ merged["snapshot_source_status"] = "snapshot_request_patched"
332
+ merged["runtime_fallback_used"] = False
333
+ merged["request_patch_used"] = True
334
+ return merged
335
 
336
 
337
  def _clamp(value: float, lo: float, hi: float) -> float:
 
481
  cleaned = str(value or "").strip()
482
  if not cleaned:
483
  continue
484
+ normalized = _normalize_name(cleaned)
485
+ if normalized in seen:
486
  continue
487
+ seen.add(normalized)
488
  out.append(cleaned)
489
  return tuple(sorted(out))
490
 
 
1510
  )
1511
  snapshot_status = snapshot_bundle.get("snapshot_status", pd.DataFrame())
1512
 
1513
+ available_hitters = _normalized_name_set(
1514
+ snapshot_bundle.get("batter_baseline_meta", pd.DataFrame())
1515
+ .get("player_name", pd.Series(dtype="object"))
1516
+ .dropna()
1517
+ .astype(str)
1518
+ .tolist()
1519
+ if isinstance(snapshot_bundle.get("batter_baseline_meta", pd.DataFrame()), pd.DataFrame)
1520
+ else []
1521
+ )
1522
+ available_pitchers = _normalized_name_set(
1523
+ snapshot_bundle.get("pitcher_baseline_meta", pd.DataFrame())
1524
+ .get("player_name", pd.Series(dtype="object"))
1525
+ .dropna()
1526
+ .astype(str)
1527
+ .tolist()
1528
+ if isinstance(snapshot_bundle.get("pitcher_baseline_meta", pd.DataFrame()), pd.DataFrame)
1529
+ else []
1530
+ )
 
 
 
1531
 
1532
+ missing_hitter_names = _compute_missing_requested_names(snapshot_batter_names, available_hitters)
1533
+ missing_pitcher_names = _compute_missing_requested_names(snapshot_pitcher_names, available_pitchers)
 
 
 
 
 
 
1534
 
1535
  requested_hitter_covered = True
1536
  if snapshot_batter_names:
 
1554
  background_refresh_queued = False
1555
 
1556
  if snapshot_has_data and requested_hitter_covered and requested_pitcher_covered and not snapshot_stale:
1557
+ return _annotate_request_coverage(
1558
+ snapshot_bundle,
1559
+ requested_hitter_names=snapshot_batter_names,
1560
+ requested_pitcher_names=snapshot_pitcher_names,
1561
+ coverage_mode=coverage_mode,
1562
+ background_refresh_queued=background_refresh_queued,
1563
+ )
 
 
1564
 
1565
  if snapshot_has_data and (
1566
  (requested_hitter_covered and requested_pitcher_covered and snapshot_stale)
 
1573
  )
1574
  snapshot_bundle["snapshot_source_status"] = "snapshot_partial_served" if coverage_mode == "partial" else "snapshot_stale_served"
1575
  snapshot_bundle["runtime_fallback_used"] = False
1576
+ return _annotate_request_coverage(
1577
+ snapshot_bundle,
1578
+ requested_hitter_names=snapshot_batter_names,
1579
+ requested_pitcher_names=snapshot_pitcher_names,
1580
+ coverage_mode=coverage_mode,
1581
+ background_refresh_queued=background_refresh_queued,
1582
+ )
 
 
1583
 
1584
  runtime_bundle = build_shared_baseline_bundle(
1585
  batter_names=snapshot_batter_names,
 
1593
  runtime_bundle,
1594
  source_status="runtime_refreshed",
1595
  )
1596
+ runtime_bundle["runtime_fallback_used"] = True
1597
 
1598
  if "snapshot_status" not in runtime_bundle:
1599
  runtime_bundle["snapshot_status"] = snapshot_status
1600
+ return _annotate_request_coverage(
1601
+ runtime_bundle,
1602
+ requested_hitter_names=snapshot_batter_names,
1603
+ requested_pitcher_names=snapshot_pitcher_names,
1604
+ coverage_mode="runtime_fallback",
1605
+ background_refresh_queued=background_refresh_queued,
1606
+ )
1607
+
1608
+
1609
+ def load_or_build_shared_baseline_bundle_complete_for_request(
1610
+ batter_names: tuple[str, ...] = (),
1611
+ pitcher_names: tuple[str, ...] = (),
1612
+ max_age_seconds: int = _DEFAULT_SNAPSHOT_MAX_AGE_SECONDS,
1613
+ persist_runtime_refresh: bool = True,
1614
+ ) -> dict[str, pd.DataFrame]:
1615
+ batter_names = _normalize_names_tuple(batter_names)
1616
+ pitcher_names = _normalize_names_tuple(pitcher_names)
1617
+ snapshot_batter_names = _resolve_snapshot_player_names(batter_names, role="batter")
1618
+ snapshot_pitcher_names = _resolve_snapshot_player_names(pitcher_names, role="pitcher")
1619
+
1620
+ snapshot_bundle = load_shared_baseline_bundle_from_snapshots(
1621
+ batter_names=snapshot_batter_names,
1622
+ pitcher_names=snapshot_pitcher_names,
1623
+ max_age_seconds=max_age_seconds,
1624
+ )
1625
+ snapshot_status = snapshot_bundle.get("snapshot_status", pd.DataFrame())
1626
+ snapshot_has_data = not snapshot_bundle.get("blended_batter_df", pd.DataFrame()).empty or not snapshot_bundle.get("blended_pitcher_df", pd.DataFrame()).empty
1627
+ snapshot_stale = bool(
1628
+ isinstance(snapshot_status, pd.DataFrame)
1629
+ and not snapshot_status.empty
1630
+ and snapshot_status["stale"].fillna(False).any()
1631
+ )
1632
+
1633
+ available_hitters = _normalized_name_set(
1634
+ snapshot_bundle.get("batter_baseline_meta", pd.DataFrame())
1635
  .get("player_name", pd.Series(dtype="object"))
1636
  .dropna()
1637
  .astype(str)
1638
  .tolist()
1639
+ if isinstance(snapshot_bundle.get("batter_baseline_meta", pd.DataFrame()), pd.DataFrame)
1640
+ else []
1641
+ )
1642
+ available_pitchers = _normalized_name_set(
1643
+ snapshot_bundle.get("pitcher_baseline_meta", pd.DataFrame())
1644
  .get("player_name", pd.Series(dtype="object"))
1645
  .dropna()
1646
  .astype(str)
1647
  .tolist()
1648
+ if isinstance(snapshot_bundle.get("pitcher_baseline_meta", pd.DataFrame()), pd.DataFrame)
1649
+ else []
1650
+ )
1651
+ missing_hitter_names = _compute_missing_requested_names(snapshot_batter_names, available_hitters)
1652
+ missing_pitcher_names = _compute_missing_requested_names(snapshot_pitcher_names, available_pitchers)
1653
+
1654
+ if snapshot_has_data and not missing_hitter_names and not missing_pitcher_names:
1655
+ coverage_mode = "full" if not snapshot_stale else "stale_full"
1656
+ background_refresh_queued = False
1657
+ if snapshot_stale:
1658
+ background_refresh_queued = queue_shared_baseline_refresh(
1659
+ batter_names=snapshot_batter_names,
1660
+ pitcher_names=snapshot_pitcher_names,
1661
+ )
1662
+ snapshot_bundle["snapshot_source_status"] = "snapshot_stale_served"
1663
+ return _annotate_request_coverage(
1664
+ snapshot_bundle,
1665
+ requested_hitter_names=snapshot_batter_names,
1666
+ requested_pitcher_names=snapshot_pitcher_names,
1667
+ coverage_mode=coverage_mode,
1668
+ background_refresh_queued=background_refresh_queued,
1669
+ )
1670
+
1671
+ if snapshot_has_data and (missing_hitter_names or missing_pitcher_names):
1672
+ patch_bundle = build_shared_baseline_bundle(
1673
+ batter_names=tuple(sorted(missing_hitter_names)),
1674
+ pitcher_names=tuple(sorted(missing_pitcher_names)),
1675
+ )
1676
+ if persist_runtime_refresh:
1677
+ patch_bundle = persist_shared_baseline_snapshots(
1678
+ patch_bundle,
1679
+ source_status="runtime_request_patch",
1680
+ )
1681
+ merged_bundle = _merge_shared_baseline_bundles(snapshot_bundle, patch_bundle)
1682
+ if snapshot_stale:
1683
+ merged_bundle["background_refresh_queued"] = queue_shared_baseline_refresh(
1684
+ batter_names=snapshot_batter_names,
1685
+ pitcher_names=snapshot_pitcher_names,
1686
+ )
1687
+ return _annotate_request_coverage(
1688
+ merged_bundle,
1689
+ requested_hitter_names=snapshot_batter_names,
1690
+ requested_pitcher_names=snapshot_pitcher_names,
1691
+ coverage_mode="request_completed_patch",
1692
+ background_refresh_queued=bool(merged_bundle.get("background_refresh_queued")),
1693
+ )
1694
+
1695
+ runtime_bundle = build_shared_baseline_bundle(
1696
+ batter_names=snapshot_batter_names,
1697
+ pitcher_names=snapshot_pitcher_names,
1698
+ )
1699
+ runtime_bundle["snapshot_source_status"] = "runtime_fallback"
1700
+ runtime_bundle["runtime_fallback_used"] = True
1701
+ runtime_bundle["request_patch_used"] = False
1702
+ if persist_runtime_refresh:
1703
+ runtime_bundle = persist_shared_baseline_snapshots(
1704
+ runtime_bundle,
1705
+ source_status="runtime_refreshed",
1706
+ )
1707
+ runtime_bundle["runtime_fallback_used"] = True
1708
+ if "snapshot_status" not in runtime_bundle:
1709
+ runtime_bundle["snapshot_status"] = snapshot_status
1710
+ return _annotate_request_coverage(
1711
+ runtime_bundle,
1712
+ requested_hitter_names=snapshot_batter_names,
1713
+ requested_pitcher_names=snapshot_pitcher_names,
1714
+ coverage_mode="runtime_fallback",
1715
+ background_refresh_queued=False,
1716
+ )
1717
 
1718
 
1719
  def build_shared_baseline_bundle(
tests/test_props_mapper.py CHANGED
@@ -603,6 +603,218 @@ class TestPropsMapper(unittest.TestCase):
603
  self.assertEqual(row["model_voice_tags"], ["arsenal_favorable"])
604
  self.assertIn("arsenal", str(row["model_voice"]).lower())
605
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
606
  def test_unmodeled_hr_ladders_do_not_get_model_probability(self) -> None:
607
  props_df = pd.DataFrame(
608
  [
 
603
  self.assertEqual(row["model_voice_tags"], ["arsenal_favorable"])
604
  self.assertIn("arsenal", str(row["model_voice"]).lower())
605
 
606
+ def test_props_mapper_matches_pitcher_aliases_across_starters_and_baseline(self) -> None:
607
+ props_df = pd.DataFrame(
608
+ [
609
+ {
610
+ "market": "hr",
611
+ "player_name": "alek thomas",
612
+ "player_name_raw": "Alek Thomas",
613
+ "odds_american": 450,
614
+ "sportsbook": "Caesars",
615
+ "away_team": "Arizona Diamondbacks",
616
+ "home_team": "Los Angeles Dodgers",
617
+ "commence_time": "2026-03-25T00:10:00Z",
618
+ }
619
+ ]
620
+ )
621
+ statcast_df = pd.DataFrame(
622
+ [
623
+ {
624
+ "player_name": "Alek Thomas",
625
+ "inning_topbot": "Top",
626
+ "away_team": "Arizona Diamondbacks",
627
+ "home_team": "Los Angeles Dodgers",
628
+ "baseline_mode": "blended",
629
+ }
630
+ ]
631
+ )
632
+ pitcher_statcast_df = pd.DataFrame(
633
+ [
634
+ {
635
+ "player_name": "Rodriguez, Eduardo",
636
+ "baseline_mode": "prior_only",
637
+ "prior_sample_size": 5000,
638
+ "season_2026_sample_size": 0,
639
+ "prior_weight": 1.0,
640
+ "season_2026_weight": 0.0,
641
+ "baseline_driver": "prior_led",
642
+ "rolling_overlay_active": False,
643
+ "p_throws": "L",
644
+ }
645
+ ]
646
+ )
647
+ probable_starters = {
648
+ ("arizona diamondbacks", "los angeles dodgers"): {
649
+ "home_pitcher": "Eduardo Rodriguez",
650
+ "away_pitcher": "Someone Else",
651
+ }
652
+ }
653
+
654
+ with patch(
655
+ "analytics.props_mapper.build_hr_probability_result",
656
+ return_value={
657
+ "adjusted_hr_prob": 0.04,
658
+ "raw_hr_prob": 0.04,
659
+ "calibrated_hr_prob": 0.04,
660
+ "baseline_hr_prob": 0.03,
661
+ "pregame_hr_prob": 0.04,
662
+ "mode": "pregame",
663
+ "applied_layers": "pitcher",
664
+ "skipped_layers": "",
665
+ "confidence_score": 70.0,
666
+ "confidence_bucket": "medium",
667
+ "confidence_reasons": [],
668
+ "opportunity_hr_adjustment": 0.0,
669
+ "expected_pa": 4.3,
670
+ "pa_multiplier": 1.0,
671
+ "lineup_slot_used": None,
672
+ "lineup_slot_source": "unknown",
673
+ "team_total_used": None,
674
+ "team_total_source": "unknown",
675
+ "opportunity_mode": None,
676
+ "opportunity_reason": None,
677
+ "pregame_pitcher_context_adj": 0.0,
678
+ "pregame_park_context_adj": 0.0,
679
+ "pregame_weather_context_adj": 0.0,
680
+ "pregame_context_applied": True,
681
+ "pitcher_hr_adjustment": 0.0,
682
+ "trend_hr_adjustment": 0.0,
683
+ "zone_hr_adjustment": 0.0,
684
+ "family_zone_hr_adjustment": 0.0,
685
+ "arsenal_hr_adjustment": 0.0,
686
+ "pulled_contact_hr_adjustment": 0.0,
687
+ "env_hr_adjustment": 0.0,
688
+ "park_hr_adjustment": 0.0,
689
+ "weather_hr_adjustment": 0.0,
690
+ "platoon_hr_adjustment": 0.0,
691
+ "trajectory_hr_adjustment": 0.0,
692
+ "rolling_hr_adjustment": 0.0,
693
+ "pitcher_reliability": 0.8,
694
+ "pitcher_resolution_status": "resolved",
695
+ "trend_reliability": 0.0,
696
+ "zone_reliability": 0.0,
697
+ "family_zone_reliability": 0.0,
698
+ "arsenal_reliability": 0.0,
699
+ "pulled_contact_reliability": 0.0,
700
+ "environment_reliability": 0.0,
701
+ "trajectory_reliability": 0.0,
702
+ "rolling_reliability": 0.0,
703
+ "opportunity_reliability": 0.0,
704
+ "matchup_platoon_multiplier": 1.0,
705
+ "matchup_platoon_reason": "unknown",
706
+ },
707
+ ):
708
+ result = map_hr_props_to_model(
709
+ props_df,
710
+ statcast_df,
711
+ pitcher_statcast_df=pitcher_statcast_df,
712
+ probable_starters=probable_starters,
713
+ )
714
+
715
+ row = result.iloc[0]
716
+ self.assertEqual(row["resolved_pitcher_name"], "Eduardo Rodriguez")
717
+ self.assertEqual(row["pitcher_baseline_mode"], "prior_only")
718
+ self.assertEqual(row["projected_starter_match_status"], "matched_projected_home")
719
+
720
+ def test_props_mapper_uses_current_season_statcast_team_when_row_team_is_missing(self) -> None:
721
+ props_df = pd.DataFrame(
722
+ [
723
+ {
724
+ "market": "hr",
725
+ "player_name": "andrew benintendi",
726
+ "player_name_raw": "Andrew Benintendi",
727
+ "odds_american": 500,
728
+ "sportsbook": "BetMGM",
729
+ "away_team": "Chicago White Sox",
730
+ "home_team": "Toronto Blue Jays",
731
+ "commence_time": "2026-03-25T00:10:00Z",
732
+ }
733
+ ]
734
+ )
735
+ statcast_df = pd.DataFrame(
736
+ [
737
+ {
738
+ "player_name": "Andrew Benintendi",
739
+ "inning_topbot": "Top",
740
+ "away_team": "Chicago White Sox",
741
+ "home_team": "Toronto Blue Jays",
742
+ "source_season": 2026,
743
+ }
744
+ ]
745
+ )
746
+ probable_starters = {
747
+ ("chicago white sox", "toronto blue jays"): {
748
+ "home_pitcher": "Anthony Kay",
749
+ "away_pitcher": "Brandon Sproat",
750
+ }
751
+ }
752
+
753
+ with patch(
754
+ "analytics.props_mapper.build_hr_probability_result",
755
+ return_value={
756
+ "adjusted_hr_prob": 0.03,
757
+ "raw_hr_prob": 0.03,
758
+ "calibrated_hr_prob": 0.03,
759
+ "baseline_hr_prob": 0.02,
760
+ "pregame_hr_prob": 0.03,
761
+ "mode": "pregame",
762
+ "applied_layers": "pitcher",
763
+ "skipped_layers": "",
764
+ "confidence_score": 68.0,
765
+ "confidence_bucket": "medium",
766
+ "confidence_reasons": [],
767
+ "opportunity_hr_adjustment": 0.0,
768
+ "expected_pa": 4.2,
769
+ "pa_multiplier": 1.0,
770
+ "lineup_slot_used": None,
771
+ "lineup_slot_source": "unknown",
772
+ "team_total_used": None,
773
+ "team_total_source": "unknown",
774
+ "opportunity_mode": None,
775
+ "opportunity_reason": None,
776
+ "pregame_pitcher_context_adj": 0.0,
777
+ "pregame_park_context_adj": 0.0,
778
+ "pregame_weather_context_adj": 0.0,
779
+ "pregame_context_applied": True,
780
+ "pitcher_hr_adjustment": 0.0,
781
+ "trend_hr_adjustment": 0.0,
782
+ "zone_hr_adjustment": 0.0,
783
+ "family_zone_hr_adjustment": 0.0,
784
+ "arsenal_hr_adjustment": 0.0,
785
+ "pulled_contact_hr_adjustment": 0.0,
786
+ "env_hr_adjustment": 0.0,
787
+ "park_hr_adjustment": 0.0,
788
+ "weather_hr_adjustment": 0.0,
789
+ "platoon_hr_adjustment": 0.0,
790
+ "trajectory_hr_adjustment": 0.0,
791
+ "rolling_hr_adjustment": 0.0,
792
+ "pitcher_reliability": 0.8,
793
+ "pitcher_resolution_status": "resolved",
794
+ "trend_reliability": 0.0,
795
+ "zone_reliability": 0.0,
796
+ "family_zone_reliability": 0.0,
797
+ "arsenal_reliability": 0.0,
798
+ "pulled_contact_reliability": 0.0,
799
+ "environment_reliability": 0.0,
800
+ "trajectory_reliability": 0.0,
801
+ "rolling_reliability": 0.0,
802
+ "opportunity_reliability": 0.0,
803
+ "matchup_platoon_multiplier": 1.0,
804
+ "matchup_platoon_reason": "unknown",
805
+ },
806
+ ):
807
+ result = map_hr_props_to_model(
808
+ props_df,
809
+ statcast_df,
810
+ probable_starters=probable_starters,
811
+ )
812
+
813
+ row = result.iloc[0]
814
+ self.assertEqual(row["batter_team"], "Chicago White Sox")
815
+ self.assertEqual(row["batter_team_source"], "current_season_statcast")
816
+ self.assertEqual(row["resolved_pitcher_name"], "Anthony Kay")
817
+
818
  def test_unmodeled_hr_ladders_do_not_get_model_probability(self) -> None:
819
  props_df = pd.DataFrame(
820
  [
tests/test_shared_baseline.py CHANGED
@@ -8,10 +8,13 @@ import pandas as pd
8
 
9
  sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
10
 
11
- from data.shared_baseline import _blend_entity_frames
12
 
13
 
14
  class TestSharedBaseline(unittest.TestCase):
 
 
 
15
  def test_blend_entity_frames_prefers_prior_when_current_sample_is_small(self) -> None:
16
  prior_df = pd.DataFrame(
17
  [
 
8
 
9
  sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
10
 
11
+ from data.shared_baseline import _blend_entity_frames, _normalize_name
12
 
13
 
14
  class TestSharedBaseline(unittest.TestCase):
15
+ def test_normalize_name_matches_first_last_and_last_first(self) -> None:
16
+ self.assertEqual(_normalize_name("Eduardo Rodriguez"), _normalize_name("Rodriguez, Eduardo"))
17
+
18
  def test_blend_entity_frames_prefers_prior_when_current_sample_is_small(self) -> None:
19
  prior_df = pd.DataFrame(
20
  [
visualization/debug_page.py CHANGED
@@ -177,7 +177,11 @@ def _build_model_upgrade_rubric(
177
  "count_pattern_damage_subscore",
178
  ]
179
  )
180
- has_k_v2 = has_shared and "expected_strikeouts_v2" in rows.columns
 
 
 
 
181
  has_opportunity = has_shared and any(
182
  col in rows.columns
183
  for col in ["projected_pitch_count", "projected_batters_faced", "projected_innings"]
@@ -780,7 +784,19 @@ def render_debug(
780
  # ------------------------------------------------------------------
781
  # SECTION 5c — Execution Layer
782
  # ------------------------------------------------------------------
783
- exec_df = st.session_state.get("props_exec_df")
 
 
 
 
 
 
 
 
 
 
 
 
784
  with st.expander("Execution Layer (Props)", expanded=False):
785
  if exec_df is None or (isinstance(exec_df, pd.DataFrame) and exec_df.empty):
786
  st.info("No execution layer data. Visit the Props tab first.")
@@ -885,7 +901,7 @@ def render_debug(
885
  "model_hr_prob_source",
886
  ]
887
  with vm_tab_normalized:
888
- st.write("Normalized HR rows")
889
  st.dataframe(
890
  exec_df[[c for c in normalized_cols if c in exec_df.columns]],
891
  use_container_width=True,
@@ -1285,51 +1301,40 @@ def render_debug(
1285
  )
1286
 
1287
  with st.expander("Props Baseline Diagnostics", expanded=False):
1288
- props_baseline_debug = st.session_state.get("props_baseline_debug") or {}
1289
- if props_baseline_debug:
1290
- c1, c2, c3 = st.columns(3)
1291
- c1.metric(
1292
- "Coverage Mode",
1293
- str(props_baseline_debug.get("snapshot_coverage_mode") or "unknown").replace("_", " ").title(),
1294
- )
1295
- c2.metric(
1296
- "Runtime Fallback Used",
1297
- "Yes" if bool(props_baseline_debug.get("runtime_fallback_used")) else "No",
1298
- )
1299
- c3.metric(
1300
- "Background Refresh Queued",
1301
- "Yes" if bool(props_baseline_debug.get("background_refresh_queued")) else "No",
1302
- )
1303
-
1304
- counts_df = pd.DataFrame(
1305
- [
1306
  {
1307
- "market_type": props_baseline_debug.get("market_type"),
1308
- "baseline_source": props_baseline_debug.get("baseline_source"),
1309
- "requested_hitter_count": props_baseline_debug.get("requested_hitter_count"),
1310
- "resolved_hitter_count": props_baseline_debug.get("resolved_hitter_count"),
1311
- "requested_pitcher_count": props_baseline_debug.get("requested_pitcher_count"),
1312
- "resolved_pitcher_count": props_baseline_debug.get("resolved_pitcher_count"),
1313
- "slate_team_scope": ", ".join(props_baseline_debug.get("slate_team_scope") or []),
 
 
 
 
 
 
1314
  }
1315
- ]
1316
- )
1317
- st.dataframe(counts_df, use_container_width=True, hide_index=True)
1318
-
1319
- missing_rows = pd.DataFrame(
1320
- [
1321
- {
1322
- "missing_hitter_names": ", ".join(props_baseline_debug.get("missing_hitter_names") or []),
1323
- "missing_pitcher_names": ", ".join(props_baseline_debug.get("missing_pitcher_names") or []),
1324
- }
1325
- ]
1326
- )
1327
- st.dataframe(missing_rows, use_container_width=True, hide_index=True)
1328
  else:
1329
  st.info("Open the Props page in this session to capture Props baseline diagnostics.")
1330
 
1331
  with st.expander("Props HR Health Diagnostics", expanded=False):
1332
- props_hr_health_debug = st.session_state.get("props_hr_health_debug") or {}
1333
  if props_hr_health_debug:
1334
  c1, c2, c3, c4, c5 = st.columns(5)
1335
  c1.metric("Modeled 1+ HR Rows", int(props_hr_health_debug.get("modeled_hr_rows_total") or 0))
@@ -1359,25 +1364,48 @@ def render_debug(
1359
  st.info("Open the Props page in this session to capture HR health diagnostics.")
1360
 
1361
  with st.expander("Shared Matchup Component Diagnostics", expanded=False):
1362
- shared_component_debug = st.session_state.get("props_shared_component_debug") or {}
1363
- if shared_component_debug:
1364
- st.caption(
1365
- f"Captured from Props market: {str(shared_component_debug.get('market_type') or 'unknown').upper()}"
1366
- )
1367
- rows_df = pd.DataFrame(shared_component_debug.get("rows") or [])
1368
- if not rows_df.empty:
1369
- st.dataframe(rows_df, use_container_width=True, hide_index=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1370
  else:
1371
- st.info("No shared-component rows captured in this session.")
1372
  else:
1373
  st.info("Open the Props page in this session to capture shared matchup diagnostics.")
1374
 
1375
  with st.expander("Model Grading Rubric", expanded=False):
1376
- props_hr_health_debug = st.session_state.get("props_hr_health_debug") or {}
1377
- shared_component_debug = st.session_state.get("props_shared_component_debug") or {}
 
 
 
 
 
 
1378
  rubric_df, rubric_summary = _build_model_upgrade_rubric(
1379
  props_hr_health_debug=props_hr_health_debug,
1380
- shared_component_debug=shared_component_debug,
1381
  )
1382
  c1, c2, c3 = st.columns(3)
1383
  c1.metric(
@@ -1462,6 +1490,8 @@ def render_debug(
1462
  "source": "cached_probable_starters",
1463
  "row_count": int(starters_meta.iloc[0]["matchup_count"]) if not starters_meta.empty else 0,
1464
  "latest_fetched_at": starters_meta.iloc[0]["fetched_at"] if not starters_meta.empty else None,
 
 
1465
  }
1466
  )
1467
  except Exception:
 
177
  "count_pattern_damage_subscore",
178
  ]
179
  )
180
+ has_k_v2 = has_shared and (
181
+ ("market_family" in rows.columns and rows["market_family"].astype(str).str.lower().eq("k").any())
182
+ or "expected_strikeouts_v2" in rows.columns
183
+ or "expected_strikeouts" in rows.columns
184
+ )
185
  has_opportunity = has_shared and any(
186
  col in rows.columns
187
  for col in ["projected_pitch_count", "projected_batters_faced", "projected_innings"]
 
784
  # ------------------------------------------------------------------
785
  # SECTION 5c — Execution Layer
786
  # ------------------------------------------------------------------
787
+ active_exec_df = st.session_state.get("props_exec_df")
788
+ props_modeled_market_bundle = st.session_state.get("props_modeled_market_bundle") or {}
789
+ props_market_debug_bundle = st.session_state.get("props_market_debug_bundle") or {}
790
+ combined_exec_frames: list[pd.DataFrame] = []
791
+ for payload in props_modeled_market_bundle.values():
792
+ mapped = payload.get("mapped", pd.DataFrame()) if isinstance(payload, dict) else pd.DataFrame()
793
+ if isinstance(mapped, pd.DataFrame) and not mapped.empty:
794
+ combined_exec_frames.append(mapped.copy())
795
+ exec_df = (
796
+ pd.concat(combined_exec_frames, ignore_index=True, sort=False)
797
+ if combined_exec_frames
798
+ else active_exec_df
799
+ )
800
  with st.expander("Execution Layer (Props)", expanded=False):
801
  if exec_df is None or (isinstance(exec_df, pd.DataFrame) and exec_df.empty):
802
  st.info("No execution layer data. Visit the Props tab first.")
 
901
  "model_hr_prob_source",
902
  ]
903
  with vm_tab_normalized:
904
+ st.write("Normalized props rows")
905
  st.dataframe(
906
  exec_df[[c for c in normalized_cols if c in exec_df.columns]],
907
  use_container_width=True,
 
1301
  )
1302
 
1303
  with st.expander("Props Baseline Diagnostics", expanded=False):
1304
+ baseline_debug_rows = []
1305
+ for market_key, payload in props_market_debug_bundle.items():
1306
+ baseline_debug = (payload or {}).get("baseline_debug") or {}
1307
+ if baseline_debug:
1308
+ baseline_debug_rows.append(
 
 
 
 
 
 
 
 
 
 
 
 
 
1309
  {
1310
+ "market_type": market_key,
1311
+ "baseline_source": baseline_debug.get("baseline_source"),
1312
+ "coverage_mode": baseline_debug.get("snapshot_coverage_mode"),
1313
+ "runtime_fallback_used": baseline_debug.get("runtime_fallback_used"),
1314
+ "request_patch_used": baseline_debug.get("request_patch_used"),
1315
+ "background_refresh_queued": baseline_debug.get("background_refresh_queued"),
1316
+ "requested_hitter_count": baseline_debug.get("requested_hitter_count"),
1317
+ "resolved_hitter_count": baseline_debug.get("resolved_hitter_count"),
1318
+ "requested_pitcher_count": baseline_debug.get("requested_pitcher_count"),
1319
+ "resolved_pitcher_count": baseline_debug.get("resolved_pitcher_count"),
1320
+ "slate_team_scope": ", ".join(baseline_debug.get("slate_team_scope") or []),
1321
+ "missing_hitter_names": ", ".join(baseline_debug.get("missing_hitter_names") or []),
1322
+ "missing_pitcher_names": ", ".join(baseline_debug.get("missing_pitcher_names") or []),
1323
  }
1324
+ )
1325
+ if baseline_debug_rows:
1326
+ baseline_debug_df = pd.DataFrame(baseline_debug_rows)
1327
+ c1, c2, c3, c4 = st.columns(4)
1328
+ c1.metric("Markets Captured", len(baseline_debug_rows))
1329
+ c2.metric("Any Runtime Fallback", "Yes" if baseline_debug_df["runtime_fallback_used"].fillna(False).astype(bool).any() else "No")
1330
+ c3.metric("Any Request Patch", "Yes" if baseline_debug_df["request_patch_used"].fillna(False).astype(bool).any() else "No")
1331
+ c4.metric("Any Refresh Queued", "Yes" if baseline_debug_df["background_refresh_queued"].fillna(False).astype(bool).any() else "No")
1332
+ st.dataframe(baseline_debug_df, use_container_width=True, hide_index=True)
 
 
 
 
1333
  else:
1334
  st.info("Open the Props page in this session to capture Props baseline diagnostics.")
1335
 
1336
  with st.expander("Props HR Health Diagnostics", expanded=False):
1337
+ props_hr_health_debug = ((props_market_debug_bundle.get("hr") or {}).get("hr_health_debug")) or {}
1338
  if props_hr_health_debug:
1339
  c1, c2, c3, c4, c5 = st.columns(5)
1340
  c1.metric("Modeled 1+ HR Rows", int(props_hr_health_debug.get("modeled_hr_rows_total") or 0))
 
1364
  st.info("Open the Props page in this session to capture HR health diagnostics.")
1365
 
1366
  with st.expander("Shared Matchup Component Diagnostics", expanded=False):
1367
+ shared_component_rows = []
1368
+ executed_rows = []
1369
+ gating_rows = []
1370
+ failure_summary_rows = []
1371
+ for market_key, payload in props_market_debug_bundle.items():
1372
+ shared_component_debug = (payload or {}).get("shared_component_debug") or {}
1373
+ for row in shared_component_debug.get("rows") or []:
1374
+ shared_component_rows.append({"market_type": market_key, **row})
1375
+ for row in shared_component_debug.get("executed_rows") or []:
1376
+ executed_rows.append({"market_type": market_key, **row})
1377
+ for row in shared_component_debug.get("gating_rows") or []:
1378
+ gating_rows.append({"market_type": market_key, **row})
1379
+ for row in shared_component_debug.get("failure_summary") or []:
1380
+ failure_summary_rows.append({"market_type": market_key, **row})
1381
+ if shared_component_rows:
1382
+ summary_df = pd.DataFrame(failure_summary_rows)
1383
+ if not summary_df.empty:
1384
+ st.write("Failure Summary")
1385
+ st.dataframe(summary_df, use_container_width=True, hide_index=True)
1386
+ if gating_rows:
1387
+ st.write("Upstream Gating Failures")
1388
+ st.dataframe(pd.DataFrame(gating_rows), use_container_width=True, hide_index=True)
1389
+ if executed_rows:
1390
+ st.write("Executed Matchup Components")
1391
+ st.dataframe(pd.DataFrame(executed_rows), use_container_width=True, hide_index=True)
1392
  else:
1393
+ st.info("No shared-component execution rows captured in this session.")
1394
  else:
1395
  st.info("Open the Props page in this session to capture shared matchup diagnostics.")
1396
 
1397
  with st.expander("Model Grading Rubric", expanded=False):
1398
+ props_hr_health_debug = ((props_market_debug_bundle.get("hr") or {}).get("hr_health_debug")) or {}
1399
+ combined_shared_component_debug = {
1400
+ "rows": [
1401
+ row
1402
+ for payload in props_market_debug_bundle.values()
1403
+ for row in ((payload or {}).get("shared_component_debug") or {}).get("executed_rows", [])
1404
+ ]
1405
+ }
1406
  rubric_df, rubric_summary = _build_model_upgrade_rubric(
1407
  props_hr_health_debug=props_hr_health_debug,
1408
+ shared_component_debug=combined_shared_component_debug,
1409
  )
1410
  c1, c2, c3 = st.columns(3)
1411
  c1.metric(
 
1490
  "source": "cached_probable_starters",
1491
  "row_count": int(starters_meta.iloc[0]["matchup_count"]) if not starters_meta.empty else 0,
1492
  "latest_fetched_at": starters_meta.iloc[0]["fetched_at"] if not starters_meta.empty else None,
1493
+ "refresh_mode": st.session_state.get("probable_starters_refresh_mode"),
1494
+ "cache_age_seconds": st.session_state.get("probable_starters_cache_age_seconds"),
1495
  }
1496
  )
1497
  except Exception:
visualization/props_page.py CHANGED
@@ -24,7 +24,7 @@ from analytics.props_view_model import (
24
  build_hr_props_view_model,
25
  select_best_lines_per_prop,
26
  )
27
- from data.shared_baseline import load_or_build_shared_baseline_bundle
28
  from data.live_prop_odds import fetch_all_upcoming_hr_props
29
  from database.db import (
30
  ensure_upcoming_hr_props_table,
@@ -635,7 +635,7 @@ def _load_props_market_baseline_bundle(
635
  hitter_names: tuple[str, ...],
636
  pitcher_names: tuple[str, ...],
637
  ) -> dict[str, Any]:
638
- bundle = load_or_build_shared_baseline_bundle(
639
  batter_names=hitter_names,
640
  pitcher_names=pitcher_names,
641
  max_age_seconds=60 * 60,
@@ -657,6 +657,7 @@ def _load_props_market_baseline_bundle(
657
  "missing_pitcher_names": bundle.get("missing_pitcher_names", []),
658
  "snapshot_coverage_mode": bundle.get("snapshot_coverage_mode"),
659
  "background_refresh_queued": bundle.get("background_refresh_queued"),
 
660
  }
661
 
662
 
@@ -742,6 +743,49 @@ def _build_market_modeling_payload(
742
  }
743
 
744
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
745
  @st.cache_data(ttl=60 * 10, show_spinner=False)
746
  def _build_modeled_market_bundle(
747
  raw: pd.DataFrame,
@@ -774,42 +818,13 @@ def _hydrate_props_debug_state(
774
  market_type: str,
775
  payload: dict[str, Any],
776
  ) -> None:
777
- baseline_request = payload.get("baseline_request") or {}
778
- baseline_bundle = payload.get("baseline_bundle") or {}
779
- mapped = payload.get("mapped", pd.DataFrame())
780
-
781
- st.session_state["props_baseline_debug"] = {
782
- "market_type": market_type,
783
- "slate_team_scope": list(baseline_request.get("slate_team_scope") or []),
784
- "requested_hitter_count": int(baseline_bundle.get("requested_hitter_count", len(baseline_request.get("hitter_names") or ()))),
785
- "requested_pitcher_count": int(baseline_bundle.get("requested_pitcher_count", len(baseline_request.get("pitcher_names") or ()))),
786
- "resolved_hitter_count": int(baseline_bundle.get("resolved_hitter_count", 0)),
787
- "resolved_pitcher_count": int(baseline_bundle.get("resolved_pitcher_count", 0)),
788
- "missing_hitter_names": list(baseline_bundle.get("missing_hitter_names", [])),
789
- "missing_pitcher_names": list(baseline_bundle.get("missing_pitcher_names", [])),
790
- "snapshot_coverage_mode": str(baseline_bundle.get("snapshot_coverage_mode") or "unknown"),
791
- "runtime_fallback_used": bool(baseline_bundle.get("runtime_fallback_used")),
792
- "background_refresh_queued": bool(baseline_bundle.get("background_refresh_queued")),
793
- "baseline_source": str(baseline_bundle.get("snapshot_source_status") or "unknown"),
794
- }
795
-
796
  if market_type == "hr":
797
- st.session_state["props_hr_health_debug"] = _build_hr_health_debug(
798
- mapped,
799
- extra_context={
800
- "requested_hitter_count": st.session_state.get("props_baseline_debug", {}).get("requested_hitter_count"),
801
- "resolved_hitter_count": st.session_state.get("props_baseline_debug", {}).get("resolved_hitter_count"),
802
- "requested_pitcher_count": st.session_state.get("props_baseline_debug", {}).get("requested_pitcher_count"),
803
- "resolved_pitcher_count": st.session_state.get("props_baseline_debug", {}).get("resolved_pitcher_count"),
804
- },
805
- )
806
  else:
807
  st.session_state.pop("props_hr_health_debug", None)
808
-
809
- st.session_state["props_shared_component_debug"] = _build_shared_component_debug(
810
- mapped,
811
- market_type=market_type,
812
- )
813
 
814
 
815
  def _build_best_on_slate_source(
@@ -1197,9 +1212,10 @@ def _build_hr_health_debug(display: pd.DataFrame, extra_context: dict[str, Any]
1197
 
1198
  def _build_shared_component_debug(display: pd.DataFrame, market_type: str) -> dict[str, Any]:
1199
  if display is None or display.empty:
1200
- return {"market_type": market_type, "rows": []}
1201
 
1202
  cols = [
 
1203
  "player_name",
1204
  "player_name_raw",
1205
  "display_label",
@@ -1245,10 +1261,65 @@ def _build_shared_component_debug(display: pd.DataFrame, market_type: str) -> di
1245
  "predicted_whiff_regions",
1246
  "component_source_map",
1247
  "expected_pitch_family_mix",
 
 
 
 
1248
  ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1249
  return {
1250
  "market_type": market_type,
1251
- "rows": display[[c for c in cols if c in display.columns]].head(30).to_dict("records"),
 
 
 
1252
  }
1253
 
1254
 
@@ -1290,9 +1361,9 @@ def render_props_hero(display_df: pd.DataFrame, view_model: dict[str, Any] | Non
1290
  hero_cols = st.columns(6)
1291
  hero_cols[0].metric("Games", int(len(games_summary_df)) if not games_summary_df.empty else int(display_df["event_id"].nunique() if "event_id" in display_df.columns else 0))
1292
  hero_cols[1].metric("Books", len(available_books))
1293
- hero_cols[2].metric("Modeled 1+ HR", int(len(modeled_display)) if not modeled_display.empty else 0)
1294
- hero_cols[3].metric("Best Edge", _format_edge(float(best_edge)) if best_edge is not None else "-")
1295
- hero_cols[4].metric("Best EV", _format_ev(float(best_ev)) if best_ev is not None else "-")
1296
  hero_cols[5].metric("Avg Featured EV", _format_ev(float(avg_featured_ev)) if avg_featured_ev is not None else "-")
1297
 
1298
 
@@ -1719,10 +1790,10 @@ def _render_summary_metrics(display: pd.DataFrame, market_type: str) -> None:
1719
  if market_type == "hr":
1720
  col1, col2, col3 = st.columns(3)
1721
  modeled_display = _modeled_hr_primary_subset(display)
1722
- col1.metric("Props shown", len(modeled_display))
1723
  with_edge = modeled_display["edge"].dropna() if "edge" in modeled_display.columns else pd.Series(dtype=float)
1724
  with_ev = modeled_display["bet_ev"].dropna() if "bet_ev" in modeled_display.columns else pd.Series(dtype=float)
1725
- col2.metric("With model edge", len(with_edge))
1726
  col3.metric("Best EV", _format_ev(float(with_ev.max())) if not with_ev.empty else "-")
1727
  else:
1728
  col1, col2, col3 = st.columns(3)
@@ -1842,6 +1913,10 @@ def render_props(
1842
  probable_starters=probable_starters,
1843
  )
1844
  st.session_state["props_modeled_market_bundle"] = modeled_market_bundle
 
 
 
 
1845
 
1846
  available_markets = sorted(raw["market"].dropna().unique().tolist())
1847
  default_idx = available_markets.index("hr") if "hr" in available_markets else 0
 
24
  build_hr_props_view_model,
25
  select_best_lines_per_prop,
26
  )
27
+ from data.shared_baseline import load_or_build_shared_baseline_bundle_complete_for_request
28
  from data.live_prop_odds import fetch_all_upcoming_hr_props
29
  from database.db import (
30
  ensure_upcoming_hr_props_table,
 
635
  hitter_names: tuple[str, ...],
636
  pitcher_names: tuple[str, ...],
637
  ) -> dict[str, Any]:
638
+ bundle = load_or_build_shared_baseline_bundle_complete_for_request(
639
  batter_names=hitter_names,
640
  pitcher_names=pitcher_names,
641
  max_age_seconds=60 * 60,
 
657
  "missing_pitcher_names": bundle.get("missing_pitcher_names", []),
658
  "snapshot_coverage_mode": bundle.get("snapshot_coverage_mode"),
659
  "background_refresh_queued": bundle.get("background_refresh_queued"),
660
+ "request_patch_used": bundle.get("request_patch_used"),
661
  }
662
 
663
 
 
743
  }
744
 
745
 
746
+ def _build_props_market_debug_payload(
747
+ *,
748
+ market_type: str,
749
+ payload: dict[str, Any],
750
+ ) -> dict[str, Any]:
751
+ baseline_request = payload.get("baseline_request") or {}
752
+ baseline_bundle = payload.get("baseline_bundle") or {}
753
+ mapped = payload.get("mapped", pd.DataFrame())
754
+ baseline_debug = {
755
+ "market_type": market_type,
756
+ "slate_team_scope": list(baseline_request.get("slate_team_scope") or []),
757
+ "requested_hitter_count": int(baseline_bundle.get("requested_hitter_count", len(baseline_request.get("hitter_names") or ()))),
758
+ "requested_pitcher_count": int(baseline_bundle.get("requested_pitcher_count", len(baseline_request.get("pitcher_names") or ()))),
759
+ "resolved_hitter_count": int(baseline_bundle.get("resolved_hitter_count", 0)),
760
+ "resolved_pitcher_count": int(baseline_bundle.get("resolved_pitcher_count", 0)),
761
+ "missing_hitter_names": list(baseline_bundle.get("missing_hitter_names", [])),
762
+ "missing_pitcher_names": list(baseline_bundle.get("missing_pitcher_names", [])),
763
+ "snapshot_coverage_mode": str(baseline_bundle.get("snapshot_coverage_mode") or "unknown"),
764
+ "runtime_fallback_used": bool(baseline_bundle.get("runtime_fallback_used")),
765
+ "background_refresh_queued": bool(baseline_bundle.get("background_refresh_queued")),
766
+ "request_patch_used": bool(baseline_bundle.get("request_patch_used")),
767
+ "baseline_source": str(baseline_bundle.get("snapshot_source_status") or "unknown"),
768
+ }
769
+ hr_health = None
770
+ if market_type == "hr":
771
+ hr_health = _build_hr_health_debug(
772
+ mapped,
773
+ extra_context={
774
+ "requested_hitter_count": baseline_debug.get("requested_hitter_count"),
775
+ "resolved_hitter_count": baseline_debug.get("resolved_hitter_count"),
776
+ "requested_pitcher_count": baseline_debug.get("requested_pitcher_count"),
777
+ "resolved_pitcher_count": baseline_debug.get("resolved_pitcher_count"),
778
+ },
779
+ )
780
+ return {
781
+ "market_type": market_type,
782
+ "baseline_debug": baseline_debug,
783
+ "hr_health_debug": hr_health,
784
+ "shared_component_debug": _build_shared_component_debug(mapped, market_type=market_type),
785
+ "mapped": mapped,
786
+ }
787
+
788
+
789
  @st.cache_data(ttl=60 * 10, show_spinner=False)
790
  def _build_modeled_market_bundle(
791
  raw: pd.DataFrame,
 
818
  market_type: str,
819
  payload: dict[str, Any],
820
  ) -> None:
821
+ debug_payload = _build_props_market_debug_payload(market_type=market_type, payload=payload)
822
+ st.session_state["props_baseline_debug"] = debug_payload.get("baseline_debug") or {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
823
  if market_type == "hr":
824
+ st.session_state["props_hr_health_debug"] = debug_payload.get("hr_health_debug") or {}
 
 
 
 
 
 
 
 
825
  else:
826
  st.session_state.pop("props_hr_health_debug", None)
827
+ st.session_state["props_shared_component_debug"] = debug_payload.get("shared_component_debug") or {}
 
 
 
 
828
 
829
 
830
  def _build_best_on_slate_source(
 
1212
 
1213
  def _build_shared_component_debug(display: pd.DataFrame, market_type: str) -> dict[str, Any]:
1214
  if display is None or display.empty:
1215
+ return {"market_type": market_type, "rows": [], "executed_rows": [], "gating_rows": [], "failure_summary": []}
1216
 
1217
  cols = [
1218
+ "market_family",
1219
  "player_name",
1220
  "player_name_raw",
1221
  "display_label",
 
1261
  "predicted_whiff_regions",
1262
  "component_source_map",
1263
  "expected_pitch_family_mix",
1264
+ "baseline_mode",
1265
+ "pitcher_resolution_status",
1266
+ "modeled_row_available",
1267
+ "modeled_row_missing_reason",
1268
  ]
1269
+ working = display[[c for c in cols if c in display.columns]].copy()
1270
+
1271
+ def _status(row: pd.Series) -> str:
1272
+ baseline_mode = str(row.get("baseline_mode") or "").strip().lower()
1273
+ starter_status = str(row.get("projected_starter_match_status") or "").strip().lower()
1274
+ pitcher_status = str(row.get("pitcher_resolution_status") or "").strip().lower()
1275
+ shared_available = str(row.get("shared_matchup_available") or "").strip().lower()
1276
+ telemetry_status = str(row.get("telemetry_path_status") or "").strip().lower()
1277
+ if not baseline_mode or baseline_mode in {"none", "nan", "unavailable"}:
1278
+ return "missing_baseline"
1279
+ if starter_status == "projected_starter_unavailable":
1280
+ return "projected_starter_unavailable"
1281
+ if starter_status == "projected_starter_available_but_unresolved":
1282
+ return "projected_starter_available_but_unresolved"
1283
+ if pitcher_status in {"pitcher_missing", "unresolved", "matchup_incomplete", "resolved_no_pitcher_statcast"}:
1284
+ return pitcher_status or "pitcher_resolution_failure"
1285
+ if shared_available in {"1", "true", "yes"} or telemetry_status in {
1286
+ "full_telemetry",
1287
+ "partial_telemetry",
1288
+ "core_baseline_plus_projected_pitcher",
1289
+ "baseline_only_degraded",
1290
+ }:
1291
+ return "executed"
1292
+ component_cols = [
1293
+ "damage_zone_alignment_subscore",
1294
+ "pitch_mix_exposure_subscore",
1295
+ "tunnel_damage_subscore",
1296
+ "count_pattern_damage_subscore",
1297
+ "handedness_damage_subscore",
1298
+ "arsenal_fit_subscore",
1299
+ "zone_matchup_subscore",
1300
+ "family_zone_matchup_subscore",
1301
+ "tunneling_subscore",
1302
+ "sequencing_subscore",
1303
+ ]
1304
+ if any(pd.notna(row.get(col)) for col in component_cols):
1305
+ return "executed"
1306
+ return "prerequisites_not_met"
1307
+
1308
+ working["component_execution_status"] = working.apply(_status, axis=1)
1309
+ executed = working[working["component_execution_status"] == "executed"].copy()
1310
+ gating = working[working["component_execution_status"] != "executed"].copy()
1311
+ failure_summary = (
1312
+ working["component_execution_status"]
1313
+ .value_counts(dropna=False)
1314
+ .rename_axis("failure_reason")
1315
+ .reset_index(name="row_count")
1316
+ )
1317
  return {
1318
  "market_type": market_type,
1319
+ "rows": working.head(30).to_dict("records"),
1320
+ "executed_rows": executed.head(30).to_dict("records"),
1321
+ "gating_rows": gating.head(30).to_dict("records"),
1322
+ "failure_summary": failure_summary.to_dict("records"),
1323
  }
1324
 
1325
 
 
1361
  hero_cols = st.columns(6)
1362
  hero_cols[0].metric("Games", int(len(games_summary_df)) if not games_summary_df.empty else int(display_df["event_id"].nunique() if "event_id" in display_df.columns else 0))
1363
  hero_cols[1].metric("Books", len(available_books))
1364
+ hero_cols[2].metric("Modeled 1+ HR Rows", int(len(modeled_display)) if not modeled_display.empty else 0)
1365
+ hero_cols[3].metric("Best Edge (modeled)", _format_edge(float(best_edge)) if best_edge is not None else "-")
1366
+ hero_cols[4].metric("Best EV (modeled)", _format_ev(float(best_ev)) if best_ev is not None else "-")
1367
  hero_cols[5].metric("Avg Featured EV", _format_ev(float(avg_featured_ev)) if avg_featured_ev is not None else "-")
1368
 
1369
 
 
1790
  if market_type == "hr":
1791
  col1, col2, col3 = st.columns(3)
1792
  modeled_display = _modeled_hr_primary_subset(display)
1793
+ col1.metric("Shown modeled 1+ HR", len(modeled_display))
1794
  with_edge = modeled_display["edge"].dropna() if "edge" in modeled_display.columns else pd.Series(dtype=float)
1795
  with_ev = modeled_display["bet_ev"].dropna() if "bet_ev" in modeled_display.columns else pd.Series(dtype=float)
1796
+ col2.metric("Shown rows with priced edge", len(with_edge))
1797
  col3.metric("Best EV", _format_ev(float(with_ev.max())) if not with_ev.empty else "-")
1798
  else:
1799
  col1, col2, col3 = st.columns(3)
 
1913
  probable_starters=probable_starters,
1914
  )
1915
  st.session_state["props_modeled_market_bundle"] = modeled_market_bundle
1916
+ st.session_state["props_market_debug_bundle"] = {
1917
+ market: _build_props_market_debug_payload(market_type=market, payload=payload)
1918
+ for market, payload in modeled_market_bundle.items()
1919
+ }
1920
 
1921
  available_markets = sorted(raw["market"].dropna().unique().tolist())
1922
  default_idx = available_markets.index("hr") if "hr" in available_markets else 0