Syntrex Claude Sonnet 4.6 commited on
Commit
0b4a187
·
1 Parent(s): 28a1d5a

Recover props terminal UI redesign; fix pitcher filter and cache hang

Browse files

- Restore be663f7 'Redesign Props page terminal UI' (521 insertions) lost
during HF Space recovery git reset
- Fix _filter_probable_starters_to_slate() to use _canonical_team() instead
of _normalize_team_key() so 'sfg' matches 'san francisco giants'
- Add LIMIT 5000 to cached_upcoming_props_rows query to prevent 5+ min
full-table scan as table grows unbounded
- Wrap read_cached_upcoming_props_bundle() in thread+10s timeout; on
timeout raises RuntimeError caught by existing except block → falls
through to live HTTP fetch
- Re-apply timeout banner (360s auto-reload) and async _maybe_log_props

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

Files changed (3) hide show
  1. app.py +12 -1
  2. database/db.py +1 -0
  3. visualization/props_page.py +527 -41
app.py CHANGED
@@ -1014,7 +1014,18 @@ def load_upcoming_hr_props() -> pd.DataFrame:
1014
  @st.cache_data(ttl=300, show_spinner=False)
1015
  def load_upcoming_hr_props_bundle() -> dict:
1016
  try:
1017
- cached_bundle = read_cached_upcoming_props_bundle(conn, cache_key="default")
 
 
 
 
 
 
 
 
 
 
 
1018
  cache_meta = cached_bundle.get("cache_meta", pd.DataFrame())
1019
  merged = cached_bundle.get("merged_props_feed", pd.DataFrame())
1020
  coverage = cached_bundle.get("coverage_summary", pd.DataFrame())
 
1014
  @st.cache_data(ttl=300, show_spinner=False)
1015
  def load_upcoming_hr_props_bundle() -> dict:
1016
  try:
1017
+ _cache_result: list[dict | None] = [None]
1018
+ def _read_db_cache() -> None:
1019
+ try:
1020
+ _cache_result[0] = read_cached_upcoming_props_bundle(conn, cache_key="default")
1021
+ except Exception:
1022
+ pass
1023
+ _dbt = threading.Thread(target=_read_db_cache, daemon=True)
1024
+ _dbt.start()
1025
+ _dbt.join(timeout=10)
1026
+ if _cache_result[0] is None:
1027
+ raise RuntimeError("DB cache read timed out — falling through to live fetch")
1028
+ cached_bundle = _cache_result[0]
1029
  cache_meta = cached_bundle.get("cache_meta", pd.DataFrame())
1030
  merged = cached_bundle.get("merged_props_feed", pd.DataFrame())
1031
  coverage = cached_bundle.get("coverage_summary", pd.DataFrame())
database/db.py CHANGED
@@ -1299,6 +1299,7 @@ def read_cached_upcoming_props_bundle(
1299
  SELECT * FROM cached_upcoming_props_rows
1300
  WHERE cache_key = :cache_key
1301
  ORDER BY fetched_at DESC, event_id, player_name
 
1302
  """
1303
  ),
1304
  conn,
 
1299
  SELECT * FROM cached_upcoming_props_rows
1300
  WHERE cache_key = :cache_key
1301
  ORDER BY fetched_at DESC, event_id, player_name
1302
+ LIMIT 5000
1303
  """
1304
  ),
1305
  conn,
visualization/props_page.py CHANGED
@@ -33,6 +33,7 @@ from database.db import (
33
  )
34
  from utils.helpers import utc_now_iso
