2026_MLB_Model / visualization /props_page.py
Syntrex's picture
Recover props terminal UI redesign; fix pitcher filter and cache hang
0b4a187
raw
history blame
111 kB
"""
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 = "&nbsp;"
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
@st.cache_data(ttl=60 * 10, show_spinner=False)
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()