Syntrex Claude Sonnet 4.6 commited on
Commit
2099da5
·
1 Parent(s): 71c16c1

Batch 14: HR props odds-injection layer + Props page

Browse files

- Add fetch_all_upcoming_hr_props() bulk fetch (single API call, no game filter)
- Add Caesars (williamhill_us) to provider supported books; document Pinnacle path
- Add analytics/props_mapper.py: get_player_hr_prob() with source tracking
(internal_model_baseline via compute_batter_baseline, fallback to unavailable);
modular map_hr_props_to_model() with injectable prob_fn
- Add upcoming_hr_props table to durable DuckDB schema
- Add visualization/props_page.py: Props page with all-book + best-line views,
sportsbook filter, min-edge slider, edge-sorted table, DB logging
- Replace Players tab with Props tab in app navigation

Deferred: Batch 13, Batch 12.5 follow-up, Pinnacle integration, CLV, bankroll sizing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

analytics/props_mapper.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ analytics/props_mapper.py
3
+
4
+ Batch 14: Maps sportsbook HR prop rows to internal model HR probabilities
5
+ and computes edge.
6
+
7
+ Model HR probability resolution order (pre-game, no live lineup context):
8
+ 1. internal_model_baseline — compute_batter_baseline() using batter statcast
9
+ features (EV90, barrel rate, hard hit rate, xwOBA, launch angle).
10
+ Preferred source when plate_appearances > 0.
11
+ 2. unavailable — insufficient statcast coverage for this player.
12
+
13
+ Note: XGBoost HR model (xgb_shadow.py) requires anchor probs from the live
14
+ simulator and cannot be used pre-game. It remains the source for Dashboard
15
+ live-game recommendations only.
16
+
17
+ The prob_fn parameter in map_hr_props_to_model() is injectable so the
18
+ probability source can be swapped later without touching odds ingestion or
19
+ the Props page.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Any, Callable
25
+
26
+ import pandas as pd
27
+
28
+ from analytics.no_vig_props import american_to_implied_prob, compute_edge
29
+ from data.odds_name_map import map_odds_name_to_model_name
30
+ from models.batter_baseline import build_batter_feature_row, compute_batter_baseline
31
+
32
+
33
+ def _build_statcast_name_index(statcast_df: pd.DataFrame) -> dict[str, str]:
34
+ """
35
+ Build a lookup: normalized_name -> original statcast player_name.
36
+ Built once per call to avoid per-row overhead.
37
+ """
38
+ if statcast_df.empty or "player_name" not in statcast_df.columns:
39
+ return {}
40
+ index: dict[str, str] = {}
41
+ for name in statcast_df["player_name"].astype(str).unique():
42
+ normalized = map_odds_name_to_model_name(name)
43
+ if normalized not in index:
44
+ index[normalized] = name
45
+ return index
46
+
47
+
48
+ def get_player_hr_prob(
49
+ player_name_normalized: str,
50
+ statcast_df: pd.DataFrame,
51
+ _name_index: dict[str, str] | None = None,
52
+ ) -> tuple[float | None, str]:
53
+ """
54
+ Returns (prob, source) for a pre-game HR probability.
55
+
56
+ source values:
57
+ "internal_model_baseline" — compute_batter_baseline() with statcast features
58
+ "unavailable" — player not found or insufficient data
59
+ """
60
+ name_index = _name_index if _name_index is not None else _build_statcast_name_index(statcast_df)
61
+
62
+ statcast_name = name_index.get(player_name_normalized)
63
+ if statcast_name is None:
64
+ return (None, "unavailable")
65
+
66
+ feature_row = build_batter_feature_row(statcast_df, statcast_name)
67
+ if feature_row.get("plate_appearances", 0) == 0:
68
+ return (None, "unavailable")
69
+
70
+ baseline = compute_batter_baseline(feature_row)
71
+ hr_prob = baseline.get("hr_prob_base")
72
+ if hr_prob is None:
73
+ return (None, "unavailable")
74
+
75
+ return (float(hr_prob), "internal_model_baseline")
76
+
77
+
78
+ def map_hr_props_to_model(
79
+ props_df: pd.DataFrame,
80
+ statcast_df: pd.DataFrame,
81
+ prob_fn: Callable[[str, pd.DataFrame, dict[str, str] | None], tuple[float | None, str]] | None = None,
82
+ ) -> pd.DataFrame:
83
+ """
84
+ Join HR prop rows to model HR probabilities and compute edge.
85
+
86
+ Adds columns:
87
+ implied_prob — book implied probability (vig-inclusive)
88
+ model_hr_prob — pre-game model HR probability (or None)
89
+ model_hr_prob_source — source label for model_hr_prob
90
+ edge — model_hr_prob - implied_prob (or None)
91
+
92
+ Filters to market == "hr".
93
+ Sorts by edge descending (rows with no edge/model prob sort last).
94
+ prob_fn is injectable for future source swaps; defaults to get_player_hr_prob.
95
+ """
96
+ if props_df.empty:
97
+ return pd.DataFrame()
98
+
99
+ _prob_fn = prob_fn if prob_fn is not None else get_player_hr_prob
100
+
101
+ hr_df = props_df[props_df["market"] == "hr"].copy()
102
+ if hr_df.empty:
103
+ return pd.DataFrame()
104
+
105
+ # Build name index once for all players
106
+ name_index = _build_statcast_name_index(statcast_df)
107
+
108
+ implied_probs: list[float] = []
109
+ model_probs: list[float | None] = []
110
+ sources: list[str] = []
111
+ edges: list[float | None] = []
112
+
113
+ for _, row in hr_df.iterrows():
114
+ odds = row.get("odds_american")
115
+ player_name = str(row.get("player_name") or "")
116
+
117
+ # Implied probability from book odds
118
+ try:
119
+ implied = american_to_implied_prob(odds) if odds is not None else None
120
+ except Exception:
121
+ implied = None
122
+
123
+ # Model HR probability
124
+ if player_name:
125
+ model_prob, source = _prob_fn(player_name, statcast_df, name_index)
126
+ else:
127
+ model_prob, source = None, "unavailable"
128
+
129
+ # Edge
130
+ if model_prob is not None and implied is not None:
131
+ edge = compute_edge(model_prob, implied)
132
+ else:
133
+ edge = None
134
+
135
+ implied_probs.append(implied) # type: ignore[arg-type]
136
+ model_probs.append(model_prob)
137
+ sources.append(source)
138
+ edges.append(edge)
139
+
140
+ hr_df = hr_df.copy()
141
+ hr_df["implied_prob"] = implied_probs
142
+ hr_df["model_hr_prob"] = model_probs
143
+ hr_df["model_hr_prob_source"] = sources
144
+ hr_df["edge"] = edges
145
+
146
+ # Sort: rows with edge first (highest edge first), then no-edge rows
147
+ has_edge = hr_df["edge"].notna()
148
+ with_edge = hr_df[has_edge].sort_values("edge", ascending=False)
149
+ without_edge = hr_df[~has_edge]
150
+
151
+ return pd.concat([with_edge, without_edge], ignore_index=True)
app.py CHANGED
@@ -110,6 +110,8 @@ from database.db import (
110
  read_batter_prop_outcomes,
111
  replace_batter_prop_outcomes,
112
  read_batter_prop_audit_view,
 
 
113
  )