35
  from data.mlb_starters import (
 
36
  build_oddsapi_starter_fallback_map,
37
  lookup_pitchers_for_game,
38
  merge_probable_starters_with_odds_fallback,
@@ -310,6 +311,158 @@ def _render_props_ui_styles() -> None:
310
  color: #afc0d3;
311
  margin: 0.9rem 0 1.1rem 0;
312
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
  </style>
314
  """,
315
  unsafe_allow_html=True,
@@ -646,13 +799,13 @@ def _filter_probable_starters_to_slate(
646
  ) -> dict:
647
  if not probable_starters or not slate_teams:
648
  return {}
649
- team_scope = {_normalize_team_key(team) for team in slate_teams if str(team).strip()}
650
  out: dict = {}
651
  for key, payload in probable_starters.items():
652
  if not isinstance(key, tuple) or len(key) != 2:
653
  continue
654
- away_norm = _normalize_team_key(key[0])
655
- home_norm = _normalize_team_key(key[1])
656
  if away_norm in team_scope and home_norm in team_scope:
657
  out[key] = payload
658
  return out
@@ -1080,6 +1233,26 @@ def _render_filter_controls(mapped: pd.DataFrame, market_type: str) -> tuple[lis
1080
  return selected_books, min_edge, sort_option, view
1081
 
1082
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1083
  def _render_market_coverage_note(display: pd.DataFrame, market_type: str) -> None:
1084
  if display is None or display.empty or "sportsbook" not in display.columns:
1085
  return
@@ -1574,6 +1747,306 @@ def render_best_on_slate_cards(best_df: pd.DataFrame, summary: dict[str, Any] |
1574
  )
1575
 
1576
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1577
  def render_player_hr_details(player_details: dict[str, Any]) -> None:
1578
  primary_rows = pd.DataFrame(player_details.get("primary_rows") or [])
1579
  alt_rows = pd.DataFrame(player_details.get("alt_rows") or [])
@@ -1989,14 +2462,14 @@ def render_props(
1989
  )
1990
  import time as _time
1991
  if prepared_bundle.get("snapshot_source_status") in ("runtime_fallback_timeout", "patch_build_timeout"):
1992
- st.session_state.setdefault("props_baseline_reload_at", _time.time() + 80)
1993
  _reload_at = st.session_state.get("props_baseline_reload_at")
1994
  if _reload_at:
1995
  if _time.time() < _reload_at:
1996
  st.info(
1997
  "📊 **Player baseline data is loading in the background.** "
1998
  "Props are shown with basic line analysis. "
1999
- "The page will refresh automatically with full Statcast enrichment in about a minute."
2000
  )
2001
  else:
2002
  del st.session_state["props_baseline_reload_at"]
@@ -2070,20 +2543,33 @@ def render_props(
2070
  st.session_state["props_view_model_bundle"] = view_model
2071
  else:
2072
  st.session_state.pop("props_view_model_bundle", None)
2073
- render_props_hero(mapped, view_model=view_model)
2074
- _render_props_legend()
2075
- _render_market_coverage_note(mapped, market_type)
2076
-
2077
- st.caption(
2078
- f"{_market_label(market_type)} - upcoming games - ranked by current model strength. "
2079
- "Best-Value cards show the strongest modeled looks even when the slate is negative."
 
2080
  )
2081
 
 
 
 
 
 
 
 
 
 
2082
  st.markdown('<div class="props-filter-rail">', unsafe_allow_html=True)
2083
- st.markdown('<div class="props-section-kicker">Filters</div>', unsafe_allow_html=True)
2084
- st.markdown('<div class="props-filter-sub">Tune book coverage, edge floor, and list style without losing the featured/actionable surface.</div>', unsafe_allow_html=True)
2085
  selected_books, min_edge, sort_option, view = _render_filter_controls(mapped, market_type)
2086
  st.markdown("</div>", unsafe_allow_html=True)
 
 
 
2087
  analysis_display, table_display = _prepare_display_frames(
2088
  mapped=mapped,
2089
  market_type=market_type,
@@ -2109,32 +2595,32 @@ def render_props(
2109
  "summary": best_on_slate_summary,
2110
  }
2111
 
2112
- if market_type == "hr":
2113
- view_model = build_hr_props_view_model(analysis_display)
2114
- _render_summary_metrics(table_display, market_type)
2115
- render_best_on_slate_cards(best_on_slate_df, best_on_slate_summary)
2116
- st.markdown(
2117
- '<div class="props-release-note">This version is intentionally HR-first. Featured cards rank the strongest modeled 1+ HR looks on the slate, while alternate HR ladders remain available inside each player expander for research and line shopping. A negative edge or EV still means pass.</div>',
2118
- unsafe_allow_html=True,
2119
- )
2120
- render_featured_hr_cards(view_model.get("featured_props_df", pd.DataFrame()))
2121
- render_game_explorer(view_model.get("game_player_props_map", {}))
2122
- with st.expander("Flat Table View", expanded=False):
2123
- render_flat_props_table(table_display, market_type)
 
 
 
 
 
 
2124
  render_probability_diagnostics(analysis_display)
 
2125
  render_execution_layer(analysis_display)
2126
- st.caption(
2127
- "Pregame HR% is produced by the shared pregame engine. It starts from a "
2128
- "batter baseline and applies available pregame layers such as pitcher, "
2129
- "matchup, park/weather, trajectory, and rolling form context. Live-only "
2130
- "signals like pitch telemetry, bullpen transitions, and count/base-out "
2131
- "state are intentionally skipped here and remain part of the live Dashboard path."
2132
- )
2133
- return
2134
-
2135
- st.subheader("Props")
2136
- st.caption("Strikeouts and game-scope markets are shown here as they become available. Verdicts remain model-driven.")
2137
- _render_summary_metrics(table_display, market_type)
2138
- render_best_on_slate_cards(best_on_slate_df, best_on_slate_summary)
2139
- render_flat_props_table(table_display, market_type)
2140
- render_probability_diagnostics(analysis_display)
 
33
  )
34
  from utils.helpers import utc_now_iso
35
  from data.mlb_starters import (
36
+ _canonical_team,
37
  build_oddsapi_starter_fallback_map,
38
  lookup_pitchers_for_game,
39
  merge_probable_starters_with_odds_fallback,
 
311
  color: #afc0d3;
312
  margin: 0.9rem 0 1.1rem 0;
313
  }
314
+ .props-ops-header {
315
+ border: 1px solid rgba(123, 145, 120, 0.28);
316
+ border-radius: 18px;
317
+ padding: 1rem 1.1rem;
318
+ background:
319
+ radial-gradient(circle at top right, rgba(78, 104, 74, 0.18), transparent 32%),
320
+ linear-gradient(180deg, rgba(18, 23, 20, 0.98), rgba(10, 14, 16, 0.98));
321
+ margin-bottom: 0.85rem;
322
+ }
323
+ .props-ops-kicker {
324
+ letter-spacing: 0.16em;
325
+ text-transform: uppercase;
326
+ font-size: 0.68rem;
327
+ color: #b0c98f;
328
+ font-weight: 800;
329
+ }
330
+ .props-ops-title {
331
+ color: #f5f2e8;
332
+ font-size: 1.85rem;
333
+ font-weight: 850;
334
+ line-height: 1.02;
335
+ margin-top: 0.35rem;
336
+ }
337
+ .props-ops-sub {
338
+ color: #b8c0b4;
339
+ font-size: 0.92rem;
340
+ margin-top: 0.35rem;
341
+ }
342
+ .props-terminal-board {
343
+ border: 1px solid rgba(114, 132, 108, 0.26);
344
+ border-radius: 18px;
345
+ padding: 1rem;
346
+ background: linear-gradient(180deg, rgba(17, 20, 18, 0.98), rgba(11, 14, 15, 0.98));
347
+ margin-bottom: 1rem;
348
+ }
349
+ .props-terminal-highlight {
350
+ border: 1px solid rgba(165, 184, 114, 0.28);
351
+ border-left: 4px solid #bccb62;
352
+ border-radius: 16px;
353
+ padding: 1rem 1rem 0.9rem 1rem;
354
+ background: linear-gradient(180deg, rgba(31, 36, 30, 0.96), rgba(18, 23, 22, 0.98));
355
+ margin-bottom: 0.8rem;
356
+ }
357
+ .props-terminal-rank {
358
+ display: inline-block;
359
+ color: #121512;
360
+ background: #d6dd9e;
361
+ border-radius: 999px;
362
+ padding: 0.12rem 0.48rem;
363
+ font-size: 0.72rem;
364
+ font-weight: 800;
365
+ letter-spacing: 0.08em;
366
+ text-transform: uppercase;
367
+ margin-bottom: 0.55rem;
368
+ }
369
+ .props-terminal-mini {
370
+ border: 1px solid rgba(88, 99, 91, 0.32);
371
+ border-radius: 14px;
372
+ padding: 0.8rem 0.85rem;
373
+ background: rgba(19, 22, 22, 0.94);
374
+ min-height: 182px;
375
+ }
376
+ .props-terminal-name {
377
+ color: #f6f2e8;
378
+ font-weight: 760;
379
+ font-size: 1.02rem;
380
+ margin-bottom: 0.15rem;
381
+ }
382
+ .props-terminal-line {
383
+ color: #bfc7bc;
384
+ font-size: 0.82rem;
385
+ margin-bottom: 0.7rem;
386
+ }
387
+ .props-terminal-thesis {
388
+ color: #9fac9a;
389
+ font-size: 0.8rem;
390
+ line-height: 1.4;
391
+ margin-top: 0.65rem;
392
+ }
393
+ .props-terminal-grid {
394
+ display: grid;
395
+ grid-template-columns: repeat(2, minmax(0, 1fr));
396
+ gap: 0.55rem 0.8rem;
397
+ }
398
+ .props-terminal-metric {
399
+ color: #8e998f;
400
+ font-size: 0.68rem;
401
+ text-transform: uppercase;
402
+ letter-spacing: 0.08em;
403
+ }
404
+ .props-terminal-value {
405
+ color: #f4efe5;
406
+ font-size: 1.02rem;
407
+ font-weight: 760;
408
+ }
409
+ .props-terminal-value.good { color: #9fe09d; }
410
+ .props-terminal-value.neutral { color: #e3c87f; }
411
+ .props-terminal-value.bad { color: #f19797; }
412
+ .props-game-nav {
413
+ border: 1px solid rgba(93, 104, 98, 0.26);
414
+ border-radius: 18px;
415
+ padding: 0.95rem 1rem 0.6rem 1rem;
416
+ background: linear-gradient(180deg, rgba(18, 20, 22, 0.96), rgba(11, 13, 15, 0.98));
417
+ margin-bottom: 1rem;
418
+ }
419
+ .props-game-nav-card {
420
+ border: 1px solid rgba(79, 92, 88, 0.28);
421
+ border-radius: 14px;
422
+ padding: 0.75rem 0.8rem;
423
+ background: rgba(20, 22, 24, 0.94);
424
+ margin-bottom: 0.45rem;
425
+ }
426
+ .props-game-nav-card.selected {
427
+ border-color: rgba(181, 198, 117, 0.42);
428
+ box-shadow: 0 0 0 1px rgba(181, 198, 117, 0.16);
429
+ }
430
+ .props-game-nav-title {
431
+ color: #f3efe5;
432
+ font-size: 0.96rem;
433
+ font-weight: 760;
434
+ }
435
+ .props-game-nav-meta {
436
+ color: #9da89d;
437
+ font-size: 0.77rem;
438
+ margin-top: 0.25rem;
439
+ line-height: 1.4;
440
+ }
441
+ .props-workspace {
442
+ border: 1px solid rgba(100, 111, 105, 0.26);
443
+ border-radius: 18px;
444
+ padding: 1rem;
445
+ background: linear-gradient(180deg, rgba(17, 20, 19, 0.98), rgba(11, 13, 14, 0.98));
446
+ margin-bottom: 1rem;
447
+ }
448
+ .props-workspace-title {
449
+ color: #f6f2e8;
450
+ font-size: 1.2rem;
451
+ font-weight: 780;
452
+ }
453
+ .props-workspace-sub {
454
+ color: #9eaa9e;
455
+ font-size: 0.83rem;
456
+ margin-top: 0.2rem;
457
+ margin-bottom: 0.85rem;
458
+ }
459
+ .props-secondary-shell {
460
+ border: 1px solid rgba(80, 93, 94, 0.24);
461
+ border-radius: 16px;
462
+ padding: 0.9rem 1rem;
463
+ background: rgba(15, 18, 20, 0.9);
464
+ margin-top: 0.8rem;
465
+ }
466
  </style>
467
  """,
468
  unsafe_allow_html=True,
 
799
  ) -> dict:
