Spaces:
Running
Running
| """ | |
| visualization/props_page.py | |
| Props page for upcoming HR props. | |
| This module now provides a decomposed layout scaffold for the upcoming Props | |
| redesign: hero summary, featured props, by-game explorer, and a legacy flat | |
| table for continuity while the premium UI is built out. | |
| """ | |
| from __future__ import annotations | |
| import datetime | |
| import threading | |
| from typing import Any | |
| import pandas as pd | |
| import streamlit as st | |
| from analytics.props_mapper import map_props_to_models | |
| from analytics.props_view_model import ( | |
| build_best_on_slate_df, | |
| build_best_on_slate_summary, | |
| build_hr_props_view_model, | |
| select_best_lines_per_prop, | |
| ) | |
| from data.shared_baseline import load_or_build_shared_baseline_bundle_complete_for_request | |
| from data.live_prop_odds import fetch_all_upcoming_hr_props | |
| from database.db import ( | |
| ensure_upcoming_hr_props_table, | |
| get_connection, | |
| insert_upcoming_hr_props, | |
| ) | |
| from utils.helpers import utc_now_iso | |
| from data.mlb_starters import ( | |
| _canonical_team, | |
| build_oddsapi_starter_fallback_map, | |
| lookup_pitchers_for_game, | |
| merge_probable_starters_with_odds_fallback, | |
| ) | |
| _PROPS_ASYNC_LOCK = threading.Lock() | |
| _PROPS_ASYNC_KEYS: set[str] = set() | |
| def _queue_props_async_refresh(key: str, fn) -> bool: | |
| with _PROPS_ASYNC_LOCK: | |
| if key in _PROPS_ASYNC_KEYS: | |
| return False | |
| _PROPS_ASYNC_KEYS.add(key) | |
| def _run() -> None: | |
| try: | |
| fn() | |
| finally: | |
| with _PROPS_ASYNC_LOCK: | |
| _PROPS_ASYNC_KEYS.discard(key) | |
| threading.Thread(target=_run, daemon=True).start() | |
| return True | |
| def _render_props_ui_styles() -> None: | |
| st.markdown( | |
| """ | |
| <style> | |
| .props-hero { | |
| padding: 1.2rem 1.2rem 1rem 1.2rem; | |
| border: 1px solid rgba(84, 196, 119, 0.24); | |
| border-radius: 18px; | |
| background: | |
| radial-gradient(circle at top right, rgba(38, 96, 64, 0.28), transparent 34%), | |
| linear-gradient(180deg, rgba(13, 21, 35, 0.96), rgba(8, 15, 28, 0.98)); | |
| box-shadow: inset 0 1px 0 rgba(255,255,255,0.04); | |
| margin-bottom: 0.9rem; | |
| } | |
| .props-hero-kicker { | |
| letter-spacing: 0.18em; | |
| text-transform: uppercase; | |
| font-size: 0.72rem; | |
| color: #78d99a; | |
| font-weight: 700; | |
| } | |
| .props-hero-title { | |
| font-size: 2.1rem; | |
| line-height: 1.05; | |
| font-weight: 800; | |
| color: #f7fafc; | |
| margin-top: 0.35rem; | |
| } | |
| .props-hero-sub { | |
| color: #9fb3c8; | |
| margin-top: 0.35rem; | |
| font-size: 0.95rem; | |
| } | |
| .props-hero-note { | |
| margin-top: 0.8rem; | |
| display: inline-block; | |
| padding: 0.32rem 0.72rem; | |
| border-radius: 999px; | |
| border: 1px solid rgba(102, 198, 133, 0.22); | |
| background: rgba(34, 73, 48, 0.28); | |
| color: #b8e7c6; | |
| font-size: 0.78rem; | |
| } | |
| .props-filter-rail { | |
| padding: 0.95rem 1rem 0.55rem 1rem; | |
| border: 1px solid rgba(74, 103, 145, 0.30); | |
| border-radius: 16px; | |
| background: linear-gradient(180deg, rgba(17, 25, 40, 0.92), rgba(10, 17, 29, 0.96)); | |
| margin: 0.6rem 0 1rem 0; | |
| } | |
| .props-filter-sub { | |
| color: #90a8c2; | |
| font-size: 0.82rem; | |
| margin-bottom: 0.55rem; | |
| } | |
| .props-section-kicker { | |
| letter-spacing: 0.16em; | |
| text-transform: uppercase; | |
| font-size: 0.70rem; | |
| color: #6fd2ff; | |
| font-weight: 700; | |
| margin-bottom: 0.35rem; | |
| } | |
| .props-section-gap { | |
| height: 0.85rem; | |
| } | |
| .props-legend { | |
| padding: 0.9rem 1rem; | |
| border: 1px solid rgba(92, 122, 168, 0.26); | |
| border-radius: 16px; | |
| background: rgba(10, 17, 29, 0.88); | |
| margin: 0.8rem 0 0.9rem 0; | |
| } | |
| .props-legend-grid { | |
| display: grid; | |
| grid-template-columns: repeat(2, minmax(0, 1fr)); | |
| gap: 0.55rem 1rem; | |
| } | |
| .props-legend-item { | |
| color: #b3c2d2; | |
| font-size: 0.88rem; | |
| } | |
| .props-legend-item strong { | |
| color: #f4f8ff; | |
| } | |
| .props-card { | |
| border: 1px solid rgba(69, 109, 173, 0.34); | |
| border-left: 4px solid #3984ff; | |
| border-radius: 18px; | |
| padding: 1rem 1rem 0.85rem 1rem; | |
| background: | |
| linear-gradient(180deg, rgba(18, 26, 43, 0.98), rgba(10, 16, 29, 0.98)); | |
| min-height: 220px; | |
| box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12); | |
| } | |
| .props-card.top { | |
| border-left-color: #58d26f; | |
| box-shadow: 0 0 0 1px rgba(88, 210, 111, 0.12), 0 10px 28px rgba(0, 0, 0, 0.16); | |
| } | |
| .props-badge { | |
| display: inline-block; | |
| border: 1px solid rgba(91, 146, 255, 0.35); | |
| color: #83b2ff; | |
| border-radius: 999px; | |
| padding: 0.15rem 0.55rem; | |
| font-size: 0.70rem; | |
| font-weight: 700; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| margin-bottom: 0.7rem; | |
| } | |
| .props-badge.success { | |
| border-color: rgba(88, 210, 111, 0.45); | |
| color: #87e29c; | |
| } | |
| .props-card-head { | |
| display: flex; | |
| flex-wrap: wrap; | |
| align-items: center; | |
| gap: 0.55rem; | |
| margin-bottom: 0.7rem; | |
| } | |
| .props-player { | |
| font-size: 1.15rem; | |
| font-weight: 750; | |
| color: #f8fbff; | |
| margin-bottom: 0.2rem; | |
| } | |
| .props-matchup { | |
| color: #a3b3c7; | |
| font-size: 0.88rem; | |
| margin-bottom: 0.85rem; | |
| } | |
| .props-line { | |
| color: #d7e2ef; | |
| font-size: 0.90rem; | |
| margin-bottom: 0.9rem; | |
| } | |
| .props-metric-grid { | |
| display: grid; | |
| grid-template-columns: repeat(2, minmax(0, 1fr)); | |
| gap: 0.65rem 0.9rem; | |
| } | |
| .props-metric-label { | |
| color: #88a0bb; | |
| font-size: 0.72rem; | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| } | |
| .props-metric-value { | |
| color: #f3f8ff; | |
| font-size: 1.15rem; | |
| font-weight: 760; | |
| } | |
| .props-metric-value.edge { | |
| color: #76de8e; | |
| } | |
| .props-metric-value.good { | |
| color: #6ef08d; | |
| } | |
| .props-metric-value.neutral { | |
| color: #f3cd65; | |
| } | |
| .props-metric-value.bad { | |
| color: #ff7f7f; | |
| } | |
| .props-metric-subvalue { | |
| color: #88a0bb; | |
| font-size: 0.72rem; | |
| line-height: 1.25; | |
| margin-top: 0.12rem; | |
| min-height: 1rem; | |
| } | |
| .props-verdict { | |
| display: inline-block; | |
| border-radius: 999px; | |
| padding: 0.18rem 0.56rem; | |
| font-size: 0.72rem; | |
| font-weight: 800; | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| margin-bottom: 0.6rem; | |
| } | |
| .props-verdict.bet { color: #8ef7a3; background: rgba(54, 121, 71, 0.28); border: 1px solid rgba(88, 210, 111, 0.35); } | |
| .props-verdict.watch { color: #f2d56c; background: rgba(100, 81, 26, 0.30); border: 1px solid rgba(240, 196, 73, 0.28); } | |
| .props-verdict.pass { color: #ff8a8a; background: rgba(104, 35, 35, 0.26); border: 1px solid rgba(239, 68, 68, 0.28); } | |
| .props-verdict.tracked { color: #9eb5cc; background: rgba(55, 71, 93, 0.26); border: 1px solid rgba(134, 159, 188, 0.20); } | |
| .props-voice { | |
| margin-top: 0.85rem; | |
| padding-top: 0.7rem; | |
| border-top: 1px solid rgba(128, 151, 179, 0.14); | |
| } | |
| .props-voice-line { | |
| color: #afc0d2; | |
| font-size: 0.83rem; | |
| line-height: 1.45; | |
| margin-top: 0.18rem; | |
| } | |
| .props-voice-line strong { | |
| color: #f3f8ff; | |
| } | |
| .props-voice-card { | |
| display: -webkit-box; | |
| -webkit-line-clamp: 3; | |
| -webkit-box-orient: vertical; | |
| overflow: hidden; | |
| min-height: 3.7rem; | |
| } | |
| .props-game-card { | |
| border: 1px solid rgba(62, 88, 125, 0.32); | |
| border-radius: 16px; | |
| background: linear-gradient(180deg, rgba(14, 22, 36, 0.92), rgba(10, 16, 28, 0.96)); | |
| padding: 0.9rem 1rem; | |
| margin-bottom: 0.4rem; | |
| } | |
| .props-game-head { | |
| display: flex; | |
| justify-content: space-between; | |
| gap: 1rem; | |
| align-items: baseline; | |
| } | |
| .props-game-title { | |
| font-size: 1rem; | |
| font-weight: 750; | |
| color: #f4f8fd; | |
| } | |
| .props-game-meta { | |
| color: #9fb1c5; | |
| font-size: 0.85rem; | |
| } | |
| .props-game-top { | |
| color: #7bdc93; | |
| font-size: 0.82rem; | |
| margin-top: 0.28rem; | |
| } | |
| .props-unmodeled-note { | |
| border: 1px dashed rgba(223, 176, 93, 0.35); | |
| border-radius: 12px; | |
| padding: 0.7rem 0.85rem; | |
| color: #e1c48b; | |
| background: rgba(72, 52, 17, 0.18); | |
| margin-top: 0.55rem; | |
| } | |
| .props-release-note { | |
| border: 1px solid rgba(84, 117, 171, 0.26); | |
| border-radius: 14px; | |
| padding: 0.85rem 0.95rem; | |
| background: rgba(14, 20, 31, 0.78); | |
| color: #afc0d3; | |
| margin: 0.9rem 0 1.1rem 0; | |
| } | |
| .props-ops-header { | |
| border: 1px solid rgba(123, 145, 120, 0.28); | |
| border-radius: 18px; | |
| padding: 1rem 1.1rem; | |
| background: | |
| radial-gradient(circle at top right, rgba(78, 104, 74, 0.18), transparent 32%), | |
| linear-gradient(180deg, rgba(18, 23, 20, 0.98), rgba(10, 14, 16, 0.98)); | |
| margin-bottom: 0.85rem; | |
| } | |
| .props-ops-kicker { | |
| letter-spacing: 0.16em; | |
| text-transform: uppercase; | |
| font-size: 0.68rem; | |
| color: #b0c98f; | |
| font-weight: 800; | |
| } | |
| .props-ops-title { | |
| color: #f5f2e8; | |
| font-size: 1.85rem; | |
| font-weight: 850; | |
| line-height: 1.02; | |
| margin-top: 0.35rem; | |
| } | |
| .props-ops-sub { | |
| color: #b8c0b4; | |
| font-size: 0.92rem; | |
| margin-top: 0.35rem; | |
| } | |
| .props-terminal-board { | |
| border: 1px solid rgba(114, 132, 108, 0.26); | |
| border-radius: 18px; | |
| padding: 1rem; | |
| background: linear-gradient(180deg, rgba(17, 20, 18, 0.98), rgba(11, 14, 15, 0.98)); | |
| margin-bottom: 1rem; | |
| } | |
| .props-terminal-highlight { | |
| border: 1px solid rgba(165, 184, 114, 0.28); | |
| border-left: 4px solid #bccb62; | |
| border-radius: 16px; | |
| padding: 1rem 1rem 0.9rem 1rem; | |
| background: linear-gradient(180deg, rgba(31, 36, 30, 0.96), rgba(18, 23, 22, 0.98)); | |
| margin-bottom: 0.8rem; | |
| } | |
| .props-terminal-rank { | |
| display: inline-block; | |
| color: #121512; | |
| background: #d6dd9e; | |
| border-radius: 999px; | |
| padding: 0.12rem 0.48rem; | |
| font-size: 0.72rem; | |
| font-weight: 800; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| margin-bottom: 0.55rem; | |
| } | |
| .props-terminal-mini { | |
| border: 1px solid rgba(88, 99, 91, 0.32); | |
| border-radius: 14px; | |
| padding: 0.8rem 0.85rem; | |
| background: rgba(19, 22, 22, 0.94); | |
| min-height: 182px; | |
| } | |
| .props-terminal-name { | |
| color: #f6f2e8; | |
| font-weight: 760; | |
| font-size: 1.02rem; | |
| margin-bottom: 0.15rem; | |
| } | |
| .props-terminal-line { | |
| color: #bfc7bc; | |
| font-size: 0.82rem; | |
| margin-bottom: 0.7rem; | |
| } | |
| .props-terminal-thesis { | |
| color: #9fac9a; | |
| font-size: 0.8rem; | |
| line-height: 1.4; | |
| margin-top: 0.65rem; | |
| } | |
| .props-terminal-grid { | |
| display: grid; | |
| grid-template-columns: repeat(2, minmax(0, 1fr)); | |
| gap: 0.55rem 0.8rem; | |
| } | |
| .props-terminal-metric { | |
| color: #8e998f; | |
| font-size: 0.68rem; | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| } | |
| .props-terminal-value { | |
| color: #f4efe5; | |
| font-size: 1.02rem; | |
| font-weight: 760; | |
| } | |
| .props-terminal-value.good { color: #9fe09d; } | |
| .props-terminal-value.neutral { color: #e3c87f; } | |
| .props-terminal-value.bad { color: #f19797; } | |
| .props-game-nav { | |
| border: 1px solid rgba(93, 104, 98, 0.26); | |
| border-radius: 18px; | |
| padding: 0.95rem 1rem 0.6rem 1rem; | |
| background: linear-gradient(180deg, rgba(18, 20, 22, 0.96), rgba(11, 13, 15, 0.98)); | |
| margin-bottom: 1rem; | |
| } | |
| .props-game-nav-card { | |
| border: 1px solid rgba(79, 92, 88, 0.28); | |
| border-radius: 14px; | |
| padding: 0.75rem 0.8rem; | |
| background: rgba(20, 22, 24, 0.94); | |
| margin-bottom: 0.45rem; | |
| } | |
| .props-game-nav-card.selected { | |
| border-color: rgba(181, 198, 117, 0.42); | |
| box-shadow: 0 0 0 1px rgba(181, 198, 117, 0.16); | |
| } | |
| .props-game-nav-title { | |
| color: #f3efe5; | |
| font-size: 0.96rem; | |
| font-weight: 760; | |
| } | |
| .props-game-nav-meta { | |
| color: #9da89d; | |
| font-size: 0.77rem; | |
| margin-top: 0.25rem; | |
| line-height: 1.4; | |
| } | |
| .props-workspace { | |
| border: 1px solid rgba(100, 111, 105, 0.26); | |
| border-radius: 18px; | |
| padding: 1rem; | |
| background: linear-gradient(180deg, rgba(17, 20, 19, 0.98), rgba(11, 13, 14, 0.98)); | |
| margin-bottom: 1rem; | |
| } | |
| .props-workspace-title { | |
| color: #f6f2e8; | |
| font-size: 1.2rem; | |
| font-weight: 780; | |
| } | |
| .props-workspace-sub { | |
| color: #9eaa9e; | |
| font-size: 0.83rem; | |
| margin-top: 0.2rem; | |
| margin-bottom: 0.85rem; | |
| } | |
| .props-secondary-shell { | |
| border: 1px solid rgba(80, 93, 94, 0.24); | |
| border-radius: 16px; | |
| padding: 0.9rem 1rem; | |
| background: rgba(15, 18, 20, 0.9); | |
| margin-top: 0.8rem; | |
| } | |
| </style> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| def _format_pct(val: float | None) -> str: | |
| if val is None: | |
| return "-" | |
| return f"{val * 100:.1f}%" | |
| def _format_odds(val: int | float | None) -> str: | |
| if val is None: | |
| return "-" | |
| v = int(val) | |
| return f"+{v}" if v > 0 else str(v) | |
| def _format_edge(val: float | None) -> str: | |
| if val is None: | |
| return "-" | |
| return f"{val * 100:+.1f}%" | |
| def _format_ev(val: float | None) -> str: | |
| if val is None: | |
| return "-" | |
| return f"{val:+.2f}u" | |
| def _format_confidence(val: float | None) -> str: | |
| if val is None: | |
| return "-" | |
| try: | |
| return f"{float(val):.0f}" | |
| except Exception: | |
| return "-" | |
| def _confidence_summary_label(value: Any) -> str: | |
| text = str(value or "").strip() | |
| return text if text else "-" | |
| def _build_confidence_metric_html(row: pd.Series | dict[str, Any]) -> str: | |
| score = _format_confidence(row.get("confidence_score")) | |
| summary = _confidence_summary_label(row.get("confidence_summary_label")) | |
| if summary == "-": | |
| summary = " " | |
| return ( | |
| f"<div class=\"props-metric-value\">{score}</div>" | |
| f"<div class=\"props-metric-subvalue\">{summary}</div>" | |
| ) | |
| def _render_confidence_breakdown(details_row: dict[str, Any]) -> None: | |
| raw_score = details_row.get("confidence_score_raw") | |
| display_score = details_row.get("confidence_score_display", details_row.get("confidence_score")) | |
| confidence_source = str(details_row.get("confidence_source") or "").strip() or "-" | |
| final_bucket = str(details_row.get("confidence_bucket") or details_row.get("confidence_bucket_display") or "-").strip() | |
| summary_label = str(details_row.get("confidence_summary_label") or "").strip() | |
| bonuses = details_row.get("confidence_component_bonuses") or [] | |
| penalties = details_row.get("confidence_component_penalties") or [] | |
| if ( | |
| raw_score is None | |
| and display_score is None | |
| and confidence_source == "-" | |
| and not bonuses | |
| and not penalties | |
| ): | |
| return | |
| st.caption("Confidence Breakdown") | |
| metric_cols = st.columns(4) | |
| metric_cols[0].metric("Raw/Base", _format_confidence(raw_score)) | |
| metric_cols[1].metric("Display", _format_confidence(display_score)) | |
| metric_cols[2].metric("Source", confidence_source.replace("_", " ").title()) | |
| metric_cols[3].metric("Bucket", final_bucket.title() if final_bucket else "-") | |
| if bonuses: | |
| st.write("Bonuses") | |
| for bonus in bonuses: | |
| st.write(f"- +{float(bonus.get('value') or 0.0):.0f} {str(bonus.get('label') or '-').strip()}") | |
| if penalties: | |
| st.write("Penalties") | |
| for penalty in penalties: | |
| value = float(penalty.get("value") or 0.0) | |
| prefix = f"-{value:.0f}" if value > 0 else "0" | |
| st.write(f"- {prefix} {str(penalty.get('label') or '-').strip()}") | |
| why_lines: list[str] = [] | |
| if summary_label: | |
| why_lines.append(f"Primary driver: {summary_label}") | |
| for reason in details_row.get("confidence_reasons") or []: | |
| if str(reason).strip(): | |
| why_lines.append(str(reason).strip()) | |
| if why_lines: | |
| st.write("Why It Landed Here") | |
| for line in why_lines: | |
| st.write(f"- {line}") | |
| def _market_label(value: Any) -> str: | |
| text = str(value or "").strip().lower() | |
| labels = { | |
| "hr": "1+ Home Runs", | |
| "k": "Pitcher Strikeouts", | |
| "no_hr": "No Home Run", | |
| } | |
| return labels.get(text, text.upper() if text else "-") | |
| def _metric_tone_class(metric: str, value: float | None) -> str: | |
| if value is None: | |
| return "" | |
| try: | |
| numeric = float(value) | |
| except Exception: | |
| return "" | |
| if metric == "ev": | |
| if numeric >= 0.05: | |
| return "good" | |
| if numeric >= -0.05: | |
| return "neutral" | |
| return "bad" | |
| if metric == "edge": | |
| if numeric >= 0.01: | |
| return "good" | |
| if numeric >= -0.01: | |
| return "neutral" | |
| return "bad" | |
| return "" | |
| def _render_verdict_badge(verdict: Any) -> str: | |
| text = str(verdict or "tracked").strip().lower() | |
| label = text.upper() | |
| return f'<div class="props-verdict {text}">{label}</div>' | |
| def _render_props_legend() -> None: | |
| st.markdown( | |
| """ | |
| <div class="props-legend"> | |
| <div class="props-section-kicker">Legend</div> | |
| <div class="props-legend-grid"> | |
| <div class="props-legend-item"><strong>Odds</strong>: the sportsbook price.</div> | |
| <div class="props-legend-item"><strong>Implied</strong>: sportsbook break-even probability from those odds.</div> | |
| <div class="props-legend-item"><strong>Pregame HR%</strong>: our modeled fair probability for the market.</div> | |
| <div class="props-legend-item"><strong>Edge</strong>: model probability minus implied probability.</div> | |
| <div class="props-legend-item"><strong>EV</strong>: expected units won or lost per 1u staked.</div> | |
| <div class="props-legend-item"><strong>Confidence</strong>: 1-100 trust score based on sample size and signal quality.</div> | |
| <div class="props-legend-item"><strong>Bet</strong>: positive value with enough confidence to act.</div> | |
| <div class="props-legend-item"><strong>Watch / Pass</strong>: monitor or avoid; value is weak or negative.</div> | |
| </div> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| def _style_metric_dataframe(df: pd.DataFrame) -> pd.io.formats.style.Styler: | |
| def style_val(value: Any, metric: str) -> str: | |
| if value is None: | |
| return "" | |
| text = str(value).strip() | |
| if text in {"", "-", "nan", "None"}: | |
| return "" | |
| try: | |
| numeric_text = text.replace("%", "").replace("u", "").replace("+", "") | |
| numeric = float(numeric_text) | |
| except Exception: | |
| return "" | |
| tone = _metric_tone_class(metric, numeric / 100.0 if metric == "edge" else numeric) | |
| color_map = { | |
| "good": "#6ef08d", | |
| "neutral": "#f3cd65", | |
| "bad": "#ff7f7f", | |
| } | |
| color = color_map.get(tone) | |
| return f"color: {color}; font-weight: 700;" if color else "" | |
| styler = df.style | |
| if "edge" in df.columns: | |
| styler = styler.map(lambda v: style_val(v, "edge"), subset=["edge"]) | |
| if "bet_ev" in df.columns: | |
| styler = styler.map(lambda v: style_val(v, "ev"), subset=["bet_ev"]) | |
| if "EV" in df.columns: | |
| styler = styler.map(lambda v: style_val(v, "ev"), subset=["EV"]) | |
| if "Edge" in df.columns: | |
| styler = styler.map(lambda v: style_val(v, "edge"), subset=["Edge"]) | |
| return styler | |
| def _build_matchup(row: pd.Series | dict[str, Any]) -> str: | |
| away = str(row.get("away_team") or "") | |
| home = str(row.get("home_team") or "") | |
| if away and home: | |
| return f"{away} @ {home}" | |
| return away or home or "-" | |
| def _build_game_time(row: pd.Series | dict[str, Any]) -> str: | |
| ct = str(row.get("commence_time") or "") | |
| if not ct: | |
| return "-" | |
| try: | |
| dt = datetime.datetime.fromisoformat(ct.replace("Z", "+00:00")) | |
| return dt.strftime("%m/%d %I:%M%p UTC") | |
| except Exception: | |
| return ct[:16] | |
| def _display_market_line_label(row: pd.Series | dict[str, Any]) -> str: | |
| display_label = str(row.get("display_label") or "").strip() | |
| market_family = str(row.get("market_family") or row.get("market") or "").strip().lower() | |
| if market_family == "k": | |
| selection_side = str(row.get("selection_side") or "").strip().lower() | |
| line = row.get("line") | |
| if line is not None and str(line).strip() not in {"", "nan", "None"}: | |
| try: | |
| line_text = f"{float(line):.1f}" | |
| except Exception: | |
| line_text = str(line).strip() | |
| if selection_side in {"over", "under"}: | |
| return f"{selection_side.title()} {line_text} Ks" | |
| if display_label and display_label.casefold() != "pitcher strikeouts": | |
| return display_label | |
| return "Pitcher Strikeouts" | |
| if display_label: | |
| return display_label | |
| return "1+ HR" if market_family == "hr" else _market_label(market_family) | |
| def _normalize_team_key(value: Any) -> str: | |
| return " ".join(str(value or "").strip().lower().split()) | |
| def _extract_slate_teams(filtered_raw: pd.DataFrame) -> tuple[str, ...]: | |
| if filtered_raw is None or filtered_raw.empty: | |
| return tuple() | |
| teams: set[str] = set() | |
| for col in ("away_team", "home_team"): | |
| if col in filtered_raw.columns: | |
| teams.update( | |
| { | |
| str(team).strip() | |
| for team in filtered_raw[col].dropna().astype(str).tolist() | |
| if str(team).strip() | |
| } | |
| ) | |
| return tuple(sorted(teams)) | |
| def _extract_slate_matchups(filtered_raw: pd.DataFrame) -> tuple[tuple[str, str], ...]: | |
| if filtered_raw is None or filtered_raw.empty: | |
| return tuple() | |
| matchups: set[tuple[str, str]] = set() | |
| cols_present = {"away_team", "home_team"}.issubset(filtered_raw.columns) | |
| if not cols_present: | |
| return tuple() | |
| for _, row in filtered_raw[["away_team", "home_team"]].dropna(how="any").iterrows(): | |
| away_team = str(row.get("away_team") or "").strip() | |
| home_team = str(row.get("home_team") or "").strip() | |
| if away_team and home_team: | |
| matchups.add((away_team, home_team)) | |
| return tuple(sorted(matchups)) | |
| def _extract_oddsapi_pitcher_names(raw: pd.DataFrame | None) -> tuple[str, ...]: | |
| if raw is None or raw.empty: | |
| return tuple() | |
| market_series = raw.get("market_family", raw.get("market", pd.Series(dtype="object", index=raw.index))) | |
| scope_series = raw.get("selection_scope", pd.Series(dtype="object", index=raw.index)) | |
| k_rows = raw[ | |
| market_series.fillna("").astype(str).str.strip().str.lower().eq("k") | |
| & scope_series.fillna("").astype(str).str.strip().str.lower().eq("pitcher") | |
| ].copy() | |
| if k_rows.empty: | |
| return tuple() | |
| names = { | |
| str(name).strip() | |
| for name in k_rows.get("player_name_raw", pd.Series(dtype="object")).dropna().astype(str).tolist() | |
| if str(name).strip() | |
| } | |
| return tuple(sorted(names)) | |
| def _extract_props_hitter_names(raw: pd.DataFrame | None) -> tuple[str, ...]: | |
| if raw is None or raw.empty or "player_name" not in raw.columns: | |
| return tuple() | |
| market_series = raw.get("market_family", raw.get("market", pd.Series(dtype="object", index=raw.index))) | |
| hitter_rows = raw[~market_series.fillna("").astype(str).str.strip().str.lower().eq("k")].copy() | |
| if hitter_rows.empty: | |
| return tuple() | |
| names = { | |
| str(name).strip() | |
| for name in hitter_rows["player_name"].dropna().astype(str).tolist() | |
| if str(name).strip() | |
| } | |
| return tuple(sorted(names)) | |
| def _extract_starter_pitcher_names(starters_map: dict | None) -> tuple[str, ...]: | |
| if not starters_map: | |
| return tuple() | |
| names: set[str] = set() | |
| for payload in starters_map.values(): | |
| if not isinstance(payload, dict): | |
| continue | |
| for key in ("away_pitcher", "home_pitcher"): | |
| pitcher_name = str(payload.get(key) or "").strip() | |
| if pitcher_name: | |
| names.add(pitcher_name) | |
| return tuple(sorted(names)) | |
| def _supported_props_markets(raw: pd.DataFrame | None) -> tuple[str, ...]: | |
| if raw is None or raw.empty or "market" not in raw.columns: | |
| return tuple() | |
| return tuple( | |
| market | |
| for market in sorted(raw["market"].dropna().astype(str).str.lower().unique().tolist()) | |
| if market in {"hr", "k"} | |
| ) | |
| def _filter_probable_starters_to_slate( | |
| probable_starters: dict | None, | |
| slate_teams: tuple[str, ...], | |
| ) -> dict: | |
| if not probable_starters or not slate_teams: | |
| return {} | |
| team_scope = {_canonical_team(team) for team in slate_teams if str(team).strip()} | |
| out: dict = {} | |
| for key, payload in probable_starters.items(): | |
| if not isinstance(key, tuple) or len(key) != 2: | |
| continue | |
| away_norm = _canonical_team(key[0]) | |
| home_norm = _canonical_team(key[1]) | |
| if away_norm in team_scope and home_norm in team_scope: | |
| out[key] = payload | |
| return out | |
| def _load_props_prepared_bundle( | |
| raw: pd.DataFrame, | |
| probable_starters: dict | None, | |
| ) -> dict[str, Any]: | |
| slate_teams = _extract_slate_teams(raw) | |
| supported_markets = _supported_props_markets(raw) | |
| primary_starters = _filter_probable_starters_to_slate(probable_starters, slate_teams) | |
| hitter_names = _extract_props_hitter_names(raw) | |
| pitcher_names = tuple( | |
| sorted( | |
| set(_extract_oddsapi_pitcher_names(raw)) | |
| | set(_extract_starter_pitcher_names(primary_starters)) | |
| ) | |
| ) | |
| bundle = load_or_build_shared_baseline_bundle_complete_for_request( | |
| batter_names=hitter_names, | |
| pitcher_names=pitcher_names, | |
| max_age_seconds=60 * 60, | |
| persist_runtime_refresh=True, | |
| ) | |
| pitcher_statcast_df = bundle.get("blended_pitcher_df", pd.DataFrame()) | |
| starter_bundle = _load_props_starter_bundle( | |
| raw=raw, | |
| probable_starters=primary_starters, | |
| pitcher_statcast_df=pitcher_statcast_df, | |
| ) | |
| prepared_bundle = { | |
| "slate_team_scope": slate_teams, | |
| "supported_markets": supported_markets, | |
| "requested_hitter_names": hitter_names, | |
| "requested_pitcher_names": pitcher_names, | |
| "blended_batter_df": bundle.get("blended_batter_df", pd.DataFrame()), | |
| "blended_pitcher_df": pitcher_statcast_df, | |
| "batter_baseline_meta": bundle.get("batter_baseline_meta", pd.DataFrame()), | |
| "pitcher_baseline_meta": bundle.get("pitcher_baseline_meta", pd.DataFrame()), | |
| "snapshot_status": bundle.get("snapshot_status", pd.DataFrame()), | |
| "snapshot_source_status": bundle.get("snapshot_source_status"), | |
| "runtime_fallback_used": bundle.get("runtime_fallback_used"), | |
| "requested_hitter_count": bundle.get("requested_hitter_count"), | |
| "requested_pitcher_count": bundle.get("requested_pitcher_count"), | |
| "resolved_hitter_count": bundle.get("resolved_hitter_count"), | |
| "resolved_pitcher_count": bundle.get("resolved_pitcher_count"), | |
| "missing_hitter_names": bundle.get("missing_hitter_names", []), | |
| "missing_pitcher_names": bundle.get("missing_pitcher_names", []), | |
| "snapshot_coverage_mode": bundle.get("snapshot_coverage_mode"), | |
| "background_refresh_queued": bundle.get("background_refresh_queued"), | |
| "request_patch_used": bundle.get("request_patch_used"), | |
| "starter_bundle": starter_bundle, | |
| "starter_debug": { | |
| "starter_cache_source": starter_bundle.get("starter_cache_source") or "unresolved", | |
| "oddsapi_fallback_used_matchup_count": int(starter_bundle.get("oddsapi_fallback_used_matchup_count") or 0), | |
| }, | |
| } | |
| prepared_bundle["signature"] = ( | |
| int(len(raw)), | |
| int(raw["event_id"].nunique()) if "event_id" in raw.columns else 0, | |
| supported_markets, | |
| int(prepared_bundle.get("requested_hitter_count") or len(hitter_names)), | |
| int(prepared_bundle.get("requested_pitcher_count") or len(pitcher_names)), | |
| int(prepared_bundle.get("resolved_hitter_count") or 0), | |
| int(prepared_bundle.get("resolved_pitcher_count") or 0), | |
| str(prepared_bundle.get("snapshot_source_status") or ""), | |
| str(starter_bundle.get("starter_cache_source") or ""), | |
| int(starter_bundle.get("oddsapi_fallback_used_matchup_count") or 0), | |
| ) | |
| return prepared_bundle | |
| def _build_props_baseline_debug( | |
| *, | |
| market_type: str, | |
| slate_team_scope: tuple[str, ...], | |
| prepared_bundle: dict[str, Any], | |
| ) -> dict[str, Any]: | |
| return { | |
| "market_type": market_type, | |
| "slate_team_scope": list(slate_team_scope), | |
| "requested_hitter_count": int(prepared_bundle.get("requested_hitter_count", len(prepared_bundle.get("requested_hitter_names") or ()))), | |
| "requested_pitcher_count": int(prepared_bundle.get("requested_pitcher_count", len(prepared_bundle.get("requested_pitcher_names") or ()))), | |
| "resolved_hitter_count": int(prepared_bundle.get("resolved_hitter_count", 0)), | |
| "resolved_pitcher_count": int(prepared_bundle.get("resolved_pitcher_count", 0)), | |
| "missing_hitter_names": list(prepared_bundle.get("missing_hitter_names", [])), | |
| "missing_pitcher_names": list(prepared_bundle.get("missing_pitcher_names", [])), | |
| "snapshot_coverage_mode": str(prepared_bundle.get("snapshot_coverage_mode") or "unknown"), | |
| "runtime_fallback_used": bool(prepared_bundle.get("runtime_fallback_used")), | |
| "background_refresh_queued": bool(prepared_bundle.get("background_refresh_queued")), | |
| "request_patch_used": bool(prepared_bundle.get("request_patch_used")), | |
| "baseline_source": str(prepared_bundle.get("snapshot_source_status") or "unknown"), | |
| } | |
| def _build_market_modeling_payload( | |
| *, | |
| filtered_raw: pd.DataFrame, | |
| market_type: str, | |
| prepared_bundle: dict[str, Any], | |
| capture_debug: bool = False, | |
| ) -> dict[str, Any]: | |
| slate_team_scope = _extract_slate_teams(filtered_raw) | |
| scoped_probable_starters = _filter_probable_starters_to_slate( | |
| (prepared_bundle.get("starter_bundle") or {}).get("merged_starters"), | |
| slate_team_scope, | |
| ) | |
| baseline_debug = _build_props_baseline_debug( | |
| market_type=market_type, | |
| slate_team_scope=slate_team_scope, | |
| prepared_bundle=prepared_bundle, | |
| ) | |
| if capture_debug: | |
| st.session_state["props_baseline_debug"] = baseline_debug | |
| statcast_df = prepared_bundle.get("blended_batter_df", pd.DataFrame()) | |
| pitcher_statcast_df = prepared_bundle.get("blended_pitcher_df", pd.DataFrame()) | |
| if (statcast_df is None or statcast_df.empty) and market_type in {"hr", "hit", "tb"}: | |
| statcast_df = pd.DataFrame() | |
| if pitcher_statcast_df is None: | |
| pitcher_statcast_df = pd.DataFrame() | |
| mapped = _map_market_rows( | |
| market_type=market_type, | |
| filtered_raw=filtered_raw, | |
| statcast_df=statcast_df, | |
| pitcher_statcast_df=pitcher_statcast_df, | |
| probable_starters=scoped_probable_starters, | |
| ) | |
| if capture_debug: | |
| if market_type == "hr": | |
| st.session_state["props_hr_health_debug"] = _build_hr_health_debug( | |
| mapped, | |
| extra_context={ | |
| "requested_hitter_count": baseline_debug.get("requested_hitter_count"), | |
| "resolved_hitter_count": baseline_debug.get("resolved_hitter_count"), | |
| "requested_pitcher_count": baseline_debug.get("requested_pitcher_count"), | |
| "resolved_pitcher_count": baseline_debug.get("resolved_pitcher_count"), | |
| }, | |
| ) | |
| else: | |
| st.session_state.pop("props_hr_health_debug", None) | |
| st.session_state["props_shared_component_debug"] = _build_shared_component_debug( | |
| mapped, | |
| market_type=market_type, | |
| ) | |
| return { | |
| "baseline_debug": baseline_debug, | |
| "statcast_df": statcast_df, | |
| "pitcher_statcast_df": pitcher_statcast_df, | |
| "mapped": mapped, | |
| } | |
| def _build_props_market_debug_payload( | |
| *, | |
| market_type: str, | |
| payload: dict[str, Any], | |
| ) -> dict[str, Any]: | |
| baseline_debug = dict(payload.get("baseline_debug") or {}) | |
| mapped = payload.get("mapped", pd.DataFrame()) | |
| hr_health = None | |
| if market_type == "hr": | |
| hr_health = _build_hr_health_debug( | |
| mapped, | |
| extra_context={ | |
| "requested_hitter_count": baseline_debug.get("requested_hitter_count"), | |
| "resolved_hitter_count": baseline_debug.get("resolved_hitter_count"), | |
| "requested_pitcher_count": baseline_debug.get("requested_pitcher_count"), | |
| "resolved_pitcher_count": baseline_debug.get("resolved_pitcher_count"), | |
| }, | |
| ) | |
| return { | |
| "market_type": market_type, | |
| "baseline_debug": baseline_debug, | |
| "hr_health_debug": hr_health, | |
| "shared_component_debug": _build_shared_component_debug(mapped, market_type=market_type), | |
| "mapped": mapped, | |
| } | |
| def _build_market_payload_from_prepared_bundle( | |
| *, | |
| raw: pd.DataFrame, | |
| market_type: str, | |
| prepared_bundle: dict[str, Any], | |
| capture_debug: bool = False, | |
| ) -> dict[str, Any] | None: | |
| if raw is None or raw.empty: | |
| return None | |
| filtered_raw = raw[raw["market"].astype(str).str.lower() == str(market_type or "").strip().lower()].copy() | |
| if filtered_raw.empty: | |
| return None | |
| return _build_market_modeling_payload( | |
| filtered_raw=filtered_raw, | |
| market_type=market_type, | |
| prepared_bundle=prepared_bundle, | |
| capture_debug=capture_debug, | |
| ) | |
| def _ensure_props_market_payloads( | |
| *, | |
| raw: pd.DataFrame, | |
| prepared_bundle: dict[str, Any], | |
| existing_payloads: dict[str, dict[str, Any]] | None = None, | |
| markets: tuple[str, ...] | list[str] | None = None, | |
| capture_debug: bool = False, | |
| ) -> dict[str, dict[str, Any]]: | |
| payloads = dict(existing_payloads or {}) | |
| target_markets = tuple( | |
| str(market).strip().lower() | |
| for market in (markets or prepared_bundle.get("supported_markets") or ()) | |
| if str(market).strip() | |
| ) | |
| for market in target_markets: | |
| if market in payloads: | |
| continue | |
| payload = _build_market_payload_from_prepared_bundle( | |
| raw=raw, | |
| market_type=market, | |
| prepared_bundle=prepared_bundle, | |
| capture_debug=capture_debug, | |
| ) | |
| if payload is not None: | |
| payloads[market] = payload | |
| return payloads | |
| def _hydrate_props_debug_state( | |
| *, | |
| market_type: str, | |
| payload: dict[str, Any], | |
| ) -> None: | |
| st.session_state["props_baseline_debug"] = dict(payload.get("baseline_debug") or {"market_type": market_type}) | |
| if market_type == "hr": | |
| st.session_state["props_hr_health_debug"] = {} | |
| else: | |
| st.session_state.pop("props_hr_health_debug", None) | |
| st.session_state["props_shared_component_debug"] = {} | |
| def _build_best_on_slate_source( | |
| *, | |
| modeled_market_bundle: dict[str, dict[str, Any]], | |
| active_market_type: str, | |
| active_mapped: pd.DataFrame, | |
| ) -> pd.DataFrame: | |
| if not modeled_market_bundle: | |
| return pd.DataFrame() | |
| market_frames: list[pd.DataFrame] = [] | |
| for market, payload in modeled_market_bundle.items(): | |
| if market == active_market_type: | |
| if active_mapped is not None and not active_mapped.empty: | |
| market_frames.append(active_mapped.copy()) | |
| continue | |
| market_mapped = payload.get("mapped", pd.DataFrame()) | |
| if market_mapped is not None and not market_mapped.empty: | |
| market_frames.append(market_mapped.copy()) | |
| if not market_frames: | |
| return pd.DataFrame() | |
| return pd.concat(market_frames, ignore_index=True, sort=False) | |
| def _load_raw_props(raw_props: pd.DataFrame | None) -> pd.DataFrame: | |
| if raw_props is not None: | |
| return raw_props | |
| with st.spinner("Loading props markets..."): | |
| return fetch_all_upcoming_hr_props(sportsbooks=DEFAULT_PROP_BOOKS) | |
| def _load_props_starter_bundle( | |
| raw: pd.DataFrame, | |
| probable_starters: dict | None, | |
| pitcher_statcast_df: pd.DataFrame | None = None, | |
| ) -> dict[str, Any]: | |
| slate_matchups = _extract_slate_matchups(raw) | |
| primary_starters = dict(probable_starters or {}) | |
| fallback_map = build_oddsapi_starter_fallback_map( | |
| props_feed=raw, | |
| primary_starters=primary_starters, | |
| pitcher_statcast_df=pitcher_statcast_df, | |
| ) | |
| merged = merge_probable_starters_with_odds_fallback(primary_starters, fallback_map) | |
| cache_sources = { | |
| str(payload.get("starter_cache_source") or "").strip() | |
| for payload in merged.values() | |
| if str(payload.get("starter_cache_source") or "").strip() | |
| } | |
| if "statsapi_plus_oddsapi_fallback" in cache_sources: | |
| aggregate_source = "statsapi_plus_oddsapi_fallback" | |
| elif any(source.startswith("oddsapi_") for source in cache_sources): | |
| aggregate_source = "oddsapi_pitcher_strikeouts_fallback" | |
| elif "statsapi_probable_pitcher" in cache_sources: | |
| aggregate_source = "statsapi_probable_pitcher" | |
| else: | |
| aggregate_source = "unresolved" | |
| return { | |
| "matchups": slate_matchups, | |
| "primary_starters": primary_starters, | |
| "oddsapi_fallback_starters": fallback_map, | |
| "merged_starters": merged, | |
| "starter_cache_source": aggregate_source, | |
| "oddsapi_fallback_used_matchup_count": sum( | |
| 1 for payload in merged.values() if bool(payload.get("fallback_used")) | |
| ), | |
| } | |
| def _render_empty_props_state() -> None: | |
| st.warning("No supported props returned from The Odds API.") | |
| with st.expander("Diagnostic details"): | |
| st.write("**Step 1 endpoint:**", "https://api.the-odds-api.com/v4/sports/baseball_mlb/events") | |
| st.write("**Step 2 endpoint:**", "https://api.the-odds-api.com/v4/sports/baseball_mlb/events/{eventId}/odds") | |
| st.write("**Markets:**", "batter_home_runs, pitcher_strikeouts") | |
| st.write("**Books requested:**", DEFAULT_PROP_BOOKS) | |
| st.write( | |
| "**Check the terminal/Streamlit log for lines tagged** `[upcoming_hr_props]` " | |
| "to see HTTP status, remaining credits, and per-game bookmaker structure." | |
| ) | |
| st.write( | |
| "Possible causes: (1) no upcoming MLB events in the next 7 days, " | |
| "(2) HTTP error on events list or per-event odds call (see log), " | |
| "(3) books requested not covering this market." | |
| ) | |
| def _map_market_rows( | |
| market_type: str, | |
| filtered_raw: pd.DataFrame, | |
| statcast_df: pd.DataFrame, | |
| pitcher_statcast_df: pd.DataFrame | None, | |
| probable_starters: dict | None, | |
| ) -> pd.DataFrame: | |
| mapped = map_props_to_models( | |
| filtered_raw, | |
| statcast_df, | |
| pitcher_statcast_df=pitcher_statcast_df, | |
| probable_starters=probable_starters, | |
| ) | |
| if market_type == "hr" and not mapped.empty: | |
| st.session_state["props_exec_df"] = mapped | |
| return mapped | |
| def _maybe_log_props(conn, mapped: pd.DataFrame) -> None: | |
| if conn is None or mapped.empty: | |
| return | |
| try: | |
| to_log = mapped.copy() | |
| to_log["fetched_at"] = utc_now_iso() | |
| log_cols = [ | |
| "fetched_at", "event_id", "commence_time", "away_team", "home_team", | |
| "sportsbook", "market", "market_variant", "threshold", "display_label", | |
| "is_primary_line", "is_modeled", "selection_scope", "selection_side", | |
| "player_name_raw", "player_name", | |
| "odds_american", "line", "implied_prob", "raw_hr_prob", | |
| "calibrated_hr_prob", "model_hr_prob", "fair_prob", "bet_ev", "confidence_score", | |
| "confidence_bucket", "opportunity_hr_adjustment", "model_hr_prob_source", "edge", | |
| "verdict", "model_voice", "model_voice_primary_reason", "model_voice_caveat", "model_voice_tags", "model_voice_for", "model_voice_against", | |
| ] | |
| for col in log_cols: | |
| if col not in to_log.columns: | |
| to_log[col] = None | |
| if "model_voice_tags" in to_log.columns: | |
| to_log["model_voice_tags"] = to_log["model_voice_tags"].apply( | |
| lambda v: "|".join(v) if isinstance(v, list) else v | |
| ) | |
| ensure_upcoming_hr_props_table(conn) | |
| insert_upcoming_hr_props(conn, to_log[log_cols]) | |
| except Exception: | |
| pass | |
| def _render_filter_controls(mapped: pd.DataFrame, market_type: str) -> tuple[list[str], float, str, str]: | |
| all_books = sorted(mapped["sportsbook"].dropna().unique().tolist()) if "sportsbook" in mapped.columns else [] | |
| filter_cols = st.columns([2.2, 1.1, 1.1, 1.2]) | |
| with filter_cols[0]: | |
| selected_books = st.multiselect( | |
| "Sportsbook", | |
| options=all_books, | |
| default=all_books, | |
| key="props_books", | |
| ) | |
| if market_type == "hr": | |
| with filter_cols[1]: | |
| min_edge = st.slider( | |
| "Min edge", | |
| min_value=-0.50, | |
| max_value=0.50, | |
| value=-0.50, | |
| step=0.01, | |
| format="%.0f%%", | |
| key="props_min_edge", | |
| ) | |
| with filter_cols[2]: | |
| sort_option = st.selectbox( | |
| "Sort by", | |
| options=["EV", "Edge", "Confidence", "Pregame Probability"], | |
| index=0, | |
| key="props_sort", | |
| ) | |
| else: | |
| min_edge = -0.50 | |
| with filter_cols[1]: | |
| st.write("") | |
| with filter_cols[2]: | |
| sort_option = st.selectbox( | |
| "Sort by", | |
| options=["EV", "Edge", "Confidence", "Implied Probability"], | |
| index=0, | |
| key="props_sort", | |
| ) | |
| with filter_cols[3]: | |
| view = st.radio("View", ["All Books", "Best Line"], horizontal=False, key="props_view") | |
| return selected_books, min_edge, sort_option, view | |
| def _get_current_filter_state(mapped: pd.DataFrame, market_type: str) -> tuple[list[str], float, str, str]: | |
| all_books = sorted(mapped["sportsbook"].dropna().unique().tolist()) if "sportsbook" in mapped.columns else [] | |
| selected_books = st.session_state.get("props_books", all_books) | |
| if not isinstance(selected_books, list) or not selected_books: | |
| selected_books = all_books | |
| selected_books = [book for book in selected_books if book in all_books] or all_books | |
| if market_type == "hr": | |
| min_edge = float(st.session_state.get("props_min_edge", -0.50)) | |
| sort_option = str(st.session_state.get("props_sort", "EV")) | |
| else: | |
| min_edge = -0.50 | |
| sort_option = str(st.session_state.get("props_sort", "EV")) | |
| view = str(st.session_state.get("props_view", "All Books")) | |
| if view not in {"All Books", "Best Line"}: | |
| view = "All Books" | |
| return selected_books, min_edge, sort_option, view | |
| def _render_market_coverage_note(display: pd.DataFrame, market_type: str) -> None: | |
| if display is None or display.empty or "sportsbook" not in display.columns: | |
| return | |
| available_books = sorted(display["sportsbook"].dropna().astype(str).unique().tolist()) | |
| if not available_books: | |
| return | |
| label = _market_label(market_type) | |
| bundle_debug = st.session_state.get("upcoming_props_bundle_debug") or {} | |
| hr_completeness = dict(bundle_debug.get("hr_snapshot_completeness") or {}) | |
| hr_snapshot_state = str(bundle_debug.get("hr_snapshot_state") or "").strip().lower() | |
| adapter_status_by_book = dict(bundle_debug.get("adapter_status_by_book") or {}) | |
| adapter_retry_after_by_book = dict(bundle_debug.get("adapter_retry_after_by_book") or {}) | |
| cache_source = str(bundle_debug.get("cache_source") or "") | |
| if len(available_books) == 1: | |
| st.info(f"{label} odds currently available from {available_books[0]} only on this slate.") | |
| if market_type == "hr" and hr_completeness and not bool(hr_completeness.get("is_complete", True)): | |
| missing_books = ", ".join(hr_completeness.get("missing_books") or []) | |
| refresh_text = "Background refresh is trying to backfill missing HR book coverage." | |
| if missing_books: | |
| refresh_text += f" Still missing: {missing_books}." | |
| dk_status = str(adapter_status_by_book.get("draftkings") or "").strip() | |
| dk_retry_after = str(adapter_retry_after_by_book.get("draftkings") or "").strip() | |
| if dk_status: | |
| refresh_text += f" DraftKings adapter: {dk_status.replace('_', ' ')}." | |
| if dk_retry_after: | |
| refresh_text += f" Next DK retry after: {dk_retry_after}." | |
| if hr_snapshot_state: | |
| refresh_text += f" HR snapshot state: {hr_snapshot_state.replace('_', ' ')}." | |
| if cache_source: | |
| refresh_text += f" Source: {cache_source.replace('_', ' ')}." | |
| st.caption(refresh_text) | |
| return | |
| books_text = ", ".join(available_books) | |
| st.caption(f"{label} coverage on this slate: {books_text}") | |
| if market_type == "hr" and hr_completeness and not bool(hr_completeness.get("is_complete", True)): | |
| missing_books = ", ".join(hr_completeness.get("missing_books") or []) | |
| refresh_text = "HR coverage is still incomplete for this slate." | |
| if missing_books: | |
| refresh_text += f" Missing books: {missing_books}." | |
| dk_status = str(adapter_status_by_book.get("draftkings") or "").strip() | |
| if dk_status: | |
| refresh_text += f" DraftKings adapter: {dk_status.replace('_', ' ')}." | |
| if hr_snapshot_state: | |
| refresh_text += f" HR snapshot state: {hr_snapshot_state.replace('_', ' ')}." | |
| if cache_source: | |
| refresh_text += f" Source: {cache_source.replace('_', ' ')}." | |
| st.caption(refresh_text) | |
| def _prepare_display_frames( | |
| mapped: pd.DataFrame, | |
| market_type: str, | |
| selected_books: list[str], | |
| min_edge: float, | |
| sort_option: str, | |
| view: str, | |
| ) -> tuple[pd.DataFrame, pd.DataFrame]: | |
| analysis_display = mapped.copy() | |
| if selected_books: | |
| analysis_display = analysis_display[analysis_display["sportsbook"].isin(selected_books)] | |
| if market_type == "hr" and min_edge > -0.50: | |
| analysis_display = analysis_display[ | |
| analysis_display["edge"].notna() & (analysis_display["edge"] >= min_edge) | |
| ] | |
| sort_col_map = { | |
| "EV": "bet_ev", | |
| "Edge": "edge", | |
| "Confidence": "confidence_score", | |
| "Pregame Probability": "model_hr_prob", | |
| "Implied Probability": "implied_prob", | |
| } | |
| sort_col = sort_col_map.get(sort_option, "implied_prob") | |
| if sort_col in analysis_display.columns: | |
| analysis_display = analysis_display.sort_values(sort_col, ascending=False, na_position="last") | |
| table_display = analysis_display.copy() | |
| if view == "Best Line": | |
| table_display = select_best_lines_per_prop(table_display) | |
| if sort_col in table_display.columns: | |
| table_display = table_display.sort_values(sort_col, ascending=False, na_position="last") | |
| return analysis_display, table_display | |
| def _modeled_hr_primary_subset(df: pd.DataFrame) -> pd.DataFrame: | |
| if df is None or df.empty: | |
| return pd.DataFrame() | |
| out = df.copy() | |
| market_series = out.get("market_family", out.get("market", pd.Series([""] * len(out), index=out.index))) | |
| market_series = market_series.astype(str).str.strip().str.lower() | |
| threshold_series = pd.to_numeric(out.get("threshold", pd.Series([1] * len(out), index=out.index)), errors="coerce").fillna(1).astype(int) | |
| if "is_modeled" in out.columns: | |
| modeled_series = out["is_modeled"].apply(lambda v: str(v).strip().lower() in {"1", "true", "yes"}) | |
| else: | |
| modeled_series = pd.Series([True] * len(out), index=out.index) | |
| if "has_model_probability" in out.columns: | |
| prob_series = out["has_model_probability"].apply(lambda v: str(v).strip().lower() in {"1", "true", "yes"}) | |
| else: | |
| prob_series = out.get("model_hr_prob", pd.Series([None] * len(out), index=out.index)).notna() | |
| return out[(market_series == "hr") & (threshold_series == 1) & modeled_series & prob_series].copy() | |
| def _build_hr_health_debug(display: pd.DataFrame, extra_context: dict[str, Any] | None = None) -> dict[str, Any]: | |
| if display is None or display.empty: | |
| out = { | |
| "modeled_hr_rows_total": 0, | |
| "modeled_hr_rows_with_probability": 0, | |
| "modeled_hr_rows_with_edge": 0, | |
| "modeled_hr_rows_missing_probability": 0, | |
| "research_hr_ladder_rows_total": 0, | |
| "health_rows": [], | |
| } | |
| out.update(dict(extra_context or {})) | |
| return out | |
| working = display.copy() | |
| market_series = working.get("market_family", working.get("market", pd.Series([""] * len(working), index=working.index))) | |
| market_series = market_series.astype(str).str.strip().str.lower() | |
| threshold_series = pd.to_numeric(working.get("threshold", pd.Series([1] * len(working), index=working.index)), errors="coerce").fillna(1).astype(int) | |
| modeled_series = ( | |
| working["is_modeled"].apply(lambda v: str(v).strip().lower() in {"1", "true", "yes"}) | |
| if "is_modeled" in working.columns else | |
| pd.Series([True] * len(working), index=working.index) | |
| ) | |
| expected_modeled = (market_series == "hr") & (threshold_series == 1) & modeled_series | |
| has_probability = working.get("has_model_probability", working.get("model_hr_prob", pd.Series([None] * len(working), index=working.index)).notna()) | |
| if not isinstance(has_probability, pd.Series): | |
| has_probability = pd.Series(has_probability, index=working.index) | |
| has_probability = has_probability.apply(lambda v: str(v).strip().lower() in {"1", "true", "yes"} if not isinstance(v, bool) else v) | |
| has_edge = working.get("has_modeled_edge", working.get("edge", pd.Series([None] * len(working), index=working.index)).notna()) | |
| if not isinstance(has_edge, pd.Series): | |
| has_edge = pd.Series(has_edge, index=working.index) | |
| has_edge = has_edge.apply(lambda v: str(v).strip().lower() in {"1", "true", "yes"} if not isinstance(v, bool) else v) | |
| research_ladders = (market_series == "hr") & (threshold_series > 1) | |
| health_cols = [ | |
| "player_name", | |
| "player_name_raw", | |
| "display_label", | |
| "threshold", | |
| "is_modeled", | |
| "model_hr_prob", | |
| "edge", | |
| "bet_ev", | |
| "implied_prob", | |
| "baseline_mode", | |
| "projected_home_pitcher", | |
| "projected_away_pitcher", | |
| "projected_starter_available", | |
| "projected_home_pitcher_source", | |
| "projected_away_pitcher_source", | |
| "starter_cache_source", | |
| "fallback_used", | |
| "projected_starter_match_status", | |
| "resolved_pitcher_name", | |
| "resolved_pitcher_source", | |
| "model_probability_status", | |
| "modeled_row_available", | |
| "modeled_row_missing_reason", | |
| "telemetry_path_status", | |
| "hr_model_tier", | |
| "applied_layers", | |
| "skipped_layers", | |
| "pitcher_resolution_status", | |
| "zone_status", | |
| "family_zone_status", | |
| "arsenal_status", | |
| ] | |
| health_rows = working[expected_modeled][[c for c in health_cols if c in working.columns]].copy() | |
| out = { | |
| "modeled_hr_rows_total": int(expected_modeled.sum()), | |
| "modeled_hr_rows_with_probability": int((expected_modeled & has_probability).sum()), | |
| "modeled_hr_rows_with_edge": int((expected_modeled & has_edge).sum()), | |
| "modeled_hr_rows_missing_probability": int((expected_modeled & ~has_probability).sum()), | |
| "research_hr_ladder_rows_total": int(research_ladders.sum()), | |
| "health_rows": health_rows.to_dict("records"), | |
| } | |
| out.update(dict(extra_context or {})) | |
| return out | |
| def _build_shared_component_debug(display: pd.DataFrame, market_type: str) -> dict[str, Any]: | |
| if display is None or display.empty: | |
| return {"market_type": market_type, "rows": [], "executed_rows": [], "gating_rows": [], "failure_summary": []} | |
| cols = [ | |
| "market_family", | |
| "player_name", | |
| "player_name_raw", | |
| "display_label", | |
| "projected_home_pitcher", | |
| "projected_away_pitcher", | |
| "projected_home_pitcher_source", | |
| "projected_away_pitcher_source", | |
| "starter_cache_source", | |
| "fallback_used", | |
| "projected_starter_match_status", | |
| "resolved_pitcher_name", | |
| "formula_version", | |
| "shared_matchup_available", | |
| "telemetry_path_status", | |
| "hr_model_tier", | |
| "matchup_coverage_confidence", | |
| "damage_zone_alignment_subscore", | |
| "pitch_mix_exposure_subscore", | |
| "tunnel_damage_subscore", | |
| "count_pattern_damage_subscore", | |
| "handedness_damage_subscore", | |
| "arsenal_fit_subscore", | |
| "environment_amplification_subscore", | |
| "hr_opportunity_projection", | |
| "projected_pitch_count", | |
| "projected_batters_faced", | |
| "projected_innings", | |
| "pitches_per_bf", | |
| "opportunity_confidence", | |
| "opportunity_reasons", | |
| "projected_k_rate", | |
| "expected_strikeouts", | |
| "zone_matchup_subscore", | |
| "family_zone_matchup_subscore", | |
| "tunneling_subscore", | |
| "release_consistency_subscore", | |
| "sequencing_subscore", | |
| "count_leverage_subscore", | |
| "leash_risk_subscore", | |
| "role_certainty_score", | |
| "times_through_order_penalty", | |
| "variance_band_low", | |
| "variance_band_high", | |
| "expected_pitch_family_mix", | |
| "predicted_attack_regions", | |
| "predicted_damage_regions", | |
| "predicted_whiff_regions", | |
| "component_source_map", | |
| "expected_pitch_family_mix", | |
| "baseline_mode", | |
| "pitcher_resolution_status", | |
| "modeled_row_available", | |
| "modeled_row_missing_reason", | |
| ] | |
| working = display[[c for c in cols if c in display.columns]].copy() | |
| def _status(row: pd.Series) -> str: | |
| baseline_mode = str(row.get("baseline_mode") or "").strip().lower() | |
| starter_status = str(row.get("projected_starter_match_status") or "").strip().lower() | |
| pitcher_status = str(row.get("pitcher_resolution_status") or "").strip().lower() | |
| shared_available = str(row.get("shared_matchup_available") or "").strip().lower() | |
| telemetry_status = str(row.get("telemetry_path_status") or "").strip().lower() | |
| if not baseline_mode or baseline_mode in {"none", "nan", "unavailable"}: | |
| return "missing_baseline" | |
| if starter_status == "projected_starter_unavailable": | |
| return "projected_starter_unavailable" | |
| if starter_status == "projected_starter_available_but_unresolved": | |
| return "projected_starter_available_but_unresolved" | |
| if pitcher_status in {"pitcher_missing", "unresolved", "matchup_incomplete", "resolved_no_pitcher_statcast"}: | |
| return pitcher_status or "pitcher_resolution_failure" | |
| if shared_available in {"1", "true", "yes"} or telemetry_status in { | |
| "full_telemetry", | |
| "partial_telemetry", | |
| "core_baseline_plus_projected_pitcher", | |
| "baseline_only_degraded", | |
| }: | |
| return "executed" | |
| component_cols = [ | |
| "damage_zone_alignment_subscore", | |
| "pitch_mix_exposure_subscore", | |
| "tunnel_damage_subscore", | |
| "count_pattern_damage_subscore", | |
| "handedness_damage_subscore", | |
| "arsenal_fit_subscore", | |
| "zone_matchup_subscore", | |
| "family_zone_matchup_subscore", | |
| "tunneling_subscore", | |
| "sequencing_subscore", | |
| ] | |
| if any(pd.notna(row.get(col)) for col in component_cols): | |
| return "executed" | |
| return "prerequisites_not_met" | |
| working["component_execution_status"] = working.apply(_status, axis=1) | |
| executed = working[working["component_execution_status"] == "executed"].copy() | |
| gating = working[working["component_execution_status"] != "executed"].copy() | |
| failure_summary = ( | |
| working["component_execution_status"] | |
| .value_counts(dropna=False) | |
| .rename_axis("failure_reason") | |
| .reset_index(name="row_count") | |
| ) | |
| return { | |
| "market_type": market_type, | |
| "rows": working.head(30).to_dict("records"), | |
| "executed_rows": executed.head(30).to_dict("records"), | |
| "gating_rows": gating.head(30).to_dict("records"), | |
| "failure_summary": failure_summary.to_dict("records"), | |
| } | |
| def render_props_hero(display_df: pd.DataFrame, view_model: dict[str, Any] | None = None) -> None: | |
| st.markdown( | |
| """ | |
| <div class="props-hero"> | |
| <div class="props-hero-kicker">Pregame Slate</div> | |
| <div class="props-hero-title">Props Command Center</div> | |
| <div class="props-hero-sub">Actionable pregame props with model probability, edge, EV, confidence, and explainability layered directly onto the slate.</div> | |
| <div class="props-hero-note">HR is fully modeled today | Strikeouts are live | Alternate ladders stay in research mode</div> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| if display_df.empty: | |
| return | |
| games_summary_df = pd.DataFrame() if not view_model else view_model.get("games_summary_df", pd.DataFrame()) | |
| featured_props_df = pd.DataFrame() if not view_model else view_model.get("featured_props_df", pd.DataFrame()) | |
| available_books = sorted(display_df["sportsbook"].dropna().astype(str).unique().tolist()) if "sportsbook" in display_df.columns else [] | |
| modeled_display = _modeled_hr_primary_subset(display_df) | |
| best_edge = modeled_display["edge"].dropna().max() if "edge" in modeled_display.columns and modeled_display["edge"].notna().any() else None | |
| best_ev = modeled_display["bet_ev"].dropna().max() if "bet_ev" in modeled_display.columns and modeled_display["bet_ev"].notna().any() else None | |
| if not modeled_display.empty: | |
| modeled_display = select_best_lines_per_prop(modeled_display) | |
| avg_featured_edge = ( | |
| featured_props_df["edge"].dropna().mean() | |
| if not featured_props_df.empty and "edge" in featured_props_df.columns and featured_props_df["edge"].notna().any() | |
| else None | |
| ) | |
| avg_featured_ev = ( | |
| featured_props_df["bet_ev"].dropna().mean() | |
| if not featured_props_df.empty and "bet_ev" in featured_props_df.columns and featured_props_df["bet_ev"].notna().any() | |
| else None | |
| ) | |
| with st.container(): | |
| hero_cols = st.columns(6) | |
| 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)) | |
| hero_cols[1].metric("Books", len(available_books)) | |
| hero_cols[2].metric("Modeled 1+ HR Rows", int(len(modeled_display)) if not modeled_display.empty else 0) | |
| hero_cols[3].metric("Best Edge (modeled)", _format_edge(float(best_edge)) if best_edge is not None else "-") | |
| hero_cols[4].metric("Best EV (modeled)", _format_ev(float(best_ev)) if best_ev is not None else "-") | |
| hero_cols[5].metric("Avg Featured EV", _format_ev(float(avg_featured_ev)) if avg_featured_ev is not None else "-") | |
| def render_featured_hr_cards(featured_df: pd.DataFrame) -> None: | |
| st.markdown('<div class="props-section-kicker">Featured Props</div>', unsafe_allow_html=True) | |
| st.markdown("#### Best-Value 1+ HR Plays") | |
| if featured_df.empty: | |
| st.info("No modeled 1+ HR props match the current filters.") | |
| return | |
| card_count = min(4, len(featured_df)) | |
| cols = st.columns(card_count) | |
| for idx, (_, row) in enumerate(featured_df.head(card_count).iterrows()): | |
| col = cols[idx % card_count] | |
| with col: | |
| player = str(row.get("player_name_raw") or row.get("player_name") or "-") | |
| matchup = _build_matchup(row) | |
| badge_class = "props-badge success" if idx == 0 else "props-badge" | |
| badge_text = "Top Pick" if idx == 0 else "Featured" | |
| verdict = str(row.get("verdict") or "tracked").strip().lower() | |
| edge_class = _metric_tone_class("edge", row.get("edge")) | |
| ev_class = _metric_tone_class("ev", row.get("bet_ev")) | |
| model_voice = str(row.get("model_voice") or "Model voice is still being assembled for this matchup.") | |
| line_label = _display_market_line_label(row) | |
| st.markdown( | |
| f""" | |
| <div class="props-card{' top' if idx == 0 else ''}"> | |
| <div class="props-card-head"> | |
| <div class="{badge_class}">{badge_text}</div> | |
| {_render_verdict_badge(verdict)} | |
| </div> | |
| <div class="props-player">{player}</div> | |
| <div class="props-matchup">{matchup}</div> | |
| <div class="props-line">{line_label} | {str(row.get('sportsbook') or '-')}</div> | |
| <div class="props-metric-grid"> | |
| <div> | |
| <div class="props-metric-label">Odds</div> | |
| <div class="props-metric-value">{_format_odds(row.get('odds_american'))}</div> | |
| </div> | |
| <div> | |
| <div class="props-metric-label">EV</div> | |
| <div class="props-metric-value {ev_class}">{_format_ev(row.get('bet_ev'))}</div> | |
| </div> | |
| <div> | |
| <div class="props-metric-label">Edge</div> | |
| <div class="props-metric-value {edge_class}">{_format_edge(row.get('edge'))}</div> | |
| </div> | |
| <div> | |
| <div class="props-metric-label">Pregame HR%</div> | |
| <div class="props-metric-value">{_format_pct(row.get('model_hr_prob'))}</div> | |
| </div> | |
| <div> | |
| <div class="props-metric-label">Implied</div> | |
| <div class="props-metric-value">{_format_pct(row.get('implied_prob'))}</div> | |
| </div> | |
| <div> | |
| <div class="props-metric-label">Confidence</div> | |
| {_build_confidence_metric_html(row)} | |
| </div> | |
| </div> | |
| <div class="props-voice"> | |
| <div class="props-voice-line props-voice-card"><strong>Model Voice:</strong> {model_voice}</div> | |
| </div> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| def render_best_on_slate_cards(best_df: pd.DataFrame, summary: dict[str, Any] | None = None) -> None: | |
| st.markdown('<div class="props-section-kicker">Slate-Wide Best Value</div>', unsafe_allow_html=True) | |
| st.markdown("#### Best Value Available") | |
| st.caption("Compares the best currently modeled 1+ HR and pitcher strikeout lines across books, then ranks the strongest bettor-value spots on the slate.") | |
| summary = summary or {} | |
| metric_cols = st.columns(4) | |
| metric_cols[0].metric("Modeled Props", int(summary.get("modeled_props_count") or 0)) | |
| metric_cols[1].metric("Books Active", int(summary.get("sportsbooks_count") or 0)) | |
| metric_cols[2].metric("Markets Active", int(summary.get("markets_count") or 0)) | |
| best_ev = summary.get("best_ev") | |
| metric_cols[3].metric("Best EV", _format_ev(float(best_ev)) if best_ev is not None else "-") | |
| if best_df is None or best_df.empty: | |
| st.info("No slate-wide modeled props are currently available.") | |
| return | |
| card_count = min(4, len(best_df)) | |
| cols = st.columns(card_count) | |
| for idx, (_, row) in enumerate(best_df.head(card_count).iterrows()): | |
| col = cols[idx % card_count] | |
| with col: | |
| player = str(row.get("player_name_raw") or row.get("player_name") or "-") | |
| matchup = _build_matchup(row) | |
| badge_class = "props-badge success" if idx == 0 else "props-badge" | |
| badge_text = "Best On Slate" if idx == 0 else "Slate Value" | |
| verdict = str(row.get("verdict") or "tracked").strip().lower() | |
| edge_class = _metric_tone_class("edge", row.get("edge")) | |
| ev_class = _metric_tone_class("ev", row.get("bet_ev")) | |
| model_voice = str(row.get("model_voice") or "Model voice is still being assembled for this matchup.") | |
| market_family = str(row.get("market_family") or row.get("market") or "").strip().lower() | |
| probability_label = "Pregame HR%" if market_family == "hr" else "Fair%" | |
| probability_value = row.get("model_hr_prob") if market_family == "hr" else row.get("fair_prob") | |
| market_badge = _display_market_line_label(row) | |
| st.markdown( | |
| f""" | |
| <div class="props-card{' top' if idx == 0 else ''}"> | |
| <div class="props-card-head"> | |
| <div class="{badge_class}">{badge_text}</div> | |
| {_render_verdict_badge(verdict)} | |
| </div> | |
| <div class="props-player">{player}</div> | |
| <div class="props-matchup">{matchup}</div> | |
| <div class="props-line">{market_badge} | {str(row.get('sportsbook') or '-')}</div> | |
| <div class="props-metric-grid"> | |
| <div> | |
| <div class="props-metric-label">Odds</div> | |
| <div class="props-metric-value">{_format_odds(row.get('odds_american'))}</div> | |
| </div> | |
| <div> | |
| <div class="props-metric-label">EV</div> | |
| <div class="props-metric-value {ev_class}">{_format_ev(row.get('bet_ev'))}</div> | |
| </div> | |
| <div> | |
| <div class="props-metric-label">Edge</div> | |
| <div class="props-metric-value {edge_class}">{_format_edge(row.get('edge'))}</div> | |
| </div> | |
| <div> | |
| <div class="props-metric-label">{probability_label}</div> | |
| <div class="props-metric-value">{_format_pct(probability_value)}</div> | |
| </div> | |
| <div> | |
| <div class="props-metric-label">Implied</div> | |
| <div class="props-metric-value">{_format_pct(row.get('implied_prob'))}</div> | |
| </div> | |
| <div> | |
| <div class="props-metric-label">Confidence</div> | |
| {_build_confidence_metric_html(row)} | |
| </div> | |
| </div> | |
| <div class="props-voice"> | |
| <div class="props-voice-line props-voice-card"><strong>Model Voice:</strong> {model_voice}</div> | |
| </div> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| def _build_terminal_top_bets_df(display: pd.DataFrame, market_type: str, limit: int = 7) -> pd.DataFrame: | |
| if display is None or display.empty: | |
| return pd.DataFrame() | |
| working = display.copy() | |
| if market_type == "hr": | |
| working = _modeled_hr_primary_subset(working) | |
| elif "is_modeled" in working.columns: | |
| working = working[working["is_modeled"] == True].copy() | |
| if working.empty: | |
| return pd.DataFrame() | |
| working = select_best_lines_per_prop(working) | |
| sort_cols: list[str] = [] | |
| ascending: list[bool] = [] | |
| for col in ("bet_ev", "edge", "confidence_score", "final_recommendation_score"): | |
| if col in working.columns: | |
| sort_cols.append(col) | |
| ascending.append(False) | |
| if "odds_american" in working.columns: | |
| sort_cols.append("odds_american") | |
| ascending.append(False) | |
| if sort_cols: | |
| working = working.sort_values(sort_cols, ascending=ascending, na_position="last") | |
| return working.head(max(1, int(limit))).reset_index(drop=True) | |
| def _render_terminal_header(display: pd.DataFrame, market_type: str, top_bets_df: pd.DataFrame) -> None: | |
| market_label = _market_label(market_type) | |
| available_books = ( | |
| sorted(display["sportsbook"].dropna().astype(str).unique().tolist()) | |
| if display is not None and not display.empty and "sportsbook" in display.columns | |
| else [] | |
| ) | |
| best_edge = ( | |
| pd.to_numeric(top_bets_df.get("edge"), errors="coerce").dropna().max() | |
| if not top_bets_df.empty and "edge" in top_bets_df.columns | |
| else None | |
| ) | |
| best_ev = ( | |
| pd.to_numeric(top_bets_df.get("bet_ev"), errors="coerce").dropna().max() | |
| if not top_bets_df.empty and "bet_ev" in top_bets_df.columns | |
| else None | |
| ) | |
| st.markdown( | |
| f""" | |
| <div class="props-ops-header"> | |
| <div class="props-ops-kicker">Baseball Ops Terminal</div> | |
| <div class="props-ops-title">{market_label} Slate Board</div> | |
| <div class="props-ops-sub">Top value first, fast matchup navigation second, deep prop detail only when you want it.</div> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| metrics = st.columns(5) | |
| metrics[0].metric("Games", int(display["event_id"].nunique()) if display is not None and not display.empty and "event_id" in display.columns else 0) | |
| metrics[1].metric("Books", len(available_books)) | |
| metrics[2].metric("Top Bets", int(len(top_bets_df))) | |
| metrics[3].metric("Best Edge", _format_edge(float(best_edge)) if best_edge is not None else "-") | |
| metrics[4].metric("Best EV", _format_ev(float(best_ev)) if best_ev is not None else "-") | |
| def _render_top_bet_highlight(row: pd.Series | dict[str, Any], rank_label: str) -> None: | |
| player = str(row.get("player_name_raw") or row.get("player_name") or "-") | |
| matchup = _build_matchup(row) | |
| market_line = _display_market_line_label(row) | |
| book = str(row.get("sportsbook") or "-") | |
| model_voice = str(row.get("model_voice") or "No model voice available.") | |
| market_family = str(row.get("market_family") or row.get("market") or "").strip().lower() | |
| probability_value = row.get("model_hr_prob") if market_family == "hr" else row.get("fair_prob") | |
| probability_label = "Pregame HR%" if market_family == "hr" else "Fair%" | |
| edge_class = _metric_tone_class("edge", row.get("edge")) | |
| ev_class = _metric_tone_class("ev", row.get("bet_ev")) | |
| st.markdown( | |
| f""" | |
| <div class="props-terminal-highlight"> | |
| <div class="props-terminal-rank">{rank_label}</div> | |
| <div class="props-player">{player}</div> | |
| <div class="props-matchup">{matchup}</div> | |
| <div class="props-terminal-line">{market_line} | {book}</div> | |
| <div class="props-terminal-grid"> | |
| <div><div class="props-terminal-metric">Odds</div><div class="props-terminal-value">{_format_odds(row.get('odds_american'))}</div></div> | |
| <div><div class="props-terminal-metric">EV</div><div class="props-terminal-value {ev_class}">{_format_ev(row.get('bet_ev'))}</div></div> | |
| <div><div class="props-terminal-metric">Edge</div><div class="props-terminal-value {edge_class}">{_format_edge(row.get('edge'))}</div></div> | |
| <div><div class="props-terminal-metric">{probability_label}</div><div class="props-terminal-value">{_format_pct(probability_value)}</div></div> | |
| <div><div class="props-terminal-metric">Implied</div><div class="props-terminal-value">{_format_pct(row.get('implied_prob'))}</div></div> | |
| <div><div class="props-terminal-metric">Confidence</div><div class="props-terminal-value">{_format_confidence(row.get('confidence_score'))}</div></div> | |
| </div> | |
| <div class="props-terminal-thesis"><strong>Thesis:</strong> {model_voice}</div> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| def _render_terminal_mini_card(row: pd.Series | dict[str, Any], rank: int) -> None: | |
| player = str(row.get("player_name_raw") or row.get("player_name") or "-") | |
| matchup = _build_matchup(row) | |
| market_line = _display_market_line_label(row) | |
| book = str(row.get("sportsbook") or "-") | |
| model_voice = str(row.get("model_voice") or "No thesis available.") | |
| model_voice = model_voice[:140] + ("..." if len(model_voice) > 140 else "") | |
| edge_class = _metric_tone_class("edge", row.get("edge")) | |
| ev_class = _metric_tone_class("ev", row.get("bet_ev")) | |
| st.markdown( | |
| f""" | |
| <div class="props-terminal-mini"> | |
| <div class="props-terminal-rank">#{rank}</div> | |
| <div class="props-terminal-name">{player}</div> | |
| <div class="props-matchup">{matchup}</div> | |
| <div class="props-terminal-line">{market_line} | {book}</div> | |
| <div class="props-terminal-grid"> | |
| <div><div class="props-terminal-metric">EV</div><div class="props-terminal-value {ev_class}">{_format_ev(row.get('bet_ev'))}</div></div> | |
| <div><div class="props-terminal-metric">Edge</div><div class="props-terminal-value {edge_class}">{_format_edge(row.get('edge'))}</div></div> | |
| <div><div class="props-terminal-metric">Odds</div><div class="props-terminal-value">{_format_odds(row.get('odds_american'))}</div></div> | |
| <div><div class="props-terminal-metric">Conf</div><div class="props-terminal-value">{_format_confidence(row.get('confidence_score'))}</div></div> | |
| </div> | |
| <div class="props-terminal-thesis">{model_voice}</div> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| def render_terminal_top_bets_board(top_bets_df: pd.DataFrame, market_type: str) -> None: | |
| st.markdown('<div class="props-section-kicker">Top Bets</div>', unsafe_allow_html=True) | |
| st.markdown("#### Best Current Value On The Slate") | |
| if top_bets_df is None or top_bets_df.empty: | |
| st.info(f"No top {_market_label(market_type)} bets are available for the current filters.") | |
| return | |
| st.markdown('<div class="props-terminal-board">', unsafe_allow_html=True) | |
| _render_top_bet_highlight(top_bets_df.iloc[0], "Top Play") | |
| if len(top_bets_df) > 1: | |
| remaining = top_bets_df.iloc[1:].reset_index(drop=True) | |
| cols_per_row = 3 | |
| for start in range(0, len(remaining), cols_per_row): | |
| cols = st.columns(min(cols_per_row, len(remaining) - start)) | |
| for idx, (_, row) in enumerate(remaining.iloc[start:start + cols_per_row].iterrows(), start=start + 2): | |
| with cols[idx - start - 2]: | |
| _render_terminal_mini_card(row, idx) | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| def _build_generic_game_workspace(display: pd.DataFrame) -> tuple[pd.DataFrame, dict[str, Any]]: | |
| if display is None or display.empty: | |
| return pd.DataFrame(), {} | |
| working = select_best_lines_per_prop(display.copy()) | |
| summary_rows: list[dict[str, Any]] = [] | |
| game_map: dict[str, Any] = {} | |
| for (event_id, away_team, home_team, commence_time), game_df in working.groupby( | |
| ["event_id", "away_team", "home_team", "commence_time"], | |
| dropna=False, | |
| ): | |
| game_key = str(event_id or f"{away_team}|{home_team}|{commence_time}") | |
| sort_df = game_df.copy() | |
| if "bet_ev" in sort_df.columns: | |
| sort_df = sort_df.sort_values(["bet_ev", "edge", "confidence_score"], ascending=[False, False, False], na_position="last") | |
| top_row = sort_df.iloc[0].to_dict() if not sort_df.empty else {} | |
| player_rows: list[dict[str, Any]] = [] | |
| for player_name, player_df in sort_df.groupby("player_name", dropna=False): | |
| primary_row = player_df.iloc[0].to_dict() | |
| player_rows.append( | |
| { | |
| "player_key": f"{game_key}|{str(primary_row.get('player_name') or '').strip().lower()}", | |
| "player_name": primary_row.get("player_name"), | |
| "player_name_raw": primary_row.get("player_name_raw"), | |
| "best_display_label": primary_row.get("display_label"), | |
| "best_book": primary_row.get("sportsbook"), | |
| "best_odds_american": primary_row.get("odds_american"), | |
| "best_edge": primary_row.get("edge"), | |
| "best_bet_ev": primary_row.get("bet_ev"), | |
| "best_confidence_score": primary_row.get("confidence_score"), | |
| "best_verdict": primary_row.get("verdict"), | |
| "best_model_hr_prob": primary_row.get("fair_prob"), | |
| "model_voice": primary_row.get("model_voice"), | |
| "model_voice_primary_reason": primary_row.get("model_voice_primary_reason"), | |
| "model_voice_caveat": primary_row.get("model_voice_caveat"), | |
| "details": { | |
| "best_primary_row": primary_row, | |
| "primary_rows": player_df.to_dict("records"), | |
| "alt_rows": [], | |
| }, | |
| } | |
| ) | |
| game_map[game_key] = { | |
| "game_key": game_key, | |
| "event_id": event_id, | |
| "away_team": away_team, | |
| "home_team": home_team, | |
| "commence_time": commence_time, | |
| "modeled_props_count": int(len(sort_df)), | |
| "players_count": int(game_df["player_name"].nunique()), | |
| "best_edge": top_row.get("edge"), | |
| "best_bet_ev": top_row.get("bet_ev"), | |
| "top_player_name": top_row.get("player_name"), | |
| "top_display_label": top_row.get("display_label"), | |
| "top_book": top_row.get("sportsbook"), | |
| "top_verdict": top_row.get("verdict"), | |
| "players": player_rows, | |
| } | |
| summary_rows.append({key: value for key, value in game_map[game_key].items() if key != "players"}) | |
| summary_df = pd.DataFrame(summary_rows) | |
| if not summary_df.empty and "best_edge" in summary_df.columns: | |
| summary_df = summary_df.sort_values(["best_edge", "best_bet_ev"], ascending=[False, False], na_position="last").reset_index(drop=True) | |
| return summary_df, game_map | |
| def _normalize_game_summary_rows( | |
| market_type: str, | |
| analysis_display: pd.DataFrame, | |
| view_model: dict[str, Any] | None = None, | |
| ) -> tuple[pd.DataFrame, dict[str, Any]]: | |
| if market_type == "hr" and isinstance(view_model, dict): | |
| return ( | |
| view_model.get("games_summary_df", pd.DataFrame()), | |
| view_model.get("game_player_props_map", {}), | |
| ) | |
| return _build_generic_game_workspace(analysis_display) | |
| def render_game_navigator_terminal(game_summary_df: pd.DataFrame, market_type: str) -> str | None: | |
| st.markdown('<div class="props-section-kicker">Game Navigator</div>', unsafe_allow_html=True) | |
| st.markdown("#### Jump Into A Matchup") | |
| if game_summary_df is None or game_summary_df.empty: | |
| st.info(f"No {_market_label(market_type)} games are available for navigation.") | |
| return None | |
| state_key = f"props_selected_game_{market_type}" | |
| ordered_game_keys = game_summary_df["game_key"].astype(str).tolist() | |
| current = st.session_state.get(state_key) | |
| if current not in ordered_game_keys: | |
| current = ordered_game_keys[0] | |
| st.session_state[state_key] = current | |
| st.markdown('<div class="props-game-nav">', unsafe_allow_html=True) | |
| cols_per_row = 3 | |
| for start in range(0, len(game_summary_df), cols_per_row): | |
| row_df = game_summary_df.iloc[start:start + cols_per_row] | |
| cols = st.columns(len(row_df)) | |
| for col, (_, row) in zip(cols, row_df.iterrows()): | |
| game_key = str(row.get("game_key") or "") | |
| selected_class = " selected" if game_key == current else "" | |
| with col: | |
| st.markdown( | |
| f""" | |
| <div class="props-game-nav-card{selected_class}"> | |
| <div class="props-game-nav-title">{_build_matchup(row)}</div> | |
| <div class="props-game-nav-meta"> | |
| {_build_game_time(row)}<br/> | |
| {int(row.get('modeled_props_count') or 0)} modeled props | best edge {_format_edge(row.get('best_edge'))}<br/> | |
| top: {str(row.get('top_player_name') or '-')} | {str(row.get('top_display_label') or '-')} | |
| </div> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| if st.button("View Game", key=f"props_game_nav_{market_type}_{game_key}", use_container_width=True): | |
| st.session_state[state_key] = game_key | |
| current = game_key | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| return current | |
| def render_selected_game_workspace( | |
| *, | |
| selected_game_key: str | None, | |
| game_map: dict[str, Any], | |
| market_type: str, | |
| ) -> None: | |
| st.markdown('<div class="props-section-kicker">Selected Game</div>', unsafe_allow_html=True) | |
| st.markdown("#### Prop Workspace") | |
| if not selected_game_key or selected_game_key not in game_map: | |
| st.info("Select a game to inspect player props and books.") | |
| return | |
| game_payload = game_map[selected_game_key] | |
| st.markdown( | |
| f""" | |
| <div class="props-workspace"> | |
| <div class="props-workspace-title">{_build_matchup(game_payload)}</div> | |
| <div class="props-workspace-sub"> | |
| {_build_game_time(game_payload)} | {int(game_payload.get('modeled_props_count') or 0)} modeled props | best edge {_format_edge(game_payload.get('best_edge'))} | best EV {_format_ev(game_payload.get('best_bet_ev'))} | |
| </div> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| player_entries = game_payload.get("players") or [] | |
| if not player_entries: | |
| st.info("No player props available for this game.") | |
| return | |
| for player_entry in player_entries: | |
| render_player_hr_row(player_entry) | |
| def render_player_hr_details(player_details: dict[str, Any]) -> None: | |
| primary_rows = pd.DataFrame(player_details.get("primary_rows") or []) | |
| alt_rows = pd.DataFrame(player_details.get("alt_rows") or []) | |
| primary_market_family = str((player_details.get("best_primary_row") or {}).get("market_family") or "").strip().lower() | |
| if not primary_rows.empty: | |
| st.caption("All books - 1+ HR" if primary_market_family in {"", "hr"} else f"All books - {_market_label(primary_market_family)}") | |
| primary_display_cols = [ | |
| "sportsbook", "display_label", "odds_american", "implied_prob", | |
| "model_hr_prob", "fair_prob", "bet_ev", "edge", "confidence_score", "verdict", "model_hr_prob_source", | |
| ] | |
| primary_display = primary_rows[[c for c in primary_display_cols if c in primary_rows.columns]].copy() | |
| if "odds_american" in primary_display.columns: | |
| primary_display["odds_american"] = primary_display["odds_american"].apply(_format_odds) | |
| if "implied_prob" in primary_display.columns: | |
| primary_display["implied_prob"] = primary_display["implied_prob"].apply(_format_pct) | |
| if "model_hr_prob" in primary_display.columns: | |
| primary_display["model_hr_prob"] = primary_display["model_hr_prob"].apply(_format_pct) | |
| if "fair_prob" in primary_display.columns: | |
| primary_display["fair_prob"] = primary_display["fair_prob"].apply(_format_pct) | |
| if "bet_ev" in primary_display.columns: | |
| primary_display["bet_ev"] = primary_display["bet_ev"].apply(_format_ev) | |
| if "edge" in primary_display.columns: | |
| primary_display["edge"] = primary_display["edge"].apply(_format_edge) | |
| if "confidence_score" in primary_display.columns: | |
| primary_display["confidence_score"] = primary_display["confidence_score"].apply(_format_confidence) | |
| st.dataframe(_style_metric_dataframe(primary_display), use_container_width=True, hide_index=True) | |
| if not alt_rows.empty: | |
| st.caption("Alternate HR ladders") | |
| alt_display_cols = [ | |
| "sportsbook", "display_label", "odds_american", "implied_prob", | |
| "model_hr_prob_source", | |
| ] | |
| alt_display = alt_rows[[c for c in alt_display_cols if c in alt_rows.columns]].copy() | |
| if "odds_american" in alt_display.columns: | |
| alt_display["odds_american"] = alt_display["odds_american"].apply(_format_odds) | |
| if "implied_prob" in alt_display.columns: | |
| alt_display["implied_prob"] = alt_display["implied_prob"].apply(_format_pct) | |
| st.dataframe(alt_display, use_container_width=True, hide_index=True) | |
| st.markdown( | |
| '<div class="props-unmodeled-note">Alternate HR ladders are tracked for price discovery, but only 1+ HR is modeled in this release.</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| best_primary = player_details.get("best_primary_row") or {} | |
| diagnostic_fields = [ | |
| "resolved_pitcher_name", | |
| "baseline_hr_prob", | |
| "raw_hr_prob", | |
| "calibrated_hr_prob", | |
| "pregame_hr_prob", | |
| "fair_prob", | |
| "bet_ev", | |
| "verdict", | |
| "model_voice", | |
| "model_voice_primary_reason", | |
| "model_voice_caveat", | |
| "model_voice_reason_candidates", | |
| "model_voice_tags", | |
| "model_voice_for", | |
| "model_voice_against", | |
| "confidence_score", | |
| "confidence_score_raw", | |
| "confidence_score_display", | |
| "confidence_source", | |
| "confidence_bucket", | |
| "confidence_reasons", | |
| "confidence_component_bonuses", | |
| "confidence_component_penalties", | |
| "confidence_primary_driver", | |
| "confidence_summary_label", | |
| "opportunity_hr_adjustment", | |
| "expected_pa", | |
| "lineup_slot_used", | |
| "lineup_slot_source", | |
| "team_total_used", | |
| "projected_home_pitcher", | |
| "projected_away_pitcher", | |
| "projected_starter_available", | |
| "projected_home_pitcher_source", | |
| "projected_away_pitcher_source", | |
| "starter_cache_source", | |
| "fallback_used", | |
| "projected_starter_match_status", | |
| "resolved_pitcher_source", | |
| "telemetry_path_status", | |
| "hr_model_tier", | |
| "shared_matchup_available", | |
| "modeled_row_available", | |
| "modeled_row_missing_reason", | |
| "pitcher_hr_adjustment", | |
| "env_hr_adjustment", | |
| "park_hr_adjustment", | |
| "weather_hr_adjustment", | |
| "rolling_hr_adjustment", | |
| "applied_layers", | |
| "skipped_layers", | |
| "pitcher_swstr_rate", | |
| "pitcher_csw_rate", | |
| "pitcher_ball_rate", | |
| "arsenal_whiff_risk", | |
| "family_zone_whiff_risk", | |
| "zone_whiff_risk", | |
| "expected_strikeouts", | |
| "pitches_per_bf", | |
| "opportunity_confidence", | |
| "opportunity_reasons", | |
| "sequencing_score", | |
| "trajectory_tunnel_score", | |
| "trajectory_release_consistency_score", | |
| ] | |
| diag_payload = {field: best_primary.get(field) for field in diagnostic_fields if field in best_primary} | |
| if diag_payload: | |
| st.caption("Primary-line diagnostics") | |
| st.json(diag_payload, expanded=False) | |
| def render_player_hr_row(player_entry: dict[str, Any]) -> None: | |
| player_name = str(player_entry.get("player_name_raw") or player_entry.get("player_name") or "-") | |
| best_label = str(player_entry.get("best_display_label") or "1+ HR") | |
| best_book = str(player_entry.get("best_book") or "-") | |
| best_odds = _format_odds(player_entry.get("best_odds_american")) | |
| best_edge = _format_edge(player_entry.get("best_edge")) | |
| best_ev = _format_ev(player_entry.get("best_bet_ev")) | |
| best_confidence = _format_confidence(player_entry.get("best_confidence_score")) | |
| verdict = str(player_entry.get("best_verdict") or "tracked").strip().lower() | |
| model_voice = str(player_entry.get("model_voice") or "") | |
| details_label = f"{player_name} | {best_label} | {best_book} {best_odds} | {best_ev} EV | {best_edge} | {best_confidence} conf" | |
| if player_entry.get("has_alt_ladders"): | |
| details_label += " | alt ladders" | |
| with st.container(): | |
| st.markdown(f"**{player_name}**") | |
| st.markdown(_render_verdict_badge(verdict), unsafe_allow_html=True) | |
| summary_cols = st.columns([0.8, 0.9, 0.7, 0.7, 0.7, 1.1]) | |
| summary_cols[0].caption(best_label) | |
| summary_cols[1].caption(f"{best_book} {best_odds}") | |
| summary_cols[2].caption(f"EV {best_ev}") | |
| summary_cols[3].caption(f"Edge {best_edge}") | |
| summary_cols[4].caption(f"Conf {best_confidence}") | |
| details_open = summary_cols[5].toggle( | |
| "Show books and ladders", | |
| value=False, | |
| key=f"props_player_toggle_{player_entry.get('player_key')}", | |
| ) | |
| if model_voice: | |
| st.caption(f"Model Voice: {model_voice}") | |
| if details_open: | |
| st.caption(details_label) | |
| details = player_entry.get("details") or {} | |
| metric_cols = st.columns(6) | |
| metric_cols[0].metric("Best Book", best_book) | |
| metric_cols[1].metric("Best Odds", best_odds) | |
| metric_cols[2].metric( | |
| "Pregame HR%" if str((details.get("best_primary_row") or {}).get("market_family") or "hr").strip().lower() == "hr" else "Fair%", | |
| _format_pct(player_entry.get("best_model_hr_prob")), | |
| ) | |
| metric_cols[3].metric("EV", best_ev) | |
| metric_cols[4].metric("Edge", best_edge) | |
| metric_cols[5].metric("Confidence", best_confidence) | |
| if model_voice: | |
| st.caption(f"Model Voice: {model_voice}") | |
| primary_reason = str(player_entry.get("model_voice_primary_reason") or "") | |
| caveat = str(player_entry.get("model_voice_caveat") or "") | |
| if primary_reason or caveat: | |
| why_lines: list[str] = [] | |
| if primary_reason: | |
| why_lines.append(f"Lead reason: {primary_reason}") | |
| if caveat: | |
| why_lines.append(f"Caveat: {caveat}") | |
| st.caption("Why this rating") | |
| for line in why_lines: | |
| st.write(f"- {line}") | |
| _render_confidence_breakdown(details.get("best_primary_row") or {}) | |
| render_player_hr_details(details) | |
| st.divider() | |
| def render_game_card(game_payload: dict[str, Any]) -> None: | |
| matchup = _build_matchup(game_payload) | |
| commence_time = _build_game_time(game_payload) | |
| best_edge = _format_edge(game_payload.get("best_edge")) | |
| top_player = str(game_payload.get("top_player_name") or "-") | |
| top_label = str(game_payload.get("top_display_label") or "-") | |
| top_book = str(game_payload.get("top_book") or "-") | |
| top_verdict = str(game_payload.get("top_verdict") or "tracked").upper() | |
| st.markdown( | |
| f""" | |
| <div class="props-game-card"> | |
| <div class="props-game-head"> | |
| <div class="props-game-title">{matchup}</div> | |
| <div class="props-game-meta">{commence_time}</div> | |
| </div> | |
| <div class="props-game-meta">{int(game_payload.get('modeled_props_count') or 0)} modeled props | best edge {best_edge} | best EV {_format_ev(game_payload.get('best_bet_ev'))}</div> | |
| <div class="props-game-top">Best current prop: {top_player} | {top_label} | {top_book} | {top_verdict}</div> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| label = f"Open {matchup}" | |
| with st.expander(label, expanded=False): | |
| player_entries = game_payload.get("players") or [] | |
| if not player_entries: | |
| st.info("No player props available for this game.") | |
| return | |
| for player_entry in player_entries: | |
| render_player_hr_row(player_entry) | |
| def render_game_explorer(game_player_props_map: dict[str, dict[str, Any]]) -> None: | |
| st.markdown('<div class="props-section-gap"></div>', unsafe_allow_html=True) | |
| st.markdown('<div class="props-section-kicker">Research Board</div>', unsafe_allow_html=True) | |
| st.markdown("#### By-Game Explorer") | |
| st.caption("Open a matchup to compare each player's best 1+ HR line, then expand a player for all books and alternate ladders.") | |
| if not game_player_props_map: | |
| st.info("No game-grouped props available for the current filters.") | |
| return | |
| for game_payload in game_player_props_map.values(): | |
| render_game_card(game_payload) | |
| def _build_flat_table_rows(display: pd.DataFrame, market_type: str) -> list[dict[str, Any]]: | |
| if market_type == "hr": | |
| return [ | |
| { | |
| "Player": str(row.get("player_name_raw") or row.get("player_name") or "-"), | |
| "Matchup": _build_matchup(row), | |
| "Game Time": _build_game_time(row), | |
| "Book": str(row.get("sportsbook") or "-"), | |
| "HR Line": _display_market_line_label(row), | |
| "Verdict": str(row.get("verdict") or "-").upper(), | |
| "Odds": _format_odds(row.get("odds_american")), | |
| "Implied%": _format_pct(row.get("implied_prob")), | |
| "Pregame HR%": _format_pct(row.get("model_hr_prob")), | |
| "EV": _format_ev(row.get("bet_ev")), | |
| "Confidence": _format_confidence(row.get("confidence_score")), | |
| "Model Voice": str(row.get("model_voice") or "-"), | |
| "Source": str(row.get("model_hr_prob_source") or "-"), | |
| "Edge": _format_edge(row.get("edge")), | |
| } | |
| for _, row in display.iterrows() | |
| ] | |
| return [ | |
| { | |
| "Player": str(row.get("player_name_raw") or row.get("player_name") or "-"), | |
| "Matchup": _build_matchup(row), | |
| "Game Time": _build_game_time(row), | |
| "Book": str(row.get("sportsbook") or "-"), | |
| "Market": _display_market_line_label(row), | |
| "Verdict": str(row.get("verdict") or "-").upper(), | |
| "Odds": _format_odds(row.get("odds_american")), | |
| "Implied%": _format_pct(row.get("implied_prob")), | |
| "Fair%": _format_pct(row.get("fair_prob")), | |
| "EV": _format_ev(row.get("bet_ev")), | |
| "Confidence": _format_confidence(row.get("confidence_score")), | |
| "Model Voice": str(row.get("model_voice") or "-"), | |
| "Edge": _format_edge(row.get("edge")), | |
| } | |
| for _, row in display.iterrows() | |
| ] | |
| def render_flat_props_table(display: pd.DataFrame, market_type: str) -> None: | |
| st.markdown("#### Flat Props Table") | |
| if market_type == "no_hr": | |
| st.caption("No Home Run is tracked when available, but it is not actionable until a fair-probability model is active.") | |
| table_df = pd.DataFrame(_build_flat_table_rows(display, market_type)) | |
| st.dataframe(_style_metric_dataframe(table_df), use_container_width=True, hide_index=True) | |
| def _render_summary_metrics(display: pd.DataFrame, market_type: str) -> None: | |
| if market_type == "hr": | |
| col1, col2, col3 = st.columns(3) | |
| modeled_display = _modeled_hr_primary_subset(display) | |
| col1.metric("Shown modeled 1+ HR", len(modeled_display)) | |
| with_edge = modeled_display["edge"].dropna() if "edge" in modeled_display.columns else pd.Series(dtype=float) | |
| with_ev = modeled_display["bet_ev"].dropna() if "bet_ev" in modeled_display.columns else pd.Series(dtype=float) | |
| col2.metric("Shown rows with priced edge", len(with_edge)) | |
| col3.metric("Best EV", _format_ev(float(with_ev.max())) if not with_ev.empty else "-") | |
| else: | |
| col1, col2, col3 = st.columns(3) | |
| col1.metric("Props shown", len(display)) | |
| modeled = display[display["is_modeled"] == True] if "is_modeled" in display.columns else pd.DataFrame() | |
| col2.metric("Modeled", len(modeled)) | |
| with_ev = display["bet_ev"].dropna() if "bet_ev" in display.columns else pd.Series(dtype=float) | |
| col3.metric("Best EV", _format_ev(float(with_ev.max())) if not with_ev.empty else "-") | |
| def render_probability_diagnostics(display: pd.DataFrame) -> None: | |
| with st.expander("Probability Diagnostics", expanded=False): | |
| diag_cols = [ | |
| "player_name", | |
| "sportsbook", | |
| "display_label", | |
| "verdict", | |
| "model_voice", | |
| "model_voice_primary_reason", | |
| "model_voice_caveat", | |
| "model_voice_for", | |
| "model_voice_against", | |
| "baseline_hr_prob", | |
| "raw_hr_prob", | |
| "calibrated_hr_prob", | |
| "pregame_hr_prob", | |
| "fair_prob", | |
| "bet_ev", | |
| "confidence_score", | |
| "confidence_bucket", | |
| "confidence_reasons", | |
| "opportunity_hr_adjustment", | |
| "expected_pa", | |
| "lineup_slot_used", | |
| "lineup_slot_source", | |
| "team_total_used", | |
| "pitcher_hr_adjustment", | |
| "trend_hr_adjustment", | |
| "zone_hr_adjustment", | |
| "family_zone_hr_adjustment", | |
| "arsenal_hr_adjustment", | |
| "pulled_contact_hr_adjustment", | |
| "env_hr_adjustment", | |
| "park_hr_adjustment", | |
| "weather_hr_adjustment", | |
| "platoon_hr_adjustment", | |
| "trajectory_hr_adjustment", | |
| "rolling_hr_adjustment", | |
| "expected_strikeouts", | |
| "pitcher_swstr_rate", | |
| "pitcher_csw_rate", | |
| "pitcher_ball_rate", | |
| "arsenal_whiff_risk", | |
| "family_zone_whiff_risk", | |
| "zone_whiff_risk", | |
| "sequencing_score", | |
| "trajectory_tunnel_score", | |
| "trajectory_release_consistency_score", | |
| "applied_layers", | |
| "skipped_layers", | |
| "resolved_pitcher_name", | |
| ] | |
| diag_display = display[[c for c in diag_cols if c in display.columns]].copy() | |
| st.dataframe(diag_display, use_container_width=True, hide_index=True) | |
| def render_execution_layer(display: pd.DataFrame) -> None: | |
| if "final_recommendation_score" not in display.columns: | |
| return | |
| with st.expander("Execution Layer", expanded=False): | |
| exec_cols = [ | |
| "player_name", | |
| "sportsbook", | |
| "display_label", | |
| "edge_raw", | |
| "edge_filtered", | |
| "execution_confidence_score", | |
| "execution_volatility_score", | |
| "execution_signal_strength_score", | |
| "market_width", | |
| "market_outlier_flag", | |
| "stale_book_flag", | |
| "timing_flag", | |
| "correlation_flag", | |
| "final_recommendation_score", | |
| "edge_filter_flags", | |
| ] | |
| exec_display = display[[c for c in exec_cols if c in display.columns]].copy() | |
| exec_display = exec_display.sort_values( | |
| "final_recommendation_score", | |
| ascending=False, | |
| na_position="last", | |
| ) | |
| st.dataframe(exec_display, use_container_width=True, hide_index=True) | |
| def render_props( | |
| statcast_df: pd.DataFrame | None = None, | |
| conn=None, | |
| raw_props: pd.DataFrame | None = None, | |
| pitcher_statcast_df: pd.DataFrame | None = None, | |
| probable_starters: dict | None = None, | |
| ) -> None: | |
| _render_props_ui_styles() | |
| raw = _load_raw_props(raw_props) | |
| if raw.empty: | |
| render_props_hero(pd.DataFrame(), view_model=None) | |
| _render_empty_props_state() | |
| return | |
| if "market" not in raw.columns: | |
| raw["market"] = "hr" | |
| available_markets = sorted(raw["market"].dropna().unique().tolist()) | |
| default_idx = available_markets.index("hr") if "hr" in available_markets else 0 | |
| market_type = st.selectbox( | |
| "Market", | |
| options=available_markets, | |
| index=default_idx, | |
| key="props_market", | |
| format_func=_market_label, | |
| ) | |
| prepared_bundle = _load_props_prepared_bundle( | |
| raw=raw, | |
| probable_starters=probable_starters, | |
| ) | |
| import time as _time | |
| if prepared_bundle.get("snapshot_source_status") in ("runtime_fallback_timeout", "patch_build_timeout"): | |
| st.session_state.setdefault("props_baseline_reload_at", _time.time() + 360) | |
| _reload_at = st.session_state.get("props_baseline_reload_at") | |
| if _reload_at: | |
| if _time.time() < _reload_at: | |
| st.info( | |
| "📊 **Player baseline data is loading in the background.** " | |
| "Props are shown with basic line analysis. " | |
| "The page will refresh automatically with full Statcast enrichment in a few minutes." | |
| ) | |
| else: | |
| del st.session_state["props_baseline_reload_at"] | |
| _load_props_prepared_bundle.clear() | |
| st.rerun() | |
| prepared_signature = prepared_bundle.get("signature") | |
| if st.session_state.get("_props_prepared_signature") != prepared_signature: | |
| st.session_state["_props_prepared_signature"] = prepared_signature | |
| st.session_state["props_prepared_bundle"] = prepared_bundle | |
| st.session_state["props_raw_feed"] = raw | |
| st.session_state["props_supported_markets"] = list(prepared_bundle.get("supported_markets") or []) | |
| st.session_state["props_modeled_market_bundle"] = {} | |
| st.session_state.pop("props_view_model_bundle", None) | |
| st.session_state.pop("props_exec_df", None) | |
| st.session_state.pop("props_market_debug_bundle", None) | |
| st.session_state.pop("_props_market_debug_signature", None) | |
| st.session_state.pop("_props_exec_df_signature", None) | |
| st.session_state.pop("_props_exec_df_combined", None) | |
| else: | |
| st.session_state["props_prepared_bundle"] = prepared_bundle | |
| st.session_state["props_raw_feed"] = raw | |
| st.session_state["props_supported_markets"] = list(prepared_bundle.get("supported_markets") or []) | |
| starter_bundle = prepared_bundle.get("starter_bundle") or {} | |
| st.session_state["props_starter_debug"] = { | |
| **dict(prepared_bundle.get("starter_debug") or {}), | |
| "starter_cache_age_seconds": st.session_state.get("probable_starters_cache_age_seconds"), | |
| "starter_refresh_mode": st.session_state.get("probable_starters_refresh_mode"), | |
| } | |
| filtered_raw = raw[raw["market"] == market_type].copy() | |
| if filtered_raw.empty: | |
| render_props_hero(pd.DataFrame(), view_model=None) | |
| st.info(f"No {market_type.upper()} props in current feed.") | |
| return | |
| modeled_market_bundle = st.session_state.get("props_modeled_market_bundle") or {} | |
| market_payload = modeled_market_bundle.get(market_type) | |
| if market_payload is None: | |
| market_payload = _build_market_payload_from_prepared_bundle( | |
| raw=raw, | |
| market_type=market_type, | |
| prepared_bundle=prepared_bundle, | |
| capture_debug=False, | |
| ) | |
| if market_payload is not None: | |
| modeled_market_bundle = dict(modeled_market_bundle) | |
| modeled_market_bundle[market_type] = market_payload | |
| st.session_state["props_modeled_market_bundle"] = modeled_market_bundle | |
| if market_payload is None: | |
| render_props_hero(pd.DataFrame(), view_model=None) | |
| st.info("No props could be prepared for the selected market.") | |
| return | |
| mapped = market_payload["mapped"] | |
| st.session_state["props_exec_df"] = mapped | |
| _hydrate_props_debug_state( | |
| market_type=market_type, | |
| payload=market_payload, | |
| ) | |
| if mapped.empty: | |
| render_props_hero(pd.DataFrame(), view_model=None) | |
| st.info("No mappable HR prop rows." if market_type == "hr" else "No props available.") | |
| return | |
| _log_t = threading.Thread(target=_maybe_log_props, args=(conn, mapped.copy()), daemon=True) | |
| _log_t.start() | |
| _log_t.join(timeout=5) | |
| view_model = build_hr_props_view_model(mapped) if market_type == "hr" else None | |
| if view_model is not None: | |
| st.session_state["props_view_model_bundle"] = view_model | |
| else: | |
| st.session_state.pop("props_view_model_bundle", None) | |
| selected_books, min_edge, sort_option, view = _get_current_filter_state(mapped, market_type) | |
| analysis_display, table_display = _prepare_display_frames( | |
| mapped=mapped, | |
| market_type=market_type, | |
| selected_books=selected_books, | |
| min_edge=min_edge, | |
| sort_option=sort_option, | |
| view=view, | |
| ) | |
| if analysis_display.empty: | |
| _render_terminal_header(mapped, market_type, pd.DataFrame()) | |
| st.info("No props match the current filters.") | |
| return | |
| top_bets_df = _build_terminal_top_bets_df(analysis_display, market_type, limit=7) | |
| _render_terminal_header(analysis_display, market_type, top_bets_df) | |
| render_terminal_top_bets_board(top_bets_df, market_type) | |
| st.markdown('<div class="props-filter-rail">', unsafe_allow_html=True) | |
| st.markdown('<div class="props-section-kicker">Controls</div>', unsafe_allow_html=True) | |
| st.markdown('<div class="props-filter-sub">Use the active filters to reshape the board, then jump directly into a matchup workspace below.</div>', unsafe_allow_html=True) | |
| selected_books, min_edge, sort_option, view = _render_filter_controls(mapped, market_type) | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| st.markdown('<div class="props-secondary-shell">', unsafe_allow_html=True) | |
| _render_market_coverage_note(mapped, market_type) | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| analysis_display, table_display = _prepare_display_frames( | |
| mapped=mapped, | |
| market_type=market_type, | |
| selected_books=selected_books, | |
| min_edge=min_edge, | |
| sort_option=sort_option, | |
| view=view, | |
| ) | |
| if analysis_display.empty: | |
| st.info("No props match the current filters.") | |
| return | |
| slate_modeled_df = _build_best_on_slate_source( | |
| modeled_market_bundle=modeled_market_bundle, | |
| active_market_type=market_type, | |
| active_mapped=mapped, | |
| ) | |
| best_on_slate_df = build_best_on_slate_df(slate_modeled_df, limit=8) | |
| best_on_slate_summary = build_best_on_slate_summary(slate_modeled_df) | |
| st.session_state["props_best_on_slate_debug"] = { | |
| "rows": best_on_slate_df.to_dict("records") if not best_on_slate_df.empty else [], | |
| "summary": best_on_slate_summary, | |
| } | |
| filtered_view_model = build_hr_props_view_model(analysis_display) if market_type == "hr" else None | |
| game_summary_df, game_map = _normalize_game_summary_rows( | |
| market_type=market_type, | |
| analysis_display=analysis_display, | |
| view_model=filtered_view_model, | |
| ) | |
| selected_game_key = render_game_navigator_terminal(game_summary_df, market_type) | |
| render_selected_game_workspace( | |
| selected_game_key=selected_game_key, | |
| game_map=game_map, | |
| market_type=market_type, | |
| ) | |
| st.markdown('<div class="props-section-kicker">Research Surfaces</div>', unsafe_allow_html=True) | |
| bottom_tabs = st.tabs(["Flat Table", "Probability", "Execution", "Legend"]) | |
| with bottom_tabs[0]: | |
| render_flat_props_table(table_display, market_type) | |
| with bottom_tabs[1]: | |
| render_probability_diagnostics(analysis_display) | |
| with bottom_tabs[2]: | |
| render_execution_layer(analysis_display) | |
| if market_type == "hr": | |
| st.caption( | |
| "Pregame HR% starts from the batter baseline and applies pitcher, matchup, park/weather, trajectory, and rolling context. " | |
| "Live-only pitch telemetry and count/base-out state remain part of the live Dashboard path." | |
| ) | |
| with bottom_tabs[3]: | |
| _render_summary_metrics(table_display, market_type) | |
| _render_props_legend() | |