Spaces:
Sleeping
Sleeping
Stabilize props baseline and starter resolution
Browse files- analytics/props_mapper.py +79 -22
- app.py +24 -1
- data/shared_baseline.py +273 -75
- tests/test_props_mapper.py +212 -0
- tests/test_shared_baseline.py +4 -1
- visualization/debug_page.py +84 -54
- visualization/props_page.py +117 -42
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).
|
| 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
|
| 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).
|
| 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 |
-
|
| 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 |
-
|
| 535 |
-
if projected_home_pitcher and projected_home_pitcher.casefold() == resolved_norm:
|
| 536 |
return "matched_projected_home"
|
| 537 |
-
if projected_away_pitcher
|
| 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
|
| 563 |
return (away_team, home_team)
|
| 564 |
-
if home_pitcher
|
| 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
|
| 594 |
-
or (projected_away
|
| 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 =
|
| 649 |
if not normalized_target:
|
| 650 |
return default
|
| 651 |
|
| 652 |
-
normalized_series = statcast_df["player_name"].astype(str).map(
|
| 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] =
|
| 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(
|
|
|
|
|
|
|
| 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 |
-
|
| 361 |
-
if
|
| 362 |
continue
|
| 363 |
-
seen.add(
|
| 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
|
| 1390 |
-
|
| 1391 |
-
|
| 1392 |
-
|
| 1393 |
-
|
| 1394 |
-
|
| 1395 |
-
|
| 1396 |
-
|
| 1397 |
-
|
| 1398 |
-
|
| 1399 |
-
|
| 1400 |
-
|
| 1401 |
-
|
| 1402 |
-
|
| 1403 |
-
|
| 1404 |
-
|
| 1405 |
-
|
| 1406 |
-
|
| 1407 |
-
.astype(str)
|
| 1408 |
-
.tolist()
|
| 1409 |
-
}
|
| 1410 |
|
| 1411 |
-
missing_hitter_names =
|
| 1412 |
-
|
| 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 |
-
|
| 1443 |
-
|
| 1444 |
-
|
| 1445 |
-
|
| 1446 |
-
|
| 1447 |
-
|
| 1448 |
-
|
| 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 |
-
|
| 1464 |
-
|
| 1465 |
-
|
| 1466 |
-
|
| 1467 |
-
|
| 1468 |
-
|
| 1469 |
-
|
| 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 |
-
|
| 1489 |
-
|
| 1490 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1491 |
.get("player_name", pd.Series(dtype="object"))
|
| 1492 |
.dropna()
|
| 1493 |
.astype(str)
|
| 1494 |
.tolist()
|
| 1495 |
-
|
| 1496 |
-
|
| 1497 |
-
|
| 1498 |
-
|
|
|
|
| 1499 |
.get("player_name", pd.Series(dtype="object"))
|
| 1500 |
.dropna()
|
| 1501 |
.astype(str)
|
| 1502 |
.tolist()
|
| 1503 |
-
|
| 1504 |
-
|
| 1505 |
-
|
| 1506 |
-
|
| 1507 |
-
|
| 1508 |
-
|
| 1509 |
-
|
| 1510 |
-
|
| 1511 |
-
|
| 1512 |
-
|
| 1513 |
-
|
| 1514 |
-
|
| 1515 |
-
|
| 1516 |
-
|
| 1517 |
-
|
| 1518 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 1289 |
-
|
| 1290 |
-
|
| 1291 |
-
|
| 1292 |
-
|
| 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":
|
| 1308 |
-
"baseline_source":
|
| 1309 |
-
"
|
| 1310 |
-
"
|
| 1311 |
-
"
|
| 1312 |
-
"
|
| 1313 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1314 |
}
|
| 1315 |
-
|
| 1316 |
-
|
| 1317 |
-
|
| 1318 |
-
|
| 1319 |
-
|
| 1320 |
-
|
| 1321 |
-
|
| 1322 |
-
|
| 1323 |
-
|
| 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 =
|
| 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 |
-
|
| 1363 |
-
|
| 1364 |
-
|
| 1365 |
-
|
| 1366 |
-
|
| 1367 |
-
|
| 1368 |
-
|
| 1369 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 1377 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1378 |
rubric_df, rubric_summary = _build_model_upgrade_rubric(
|
| 1379 |
props_hr_health_debug=props_hr_health_debug,
|
| 1380 |
-
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
|
| 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 =
|
| 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 |
-
|
| 778 |
-
|
| 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"] =
|
| 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":
|
|
|
|
|
|
|
|
|
|
| 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("
|
| 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("
|
| 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
|