800
  if not probable_starters or not slate_teams:
801
  return {}
802
+ team_scope = {_canonical_team(team) for team in slate_teams if str(team).strip()}
803
  out: dict = {}
804
  for key, payload in probable_starters.items():
805
  if not isinstance(key, tuple) or len(key) != 2:
806
  continue
807
+ away_norm = _canonical_team(key[0])
808
+ home_norm = _canonical_team(key[1])
809
  if away_norm in team_scope and home_norm in team_scope:
810
  out[key] = payload
811
  return out
 
1233
  return selected_books, min_edge, sort_option, view
1234
 
1235
 
1236
+ def _get_current_filter_state(mapped: pd.DataFrame, market_type: str) -> tuple[list[str], float, str, str]:
1237
+ all_books = sorted(mapped["sportsbook"].dropna().unique().tolist()) if "sportsbook" in mapped.columns else []
1238
+ selected_books = st.session_state.get("props_books", all_books)
1239
+ if not isinstance(selected_books, list) or not selected_books:
1240
+ selected_books = all_books
1241
+ selected_books = [book for book in selected_books if book in all_books] or all_books
1242
+
1243
+ if market_type == "hr":
1244
+ min_edge = float(st.session_state.get("props_min_edge", -0.50))
1245
+ sort_option = str(st.session_state.get("props_sort", "EV"))
1246
+ else:
1247
+ min_edge = -0.50
1248
+ sort_option = str(st.session_state.get("props_sort", "EV"))
1249
+
1250
+ view = str(st.session_state.get("props_view", "All Books"))
1251
+ if view not in {"All Books", "Best Line"}:
1252
+ view = "All Books"
1253
+ return selected_books, min_edge, sort_option, view
1254
+
1255
+
1256
  def _render_market_coverage_note(display: pd.DataFrame, market_type: str) -> None:
1257
  if display is None or display.empty or "sportsbook" not in display.columns:
1258
  return
 
1747
  )
1748
 
1749
 
1750
+ def _build_terminal_top_bets_df(display: pd.DataFrame, market_type: str, limit: int = 7) -> pd.DataFrame:
1751
+ if display is None or display.empty:
1752
+ return pd.DataFrame()
1753
+
1754
+ working = display.copy()
1755
+ if market_type == "hr":
1756
+ working = _modeled_hr_primary_subset(working)
1757
+ elif "is_modeled" in working.columns:
1758
+ working = working[working["is_modeled"] == True].copy()
1759
+ if working.empty:
1760
+ return pd.DataFrame()
1761
+
1762
+ working = select_best_lines_per_prop(working)
1763
+ sort_cols: list[str] = []
1764
+ ascending: list[bool] = []
1765
+ for col in ("bet_ev", "edge", "confidence_score", "final_recommendation_score"):
1766
+ if col in working.columns:
1767
+ sort_cols.append(col)
1768
+ ascending.append(False)
1769
+ if "odds_american" in working.columns:
1770
+ sort_cols.append("odds_american")
1771
+ ascending.append(False)
1772
+ if sort_cols:
1773
+ working = working.sort_values(sort_cols, ascending=ascending, na_position="last")
1774
+ return working.head(max(1, int(limit))).reset_index(drop=True)
1775
+
1776
+
1777
+ def _render_terminal_header(display: pd.DataFrame, market_type: str, top_bets_df: pd.DataFrame) -> None:
1778
+ market_label = _market_label(market_type)
1779
+ available_books = (
1780
+ sorted(display["sportsbook"].dropna().astype(str).unique().tolist())
1781
+ if display is not None and not display.empty and "sportsbook" in display.columns
1782
+ else []
1783
+ )
1784
+ best_edge = (
1785
+ pd.to_numeric(top_bets_df.get("edge"), errors="coerce").dropna().max()
1786
+ if not top_bets_df.empty and "edge" in top_bets_df.columns
1787
+ else None
1788
+ )
1789
+ best_ev = (
1790
+ pd.to_numeric(top_bets_df.get("bet_ev"), errors="coerce").dropna().max()
1791
+ if not top_bets_df.empty and "bet_ev" in top_bets_df.columns
1792
+ else None
1793
+ )
1794
+ st.markdown(
1795
+ f"""
1796
+ <div class="props-ops-header">
1797
+ <div class="props-ops-kicker">Baseball Ops Terminal</div>
1798
+ <div class="props-ops-title">{market_label} Slate Board</div>
1799
+ <div class="props-ops-sub">Top value first, fast matchup navigation second, deep prop detail only when you want it.</div>
1800
+ </div>
1801
+ """,
1802
+ unsafe_allow_html=True,
1803
+ )
1804
+ metrics = st.columns(5)
1805
+ 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)
1806
+ metrics[1].metric("Books", len(available_books))
1807
+ metrics[2].metric("Top Bets", int(len(top_bets_df)))
1808
+ metrics[3].metric("Best Edge", _format_edge(float(best_edge)) if best_edge is not None else "-")
1809
+ metrics[4].metric("Best EV", _format_ev(float(best_ev)) if best_ev is not None else "-")
1810
+
1811
+
1812
+ def _render_top_bet_highlight(row: pd.Series | dict[str, Any], rank_label: str) -> None:
1813
+ player = str(row.get("player_name_raw") or row.get("player_name") or "-")
1814
+ matchup = _build_matchup(row)
1815
+ market_line = _display_market_line_label(row)
1816
+ book = str(row.get("sportsbook") or "-")
1817
+ model_voice = str(row.get("model_voice") or "No model voice available.")
1818
+ market_family = str(row.get("market_family") or row.get("market") or "").strip().lower()
1819
+ probability_value = row.get("model_hr_prob") if market_family == "hr" else row.get("fair_prob")
1820
+ probability_label = "Pregame HR%" if market_family == "hr" else "Fair%"
1821
+ edge_class = _metric_tone_class("edge", row.get("edge"))
1822
+ ev_class = _metric_tone_class("ev", row.get("bet_ev"))
1823
+ st.markdown(
1824
+ f"""
1825
+ <div class="props-terminal-highlight">
1826
+ <div class="props-terminal-rank">{rank_label}</div>
1827
+ <div class="props-player">{player}</div>
1828
+ <div class="props-matchup">{matchup}</div>
1829
+ <div class="props-terminal-line">{market_line} | {book}</div>
1830
+ <div class="props-terminal-grid">
1831
+ <div><div class="props-terminal-metric">Odds</div><div class="props-terminal-value">{_format_odds(row.get('odds_american'))}</div></div>
1832
+ <div><div class="props-terminal-metric">EV</div><div class="props-terminal-value {ev_class}">{_format_ev(row.get('bet_ev'))}</div></div>
1833
+ <div><div class="props-terminal-metric">Edge</div><div class="props-terminal-value {edge_class}">{_format_edge(row.get('edge'))}</div></div>
1834
+ <div><div class="props-terminal-metric">{probability_label}</div><div class="props-terminal-value">{_format_pct(probability_value)}</div></div>
1835
+ <div><div class="props-terminal-metric">Implied</div><div class="props-terminal-value">{_format_pct(row.get('implied_prob'))}</div></div>
1836
+ <div><div class="props-terminal-metric">Confidence</div><div class="props-terminal-value">{_format_confidence(row.get('confidence_score'))}</div></div>
1837
+ </div>
1838
+ <div class="props-terminal-thesis"><strong>Thesis:</strong> {model_voice}</div>
1839
+ </div>
1840
+ """,
1841
+ unsafe_allow_html=True,
1842
+ )
1843
+
1844
+
1845
+ def _render_terminal_mini_card(row: pd.Series | dict[str, Any], rank: int) -> None:
1846
+ player = str(row.get("player_name_raw") or row.get("player_name") or "-")
1847
+ matchup = _build_matchup(row)
1848
+ market_line = _display_market_line_label(row)
1849
+ book = str(row.get("sportsbook") or "-")
1850
+ model_voice = str(row.get("model_voice") or "No thesis available.")
1851
+ model_voice = model_voice[:140] + ("..." if len(model_voice) > 140 else "")
1852
+ edge_class = _metric_tone_class("edge", row.get("edge"))
1853
+ ev_class = _metric_tone_class("ev", row.get("bet_ev"))
1854
+ st.markdown(
1855
+ f"""
1856
+ <div class="props-terminal-mini">
1857
+ <div class="props-terminal-rank">#{rank}</div>
1858
+ <div class="props-terminal-name">{player}</div>
1859
+ <div class="props-matchup">{matchup}</div>
1860
+ <div class="props-terminal-line">{market_line} | {book}</div>
1861
+ <div class="props-terminal-grid">
1862
+ <div><div class="props-terminal-metric">EV</div><div class="props-terminal-value {ev_class}">{_format_ev(row.get('bet_ev'))}</div></div>
1863
+ <div><div class="props-terminal-metric">Edge</div><div class="props-terminal-value {edge_class}">{_format_edge(row.get('edge'))}</div></div>
1864
+ <div><div class="props-terminal-metric">Odds</div><div class="props-terminal-value">{_format_odds(row.get('odds_american'))}</div></div>
1865
+ <div><div class="props-terminal-metric">Conf</div><div class="props-terminal-value">{_format_confidence(row.get('confidence_score'))}</div></div>
1866
+ </div>
1867
+ <div class="props-terminal-thesis">{model_voice}</div>
1868
+ </div>
1869
+ """,
1870
+ unsafe_allow_html=True,
1871
+ )
1872
+
1873
+
1874
+ def render_terminal_top_bets_board(top_bets_df: pd.DataFrame, market_type: str) -> None:
1875
+ st.markdown('<div class="props-section-kicker">Top Bets</div>', unsafe_allow_html=True)
1876
+ st.markdown("#### Best Current Value On The Slate")
1877
+ if top_bets_df is None or top_bets_df.empty:
1878
+ st.info(f"No top {_market_label(market_type)} bets are available for the current filters.")
1879
+ return
1880
+
1881
+ st.markdown('<div class="props-terminal-board">', unsafe_allow_html=True)
1882
+ _render_top_bet_highlight(top_bets_df.iloc[0], "Top Play")
1883
+ if len(top_bets_df) > 1:
1884
+ remaining = top_bets_df.iloc[1:].reset_index(drop=True)
1885
+ cols_per_row = 3
1886
+ for start in range(0, len(remaining), cols_per_row):
1887
+ cols = st.columns(min(cols_per_row, len(remaining) - start))
1888
+ for idx, (_, row) in enumerate(remaining.iloc[start:start + cols_per_row].iterrows(), start=start + 2):
1889
+ with cols[idx - start - 2]:
1890
+ _render_terminal_mini_card(row, idx)
1891
+ st.markdown("</div>", unsafe_allow_html=True)
1892
+
1893
+
1894
+ def _build_generic_game_workspace(display: pd.DataFrame) -> tuple[pd.DataFrame, dict[str, Any]]:
1895
+ if display is None or display.empty:
1896
+ return pd.DataFrame(), {}
1897
+
1898
+ working = select_best_lines_per_prop(display.copy())
1899
+ summary_rows: list[dict[str, Any]] = []
1900
+ game_map: dict[str, Any] = {}
1901
+
1902
+ for (event_id, away_team, home_team, commence_time), game_df in working.groupby(
1903
+ ["event_id", "away_team", "home_team", "commence_time"],
1904
+ dropna=False,
1905
+ ):
1906
+ game_key = str(event_id or f"{away_team}|{home_team}|{commence_time}")
1907
+ sort_df = game_df.copy()
1908
+ if "bet_ev" in sort_df.columns:
1909
+ sort_df = sort_df.sort_values(["bet_ev", "edge", "confidence_score"], ascending=[False, False, False], na_position="last")
1910
+ top_row = sort_df.iloc[0].to_dict() if not sort_df.empty else {}
1911
+ player_rows: list[dict[str, Any]] = []
1912
+ for player_name, player_df in sort_df.groupby("player_name", dropna=False):
1913
+ primary_row = player_df.iloc[0].to_dict()
1914
+ player_rows.append(
1915
+ {
1916
+ "player_key": f"{game_key}|{str(primary_row.get('player_name') or '').strip().lower()}",
1917
+ "player_name": primary_row.get("player_name"),
1918
+ "player_name_raw": primary_row.get("player_name_raw"),
1919
+ "best_display_label": primary_row.get("display_label"),
1920
+ "best_book": primary_row.get("sportsbook"),
1921
+ "best_odds_american": primary_row.get("odds_american"),
1922
+ "best_edge": primary_row.get("edge"),
1923
+ "best_bet_ev": primary_row.get("bet_ev"),
1924
+ "best_confidence_score": primary_row.get("confidence_score"),
1925
+ "best_verdict": primary_row.get("verdict"),
1926
+ "best_model_hr_prob": primary_row.get("fair_prob"),
1927
+ "model_voice": primary_row.get("model_voice"),
1928
+ "model_voice_primary_reason": primary_row.get("model_voice_primary_reason"),
1929
+ "model_voice_caveat": primary_row.get("model_voice_caveat"),
1930
+ "details": {
1931
+ "best_primary_row": primary_row,
1932
+ "primary_rows": player_df.to_dict("records"),
1933
+ "alt_rows": [],
1934
+ },
1935
+ }
1936
+ )
1937
+ game_map[game_key] = {
1938
+ "game_key": game_key,
1939
+ "event_id": event_id,
1940
+ "away_team": away_team,
1941
+ "home_team": home_team,
1942
+ "commence_time": commence_time,
1943
+ "modeled_props_count": int(len(sort_df)),
1944
+ "players_count": int(game_df["player_name"].nunique()),
1945
+ "best_edge": top_row.get("edge"),
1946
+ "best_bet_ev": top_row.get("bet_ev"),
1947
+ "top_player_name": top_row.get("player_name"),
1948
+ "top_display_label": top_row.get("display_label"),
1949
+ "top_book": top_row.get("sportsbook"),
1950
+ "top_verdict": top_row.get("verdict"),
1951
+ "players": player_rows,
1952
+ }
1953
+ summary_rows.append({key: value for key, value in game_map[game_key].items() if key != "players"})
1954
+
1955
+ summary_df = pd.DataFrame(summary_rows)
1956
+ if not summary_df.empty and "best_edge" in summary_df.columns:
1957
+ summary_df = summary_df.sort_values(["best_edge", "best_bet_ev"], ascending=[False, False], na_position="last").reset_index(drop=True)
1958
+ return summary_df, game_map
1959
+
1960
+
1961
+ def _normalize_game_summary_rows(
1962
+ market_type: str,
1963
+ analysis_display: pd.DataFrame,
1964
+ view_model: dict[str, Any] | None = None,
1965
+ ) -> tuple[pd.DataFrame, dict[str, Any]]:
1966
+ if market_type == "hr" and isinstance(view_model, dict):
1967
+ return (
1968
+ view_model.get("games_summary_df", pd.DataFrame()),
1969
+ view_model.get("game_player_props_map", {}),
1970
+ )
1971
+ return _build_generic_game_workspace(analysis_display)
1972
+
1973
+
1974
+ def render_game_navigator_terminal(game_summary_df: pd.DataFrame, market_type: str) -> str | None:
1975
+ st.markdown('<div class="props-section-kicker">Game Navigator</div>', unsafe_allow_html=True)
1976
+ st.markdown("#### Jump Into A Matchup")
1977
+ if game_summary_df is None or game_summary_df.empty:
1978
+ st.info(f"No {_market_label(market_type)} games are available for navigation.")
1979
+ return None
1980
+
1981
+ state_key = f"props_selected_game_{market_type}"
1982
+ ordered_game_keys = game_summary_df["game_key"].astype(str).tolist()
1983
+ current = st.session_state.get(state_key)
1984
+ if current not in ordered_game_keys:
1985
+ current = ordered_game_keys[0]
1986
+ st.session_state[state_key] = current
1987
+
1988
+ st.markdown('<div class="props-game-nav">', unsafe_allow_html=True)
1989
+ cols_per_row = 3
1990
+ for start in range(0, len(game_summary_df), cols_per_row):
1991
+ row_df = game_summary_df.iloc[start:start + cols_per_row]
1992
+ cols = st.columns(len(row_df))
1993
+ for col, (_, row) in zip(cols, row_df.iterrows()):
1994
+ game_key = str(row.get("game_key") or "")
1995
+ selected_class = " selected" if game_key == current else ""
1996
+ with col:
1997
+ st.markdown(
1998
+ f"""
1999
+ <div class="props-game-nav-card{selected_class}">
2000
+ <div class="props-game-nav-title">{_build_matchup(row)}</div>
2001
+ <div class="props-game-nav-meta">
2002
+ {_build_game_time(row)}<br/>
2003
+ {int(row.get('modeled_props_count') or 0)} modeled props | best edge {_format_edge(row.get('best_edge'))}<br/>
2004
+ top: {str(row.get('top_player_name') or '-')} | {str(row.get('top_display_label') or '-')}
2005
+ </div>
2006
+ </div>
2007
+ """,
2008
+ unsafe_allow_html=True,
2009
+ )
2010
+ if st.button("View Game", key=f"props_game_nav_{market_type}_{game_key}", use_container_width=True):
2011
+ st.session_state[state_key] = game_key
2012
+ current = game_key
2013
+ st.markdown("</div>", unsafe_allow_html=True)
2014
+ return current
2015
+
2016
+
2017
+ def render_selected_game_workspace(
2018
+ *,
2019
+ selected_game_key: str | None,
2020
+ game_map: dict[str, Any],
2021
+ market_type: str,
2022
+ ) -> None:
2023
+ st.markdown('<div class="props-section-kicker">Selected Game</div>', unsafe_allow_html=True)
2024
+ st.markdown("#### Prop Workspace")
2025
+ if not selected_game_key or selected_game_key not in game_map:
2026
+ st.info("Select a game to inspect player props and books.")
2027
+ return
2028
+
2029
+ game_payload = game_map[selected_game_key]
2030
+ st.markdown(
2031
+ f"""
2032
+ <div class="props-workspace">
2033
+ <div class="props-workspace-title">{_build_matchup(game_payload)}</div>
2034
+ <div class="props-workspace-sub">
2035
+ {_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'))}
2036
+ </div>
2037
+ </div>
2038
+ """,
2039
+ unsafe_allow_html=True,
2040
+ )
2041
+
2042
+ player_entries = game_payload.get("players") or []
2043
+ if not player_entries:
2044
+ st.info("No player props available for this game.")
2045
+ return
2046
+
2047
+ for player_entry in player_entries:
2048
+ render_player_hr_row(player_entry)
2049
+
2050
  def render_player_hr_details(player_details: dict[str, Any]) -> None:
2051
  primary_rows = pd.DataFrame(player_details.get("primary_rows") or [])
2052
  alt_rows = pd.DataFrame(player_details.get("alt_rows") or [])
 
2462
  )
2463
  import time as _time
2464
  if prepared_bundle.get("snapshot_source_status") in ("runtime_fallback_timeout", "patch_build_timeout"):
2465
+ st.session_state.setdefault("props_baseline_reload_at", _time.time() + 360)
2466
  _reload_at = st.session_state.get("props_baseline_reload_at")
2467
  if _reload_at:
2468
  if _time.time() < _reload_at:
2469
  st.info(
2470
  "📊 **Player baseline data is loading in the background.** "
2471
  "Props are shown with basic line analysis. "
2472
+ "The page will refresh automatically with full Statcast enrichment in a few minutes."
2473
  )
2474
  else:
2475
  del st.session_state["props_baseline_reload_at"]
 
2543
  st.session_state["props_view_model_bundle"] = view_model
2544
  else:
2545
  st.session_state.pop("props_view_model_bundle", None)
2546
+ selected_books, min_edge, sort_option, view = _get_current_filter_state(mapped, market_type)
2547
+ analysis_display, table_display = _prepare_display_frames(
2548
+ mapped=mapped,
2549
+ market_type=market_type,
2550
+ selected_books=selected_books,
2551
+ min_edge=min_edge,
2552
+ sort_option=sort_option,
2553
+ view=view,
2554
  )