114
 
115
  from features.batter_features import batter_summary
@@ -122,6 +124,7 @@ from visualization.batter import create_exit_velocity_chart, create_launch_angle
122
  from visualization.betting import create_bankroll_chart, create_edge_chart
123
  from visualization.matchup import create_hit_hr_chart, create_matchup_score_chart
124
  from visualization.pitcher import create_pitch_movement_chart
 
125
  from visualization.simulation import create_hr_distribution, create_total_bases_distribution
126
  from visualization.game_cards import render_game_card
127
 
@@ -3962,7 +3965,7 @@ def main() -> None:
3962
  "Navigation",
3963
  options=[
3964
  "Dashboard",
3965
- "Players",
3966
  "Matchups",
3967
  "Betting",
3968
  "Bet Tracker",
@@ -3975,8 +3978,8 @@ def main() -> None:
3975
 
3976
  if page == "Dashboard":
3977
  render_dashboard()
3978
- elif page == "Players":
3979
- render_players()
3980
  elif page == "Matchups":
3981
  render_matchups()
3982
  elif page == "Betting":
 
110
  read_batter_prop_outcomes,
111
  replace_batter_prop_outcomes,
112
  read_batter_prop_audit_view,
113
+ ensure_upcoming_hr_props_table,
114
+ insert_upcoming_hr_props,
115
  )
