Spaces:
Sleeping
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 +151 -0
- app.py +6 -3
- data/live_prop_odds.py +34 -0
- data/provider_theoddsapi.py +98 -3
- database/db.py +58 -0
- visualization/props_page.py +196 -0
|
@@ -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)
|
|
@@ -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 |
-
"
|
| 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 == "
|
| 3979 |
-
|
| 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":
|
|
@@ -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,
|
|
@@ -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":
|
| 34 |
-
"fanduel":
|
| 35 |
-
"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)
|
|
@@ -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 |
|
|
@@ -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 |
+
)
|