2555
 
2556
+ if analysis_display.empty:
2557
+ _render_terminal_header(mapped, market_type, pd.DataFrame())
2558
+ st.info("No props match the current filters.")
2559
+ return
2560
+
2561
+ top_bets_df = _build_terminal_top_bets_df(analysis_display, market_type, limit=7)
2562
+ _render_terminal_header(analysis_display, market_type, top_bets_df)
2563
+ render_terminal_top_bets_board(top_bets_df, market_type)
2564
+
2565
  st.markdown('<div class="props-filter-rail">', unsafe_allow_html=True)
2566
+ st.markdown('<div class="props-section-kicker">Controls</div>', unsafe_allow_html=True)
2567
+ 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)
2568
  selected_books, min_edge, sort_option, view = _render_filter_controls(mapped, market_type)
2569
  st.markdown("</div>", unsafe_allow_html=True)
2570
+ st.markdown('<div class="props-secondary-shell">', unsafe_allow_html=True)
2571
+ _render_market_coverage_note(mapped, market_type)
2572
+ st.markdown("</div>", unsafe_allow_html=True)
2573
  analysis_display, table_display = _prepare_display_frames(
2574
  mapped=mapped,
2575
  market_type=market_type,
 
2595
  "summary": best_on_slate_summary,
2596
  }