116
 
117
  from features.batter_features import batter_summary
 
124
  from visualization.betting import create_bankroll_chart, create_edge_chart
125
  from visualization.matchup import create_hit_hr_chart, create_matchup_score_chart
126
  from visualization.pitcher import create_pitch_movement_chart
127
+ from visualization.props_page import render_props
128
  from visualization.simulation import create_hr_distribution, create_total_bases_distribution
129
  from visualization.game_cards import render_game_card
130
 
 
3965
  "Navigation",
3966
  options=[
3967
  "Dashboard",
3968
+ "Props",
3969
  "Matchups",
3970
  "Betting",
3971
  "Bet Tracker",
 
3978
 
3979
  if page == "Dashboard":
3980
  render_dashboard()
3981
+ elif page == "Props":
3982
+ render_props(load_statcast_recent(), conn=conn)
3983
  elif page == "Matchups":
3984
  render_matchups()
3985
  elif page == "Betting":
data/live_prop_odds.py CHANGED
@@ -61,6 +61,40 @@ def best_book_by_player_market(df: pd.DataFrame) -> pd.DataFrame:
61
  return pd.DataFrame(rows)
62
 
63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  def fetch_live_prop_odds(
65
  game_context: dict,
66
  sportsbooks: list[str] | None = None,
 
61
  return pd.DataFrame(rows)
62
 
63
 
64
+ def fetch_all_upcoming_hr_props(
65
+ sportsbooks: list[str] | None = None,
66
+ ) -> pd.DataFrame:
67
+ """
68
+ Fetch HR props for all upcoming MLB games in a single API call (no game filter).
69
+ Returns normalized DataFrame with columns per normalize_prop_odds().
70
+ Returns empty DataFrame on any failure.
71
+ """
72
+ providers = []
73
+
74
+ if ENABLE_ENTERPRISE_PROVIDER:
75
+ providers.append(EnterpriseMarketProvider())
76
+
77
+ providers.append(TheOddsAPIProvider())
78
+
79
+ frames = []
80
+ for provider in providers:
81
+ try:
82
+ fetch_fn = getattr(provider, "fetch_all_upcoming_hr_props", None)
83
+ if fetch_fn is None:
84
+ continue
85
+ df = fetch_fn(sportsbooks=sportsbooks)
86
+ if not df.empty:
87
+ frames.append(df)
88
+ except Exception:
89
+ continue
90
+
91
+ if not frames:
92
+ return pd.DataFrame()
93
+
94
+ merged = pd.concat(frames, ignore_index=True)
95
+ return normalize_prop_odds(merged)
96
+
97
+
98
  def fetch_live_prop_odds(
99
  game_context: dict,
100
  sportsbooks: list[str] | None = None,
data/provider_theoddsapi.py CHANGED
@@ -11,10 +11,20 @@ from data.odds_name_map import map_odds_name_to_model_name
11
 
12
  ODDS_API_BASE = "https://api.the-odds-api.com/v4/sports"
13
 
 
 
 
 
 
 
 
 
 
14
  SUPPORTED_BOOKS = {
15
  "draftkings",
16
  "fanduel",
17
  "betmgm",
 
18
  }
19
 
20
  SUPPORTED_MARKETS = {
@@ -30,9 +40,10 @@ MARKET_NAME_MAP = {
30
  }
31
 
32
  BOOK_KEY_MAP = {
33
- "draftkings": "DraftKings",
34
- "fanduel": "FanDuel",
35
- "betmgm": "BetMGM",
 
36
  }
37
 
38
  TEAM_NAME_ALIASES = {
@@ -174,4 +185,88 @@ class TheOddsAPIProvider(MarketProviderBase):
174
  }
175
  )
176
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  return pd.DataFrame(rows)
 
11
 
12
  ODDS_API_BASE = "https://api.the-odds-api.com/v4/sports"
13
 
14
+ # ---------------------------------------------------------------------------
15
+ # Provider strategy (Batch 14)
16
+ # Active v1: The Odds API → DraftKings, FanDuel, BetMGM, Caesars (williamhill_us)
17
+ # Sharp feed: Pinnacle — planned as separate PinnacleProvider class with its own
18
+ # API key; register it in live_prop_odds.py when ready
19
+ # Deferred: Bet365, Circa (unclear API availability on The Odds API)
20
+ # Enterprise: ENABLE_ENTERPRISE_PROVIDER flag in config/settings.py
21
+ # ---------------------------------------------------------------------------
22
+
23
  SUPPORTED_BOOKS = {
24
  "draftkings",
25
  "fanduel",
26
  "betmgm",
27
+ "williamhill_us", # Caesars
28
  }
29
 
30
  SUPPORTED_MARKETS = {
 
40
  }
41
 
42
  BOOK_KEY_MAP = {
43
+ "draftkings": "DraftKings",
44
+ "fanduel": "FanDuel",
45
+ "betmgm": "BetMGM",
46
+ "williamhill_us": "Caesars",
47
  }
48
 
49
  TEAM_NAME_ALIASES = {
 
185
  }
186
  )
187
 
188
+ return pd.DataFrame(rows)
189
+
190
+ def fetch_all_upcoming_hr_props(
191
+ self,
192
+ sportsbooks: list[str] | None = None,
193
+ ) -> pd.DataFrame:
194
+ """
195
+ Fetch HR props for ALL upcoming MLB events in a single API call.
196
+ Unlike fetch_live_prop_odds(), this applies no game-level team filter —
197
+ every event in the payload is included.
198
+
199
+ HR market only (batter_home_runs).
200
+ """
201
+ if not ODDS_API_KEY:
202
+ return pd.DataFrame()
203
+
204
+ books = [b for b in (sportsbooks or ["draftkings", "fanduel", "betmgm"]) if b in SUPPORTED_BOOKS]
205
+ if not books:
206
+ return pd.DataFrame()
207
+
208
+ sport_key = "baseball_mlb"
209
+ url = f"{ODDS_API_BASE}/{sport_key}/odds"
210
+ params = {
211
+ "apiKey": ODDS_API_KEY,
212
+ "regions": "us",
213
+ "markets": "batter_home_runs",
214
+ "bookmakers": ",".join(books),
215
+ "oddsFormat": "american",
216
+ "dateFormat": "iso",
217
+ }
218
+
219
+ response = requests.get(url, params=params, timeout=30)
220
+ response.raise_for_status()
221
+ payload = response.json()
222
+
223
+ rows: list[dict[str, Any]] = []
224
+
225
+ for event in payload:
226
+ event_id = str(event.get("id", "") or "")
227
+ commence_time = str(event.get("commence_time", "") or "")
228
+ away_team = str(event.get("away_team", "") or "")
229
+ home_team = str(event.get("home_team", "") or "")
230
+
231
+ for bookmaker in event.get("bookmakers", []) or []:
232
+ book_key = str(bookmaker.get("key", "") or "")
233
+ book_name = BOOK_KEY_MAP.get(book_key, book_key)
234
+
235
+ for market in bookmaker.get("markets", []) or []:
236
+ market_key = str(market.get("key", "") or "")
237
+ if market_key != "batter_home_runs":
238
+ continue
239
+ market_name = MARKET_NAME_MAP.get(market_key, market_key)
240
+
241
+ for outcome in market.get("outcomes", []) or []:
242
+ player_name_raw = str(
243
+ outcome.get("description", "") or outcome.get("name", "") or ""
244
+ ).strip()
245
+ if not player_name_raw:
246
+ continue
247
+
248
+ price = outcome.get("price")
249
+ if price is None:
250
+ continue
251
+
252
+ line = _safe_float(outcome.get("point"))
253
+
254
+ rows.append(
255
+ {
256
+ "provider": self.provider_name,
257
+ "event_id": event_id,
258
+ "commence_time": commence_time,
259
+ "away_team": away_team,
260
+ "home_team": home_team,
261
+ "sportsbook": book_name,
262
+ "sportsbook_key": book_key,
263
+ "market_key": market_key,
264
+ "market": market_name,
265
+ "player_name_raw": player_name_raw,
266
+ "player_name": map_odds_name_to_model_name(player_name_raw),
267
+ "odds_american": int(price),
268
+ "line": line,
269
+ }
270
+ )
271
+
272
  return pd.DataFrame(rows)
database/db.py CHANGED
@@ -434,6 +434,64 @@ def replace_batter_prop_outcomes(conn, df: pd.DataFrame) -> None:
434
  )
435
  conn.unregister("batter_prop_outcomes_replace_df")
436
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
437
  def read_batter_prop_audit_view(conn) -> pd.DataFrame:
438
  ensure_batter_prop_outcomes_table(conn)
439
 
 
434
  )
435
  conn.unregister("batter_prop_outcomes_replace_df")
436
 
437
+ def ensure_upcoming_hr_props_table(conn) -> None:
438
+ conn.execute(
439
+ """
440
+ CREATE TABLE IF NOT EXISTS upcoming_hr_props (
441
+ fetched_at TEXT,
442
+ event_id TEXT,
443
+ commence_time TEXT,
444
+ away_team TEXT,
445
+ home_team TEXT,
446
+ sportsbook TEXT,
447
+ market TEXT,
448
+ player_name_raw TEXT,
449
+ player_name TEXT,
450
+ odds_american INTEGER,
451
+ line DOUBLE,
452
+ implied_prob DOUBLE,
453
+ model_hr_prob DOUBLE,
454
+ model_hr_prob_source TEXT,
455
+ edge DOUBLE
456
+ )
457
+ """
458
+ )
459
+ for _col, _dtype in [
460
+ ("model_hr_prob_source", "TEXT"),
461
+ ("edge", "DOUBLE"),
462
+ ]:
463
+ try:
464
+ conn.execute(f"ALTER TABLE upcoming_hr_props ADD COLUMN {_col} {_dtype}")
465
+ except Exception:
466
+ pass # Column already exists
467
+
468
+
469
+ def insert_upcoming_hr_props(conn, df: pd.DataFrame) -> None:
470
+ if df is None or df.empty:
471
+ return
472
+ ensure_upcoming_hr_props_table(conn)
473
+ conn.register("upcoming_hr_props_df", df)
474
+ conn.execute(
475
+ """
476
+ INSERT INTO upcoming_hr_props
477
+ SELECT
478
+ fetched_at, event_id, commence_time, away_team, home_team,
479
+ sportsbook, market, player_name_raw, player_name,
480
+ odds_american, line, implied_prob, model_hr_prob,
481
+ model_hr_prob_source, edge
482
+ FROM upcoming_hr_props_df
483
+ """
484
+ )
485
+ conn.unregister("upcoming_hr_props_df")
486
+
487
+
488
+ def read_upcoming_hr_props(conn) -> pd.DataFrame:
489
+ ensure_upcoming_hr_props_table(conn)
490
+ return conn.execute(
491
+ "SELECT * FROM upcoming_hr_props ORDER BY fetched_at DESC"
492
+ ).df()
493
+
494
+
495
  def read_batter_prop_audit_view(conn) -> pd.DataFrame:
496
  ensure_batter_prop_outcomes_table(conn)
497
 
visualization/props_page.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ visualization/props_page.py
3
+
4
+ Batch 14: Props page — upcoming HR props sorted by edge.
5
+
6
+ Displays HR prop odds from retail sportsbooks (DK, FD, BetMGM, Caesars)
7
+ alongside model HR probabilities and computed edge.
8
+
9
+ Model HR% source:
10
+ - "internal_model_baseline": compute_batter_baseline() from statcast features
11
+ (EV90, barrel rate, hard hit rate, xwOBA, launch angle). Pre-game only.
12
+ - "unavailable": player not found in statcast data.
13
+
14
+ Full simulator context (matchup, park, weather adjustments) is only available
15
+ for live in-game batters — see the Dashboard tab.
16
+
17
+ Deferred:
18
+ - Batch 13: post-game evaluation grading, multi-market expansion
19
+ - Batch 12.5 follow-up: audit view join fix
20
+ - Pinnacle sharp-feed integration
21
+ - xgb_hr_adjusted for pre-game use (requires anchor probs from simulator)
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import pandas as pd
27
+ import streamlit as st
28
+
29
+ from analytics.props_mapper import map_hr_props_to_model
30
+ from config.settings import DEFAULT_PROP_BOOKS
31
+ from data.live_prop_odds import fetch_all_upcoming_hr_props
32
+ from database.db import ensure_upcoming_hr_props_table, insert_upcoming_hr_props
33
+ from utils.helpers import utc_now_iso
34
+
35
+
36
+ def _format_pct(val: float | None) -> str:
37
+ if val is None:
38
+ return "—"
39
+ return f"{val * 100:.1f}%"
40
+
41
+
42
+ def _format_odds(val: int | float | None) -> str:
43
+ if val is None:
44
+ return "—"
45
+ v = int(val)
46
+ return f"+{v}" if v > 0 else str(v)
47
+
48
+
49
+ def _format_edge(val: float | None) -> str:
50
+ if val is None:
51
+ return "—"
52
+ return f"{val * 100:+.1f}%"
53
+
54
+
55
+ def render_props(statcast_df: pd.DataFrame, conn=None) -> None:
56
+ st.subheader("Props")
57
+ st.caption("HR — upcoming games — sorted by edge")
58
+
59
+ # Fetch all upcoming HR props (single API call, no game filter)
60
+ with st.spinner("Loading HR props..."):
61
+ raw = fetch_all_upcoming_hr_props(sportsbooks=DEFAULT_PROP_BOOKS)
62
+
63
+ if raw.empty:
64
+ st.warning(
65
+ "No HR props returned. Check ODDS_API_KEY or provider coverage. "
66
+ "This may also occur outside of game days."
67
+ )
68
+ return
69
+
70
+ hr_props = raw[raw["market"] == "hr"].copy() if not raw.empty else raw
71
+ if hr_props.empty:
72
+ st.info("No HR props in current feed.")
73
+ return
74
+
75
+ # Map to model + compute edge
76
+ mapped = map_hr_props_to_model(hr_props, statcast_df)
77
+ if mapped.empty:
78
+ st.info("No mappable HR prop rows.")
79
+ return
80
+
81
+ # Log to durable DB (non-blocking)
82
+ if conn is not None:
83
+ try:
84
+ to_log = mapped.copy()
85
+ to_log["fetched_at"] = utc_now_iso()
86
+ # Ensure only expected columns are present
87
+ log_cols = [
88
+ "fetched_at", "event_id", "commence_time", "away_team", "home_team",
89
+ "sportsbook", "market", "player_name_raw", "player_name",
90
+ "odds_american", "line", "implied_prob", "model_hr_prob",
91
+ "model_hr_prob_source", "edge",
92
+ ]
93
+ for col in log_cols:
94
+ if col not in to_log.columns:
95
+ to_log[col] = None
96
+ ensure_upcoming_hr_props_table(conn)
97
+ insert_upcoming_hr_props(conn, to_log[log_cols])
98
+ except Exception:
99
+ pass
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # Sidebar filters
103
+ # ---------------------------------------------------------------------------
104
+ all_books = sorted(mapped["sportsbook"].dropna().unique().tolist())
105
+ selected_books = st.sidebar.multiselect(
106
+ "Sportsbook", options=all_books, default=all_books, key="props_books"
107
+ )
108
+ min_edge = st.sidebar.slider(
109
+ "Min edge", min_value=-0.50, max_value=0.50, value=-0.50, step=0.01,
110
+ format="%.0f%%", key="props_min_edge",
111
+ )
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # View toggle
115
+ # ---------------------------------------------------------------------------
116
+ view = st.radio("View", ["All Books", "Best Line Per Player"], horizontal=True)
117
+
118
+ # Apply filters
119
+ display = mapped.copy()
120
+ if selected_books:
121
+ display = display[display["sportsbook"].isin(selected_books)]
122
+ if min_edge > -0.50:
123
+ display = display[display["edge"].notna() & (display["edge"] >= min_edge)]
124
+
125
+ if view == "Best Line Per Player":
126
+ # Best edge per player (highest edge across all books)
127
+ display = (
128
+ display
129
+ .sort_values("edge", ascending=False, na_position="last")
130
+ .drop_duplicates("player_name")
131
+ .reset_index(drop=True)
132
+ )
133
+
134
+ if display.empty:
135
+ st.info("No props match the current filters.")
136
+ return
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # Summary metrics
140
+ # ---------------------------------------------------------------------------
141
+ col1, col2, col3 = st.columns(3)
142
+ col1.metric("Props shown", len(display))
143
+ with_edge = display["edge"].dropna()
144
+ col2.metric("With model edge", len(with_edge))
145
+ if not with_edge.empty:
146
+ col3.metric("Best edge", _format_edge(float(with_edge.max())))
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # Table
150
+ # ---------------------------------------------------------------------------
151
+ def _build_matchup(row: pd.Series) -> str:
152
+ away = str(row.get("away_team") or "")
153
+ home = str(row.get("home_team") or "")
154
+ if away and home:
155
+ return f"{away} @ {home}"
156
+ return away or home or "—"
157
+
158
+ def _build_game_time(row: pd.Series) -> str:
159
+ ct = str(row.get("commence_time") or "")
160
+ if not ct:
161
+ return "—"
162
+ # ISO timestamp: take date + time portion only
163
+ try:
164
+ import datetime
165
+ dt = datetime.datetime.fromisoformat(ct.replace("Z", "+00:00"))
166
+ return dt.strftime("%m/%d %I:%M%p UTC")
167
+ except Exception:
168
+ return ct[:16]
169
+
170
+ rows = []
171
+ for _, row in display.iterrows():
172
+ name_raw = str(row.get("player_name_raw") or row.get("player_name") or "—")
173
+ rows.append({
174
+ "Player": name_raw,
175
+ "Matchup": _build_matchup(row),
176
+ "Game Time": _build_game_time(row),
177
+ "Book": str(row.get("sportsbook") or "—"),
178
+ "Odds": _format_odds(row.get("odds_american")),
179
+ "Implied%": _format_pct(row.get("implied_prob")),
180
+ "Model HR%": _format_pct(row.get("model_hr_prob")),
181
+ "Source": str(row.get("model_hr_prob_source") or "—"),
182
+ "Edge": _format_edge(row.get("edge")),
183
+ })
184
+
185
+ table_df = pd.DataFrame(rows)
186
+ st.dataframe(table_df, use_container_width=True, hide_index=True)
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # Disclaimer
190
+ # ---------------------------------------------------------------------------
191
+ st.caption(
192
+ "Model HR% is a pre-game baseline from statcast features (EV90, barrel rate, "
193
+ "xwOBA, launch angle). It does not include live matchup, park, or weather "
194
+ "adjustments. Full simulator context is available only during live games "
195
+ "(Dashboard tab). Source column indicates data source used per player."
196
+ )