2597
 
2598
+ filtered_view_model = build_hr_props_view_model(analysis_display) if market_type == "hr" else None
2599
+ game_summary_df, game_map = _normalize_game_summary_rows(
2600
+ market_type=market_type,
2601
+ analysis_display=analysis_display,
2602
+ view_model=filtered_view_model,
2603
+ )
2604
+ selected_game_key = render_game_navigator_terminal(game_summary_df, market_type)
2605
+ render_selected_game_workspace(
2606
+ selected_game_key=selected_game_key,
2607
+ game_map=game_map,
2608
+ market_type=market_type,
2609
+ )
2610
+
2611
+ st.markdown('<div class="props-section-kicker">Research Surfaces</div>', unsafe_allow_html=True)
2612
+ bottom_tabs = st.tabs(["Flat Table", "Probability", "Execution", "Legend"])
2613
+ with bottom_tabs[0]:
2614
+ render_flat_props_table(table_display, market_type)
2615
+ with bottom_tabs[1]:
2616
  render_probability_diagnostics(analysis_display)
2617
+ with bottom_tabs[2]:
2618
  render_execution_layer(analysis_display)
2619
+ if market_type == "hr":
2620
+ st.caption(
2621
+ "Pregame HR% starts from the batter baseline and applies pitcher, matchup, park/weather, trajectory, and rolling context. "
2622
+ "Live-only pitch telemetry and count/base-out state remain part of the live Dashboard path."
2623
+ )
2624
+ with bottom_tabs[3]:
2625
+ _render_summary_metrics(table_display, market_type)
2626
+ _render_props_legend()