Spaces:
Sleeping
Tier 5A execution layer + Alpha Release tab + edge strip fix
Browse files- analytics/execution_layer.py: New post-model enrichment pass (5 sequential
tasks: market disagreement, edge quality, timing, correlation, final score)
- analytics/props_mapper.py: Wire enrich_with_execution_layer at end of
map_hr_props_to_model; try/except with logger.warning fallback
- analytics/recommendation_engine.py: Add prop_odds_df param; inject real live
HR prop odds per batter (MAX odds_american, normalized + raw name fallback,
book_hr_odds_source field); logs mapping misses
- data/live_prop_odds.py: Fix silent exception in fetch_live_prop_odds
- app.py: load_hr_prop_odds_for_game (60s TTL) wired into dashboard; replace
render_algorithm_breakdown with render_alpha_release (10 expanders, disclaimer,
no WBC content); rename nav entry
- visualization/props_page.py: Store mapped df in session_state; Execution Layer
expander after HR props table
- visualization/debug_page.py: Section 5c Execution Layer reading from
session_state
- visualization/recommendation_panels.py: BOOK column shows ~odds with tooltip
when book_hr_odds_source == placeholder
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- analytics/execution_layer.py +356 -0
- analytics/props_mapper.py +9 -1
- analytics/recommendation_engine.py +54 -0
- app.py +207 -27
- data/live_prop_odds.py +2 -1
- visualization/debug_page.py +29 -0
- visualization/props_page.py +17 -0
- visualization/recommendation_panels.py +11 -1
|
@@ -0,0 +1,356 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
analytics/execution_layer.py
|
| 3 |
+
|
| 4 |
+
Tier 5A — Execution Layer (Alpha Release)
|
| 5 |
+
|
| 6 |
+
Post-model enrichment pass operating exclusively on already-computed outputs
|
| 7 |
+
(model probs + book odds). No simulation logic, no probability calculations,
|
| 8 |
+
no model changes.
|
| 9 |
+
|
| 10 |
+
Entry point: enrich_with_execution_layer(df) → df with execution fields added.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import statistics
|
| 16 |
+
from typing import Any
|
| 17 |
+
|
| 18 |
+
import pandas as pd
|
| 19 |
+
|
| 20 |
+
from analytics.no_vig_props import american_to_implied_prob
|
| 21 |
+
|
| 22 |
+
# ---------------------------------------------------------------------------
|
| 23 |
+
# Thresholds
|
| 24 |
+
# ---------------------------------------------------------------------------
|
| 25 |
+
OUTLIER_THRESHOLD = 0.03 # 3pp deviation from median → outlier
|
| 26 |
+
STALE_THRESHOLD = 0.025 # 2.5pp worse than median → stale book
|
| 27 |
+
AGGRESSIVE_THRESHOLD = 0.02 # 2pp better than median → aggressive/timing flag
|
| 28 |
+
|
| 29 |
+
_TIMESTAMP_KEYS = ("last_update", "timestamp", "odds_timestamp", "updated_at")
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# ---------------------------------------------------------------------------
|
| 33 |
+
# Helpers
|
| 34 |
+
# ---------------------------------------------------------------------------
|
| 35 |
+
|
| 36 |
+
def _safe_float(val: Any, default: float | None = None) -> float | None:
|
| 37 |
+
if val is None:
|
| 38 |
+
return default
|
| 39 |
+
try:
|
| 40 |
+
return float(val)
|
| 41 |
+
except (TypeError, ValueError):
|
| 42 |
+
return default
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _safe_implied(odds: Any) -> float | None:
|
| 46 |
+
if odds is None:
|
| 47 |
+
return None
|
| 48 |
+
try:
|
| 49 |
+
return american_to_implied_prob(odds)
|
| 50 |
+
except Exception:
|
| 51 |
+
return None
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _make_player_game_key(row: pd.Series) -> str:
|
| 55 |
+
event_id = str(row.get("event_id") or "").strip()
|
| 56 |
+
player_name = str(row.get("player_name") or "").strip()
|
| 57 |
+
if event_id and event_id not in ("nan", "None", ""):
|
| 58 |
+
return f"{event_id}|{player_name}"
|
| 59 |
+
away = str(row.get("away_team") or "").strip()
|
| 60 |
+
home = str(row.get("home_team") or "").strip()
|
| 61 |
+
return f"{away}|{home}|{player_name}"
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _make_game_key(row: pd.Series) -> str:
|
| 65 |
+
event_id = str(row.get("event_id") or "").strip()
|
| 66 |
+
if event_id and event_id not in ("nan", "None", ""):
|
| 67 |
+
return event_id
|
| 68 |
+
away = str(row.get("away_team") or "").strip()
|
| 69 |
+
home = str(row.get("home_team") or "").strip()
|
| 70 |
+
return f"{away}_{home}"
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
# ---------------------------------------------------------------------------
|
| 74 |
+
# Task 1 — Market Disagreement
|
| 75 |
+
# ---------------------------------------------------------------------------
|
| 76 |
+
|
| 77 |
+
def _compute_market_fields(df: pd.DataFrame) -> pd.DataFrame:
|
| 78 |
+
"""Add best_price, median_price, market_width, market_outlier_flag, stale_book_flag."""
|
| 79 |
+
df = df.copy()
|
| 80 |
+
|
| 81 |
+
# Build scoped player-game keys
|
| 82 |
+
keys = df.apply(_make_player_game_key, axis=1)
|
| 83 |
+
df["_pg_key"] = keys
|
| 84 |
+
|
| 85 |
+
# Pre-compute implied probs for each row
|
| 86 |
+
df["_implied"] = df["odds_american"].apply(_safe_implied)
|
| 87 |
+
|
| 88 |
+
# Group stats per scoped player-game key
|
| 89 |
+
group_stats: dict[str, dict] = {}
|
| 90 |
+
for key, grp in df.groupby("_pg_key"):
|
| 91 |
+
implied_vals = [v for v in grp["_implied"].tolist() if v is not None]
|
| 92 |
+
if not implied_vals:
|
| 93 |
+
group_stats[key] = {
|
| 94 |
+
"best": None, "worst": None, "median": None, "width": None
|
| 95 |
+
}
|
| 96 |
+
continue
|
| 97 |
+
best = min(implied_vals) # lowest implied = best for bettor
|
| 98 |
+
worst = max(implied_vals)
|
| 99 |
+
med = statistics.median(implied_vals)
|
| 100 |
+
width = abs(worst - best)
|
| 101 |
+
group_stats[key] = {"best": best, "worst": worst, "median": med, "width": width}
|
| 102 |
+
|
| 103 |
+
best_prices: list[float | None] = []
|
| 104 |
+
median_prices: list[float | None] = []
|
| 105 |
+
market_widths: list[float | None] = []
|
| 106 |
+
outlier_flags: list[bool] = []
|
| 107 |
+
stale_flags: list[bool] = []
|
| 108 |
+
|
| 109 |
+
for _, row in df.iterrows():
|
| 110 |
+
key = row["_pg_key"]
|
| 111 |
+
stats = group_stats.get(key, {})
|
| 112 |
+
this_implied = row["_implied"]
|
| 113 |
+
|
| 114 |
+
best_prices.append(stats.get("best"))
|
| 115 |
+
median_prices.append(stats.get("median"))
|
| 116 |
+
market_widths.append(stats.get("width"))
|
| 117 |
+
|
| 118 |
+
med = stats.get("median")
|
| 119 |
+
if this_implied is not None and med is not None:
|
| 120 |
+
outlier_flags.append(abs(this_implied - med) > OUTLIER_THRESHOLD)
|
| 121 |
+
stale_flags.append((this_implied - med) > STALE_THRESHOLD)
|
| 122 |
+
else:
|
| 123 |
+
outlier_flags.append(False)
|
| 124 |
+
stale_flags.append(False)
|
| 125 |
+
|
| 126 |
+
df["best_price"] = best_prices
|
| 127 |
+
df["median_price"] = median_prices
|
| 128 |
+
df["market_width"] = market_widths
|
| 129 |
+
df["market_outlier_flag"] = outlier_flags
|
| 130 |
+
df["stale_book_flag"] = stale_flags
|
| 131 |
+
|
| 132 |
+
df.drop(columns=["_pg_key", "_implied"], inplace=True)
|
| 133 |
+
return df
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
# ---------------------------------------------------------------------------
|
| 137 |
+
# Task 2 — Edge Quality Filters
|
| 138 |
+
# ---------------------------------------------------------------------------
|
| 139 |
+
|
| 140 |
+
def _compute_edge_quality(df: pd.DataFrame) -> pd.DataFrame:
|
| 141 |
+
"""Add confidence_score, volatility_score, signal_strength_score,
|
| 142 |
+
edge_raw, edge_filtered, edge_filter_flags."""
|
| 143 |
+
df = df.copy()
|
| 144 |
+
|
| 145 |
+
conf_scores: list[float] = []
|
| 146 |
+
vol_scores: list[float] = []
|
| 147 |
+
sig_scores: list[float] = []
|
| 148 |
+
edge_raws: list[float | None] = []
|
| 149 |
+
edge_filtered_vals: list[float | None] = []
|
| 150 |
+
edge_flag_strs: list[str] = []
|
| 151 |
+
|
| 152 |
+
for _, row in df.iterrows():
|
| 153 |
+
source = str(row.get("model_hr_prob_source") or "unavailable")
|
| 154 |
+
context_applied = bool(row.get("pregame_context_applied") or False)
|
| 155 |
+
edge_raw = _safe_float(row.get("edge"))
|
| 156 |
+
market_width = _safe_float(row.get("market_width"), default=0.0)
|
| 157 |
+
|
| 158 |
+
# Context adj magnitude
|
| 159 |
+
pitcher_adj = _safe_float(row.get("pregame_pitcher_context_adj"), default=0.0)
|
| 160 |
+
park_adj = _safe_float(row.get("pregame_park_context_adj"), default=0.0)
|
| 161 |
+
context_mag = abs(pitcher_adj or 0.0) + abs(park_adj or 0.0)
|
| 162 |
+
|
| 163 |
+
# Confidence score
|
| 164 |
+
if source == "internal_model_baseline":
|
| 165 |
+
conf = 1.0 if context_applied else 0.7
|
| 166 |
+
else:
|
| 167 |
+
conf = 0.3
|
| 168 |
+
|
| 169 |
+
# Volatility score (weighted blend, range [0, 1])
|
| 170 |
+
width_component = min(1.0, (market_width or 0.0) / 0.10)
|
| 171 |
+
ctx_component = min(1.0, context_mag / 0.02) if context_mag > 0 else 0.0
|
| 172 |
+
vol = 0.7 * width_component + 0.3 * ctx_component
|
| 173 |
+
|
| 174 |
+
# Signal strength score
|
| 175 |
+
if source == "internal_model_baseline":
|
| 176 |
+
sig = 0.7 + (0.3 if context_applied else 0.0)
|
| 177 |
+
else:
|
| 178 |
+
sig = 0.1
|
| 179 |
+
sig = min(1.0, sig)
|
| 180 |
+
|
| 181 |
+
# Edge filtered + flags
|
| 182 |
+
if edge_raw is None:
|
| 183 |
+
edge_filt = None
|
| 184 |
+
flags = "clean"
|
| 185 |
+
else:
|
| 186 |
+
edge_filt = edge_raw
|
| 187 |
+
applied: list[str] = []
|
| 188 |
+
|
| 189 |
+
# Confidence penalty
|
| 190 |
+
if conf < 0.5:
|
| 191 |
+
scale = conf / 0.5
|
| 192 |
+
edge_filt = edge_filt * scale
|
| 193 |
+
applied.append("conf_penalty")
|
| 194 |
+
|
| 195 |
+
# Volatility penalty
|
| 196 |
+
vol_pen = min(0.02, vol * 0.02)
|
| 197 |
+
if vol_pen > 0:
|
| 198 |
+
edge_filt = edge_filt - vol_pen
|
| 199 |
+
applied.append("vol_penalty")
|
| 200 |
+
|
| 201 |
+
# Weak signal suppression
|
| 202 |
+
if sig < 0.3:
|
| 203 |
+
edge_filt = edge_filt * 0.5
|
| 204 |
+
applied.append("weak_signal")
|
| 205 |
+
|
| 206 |
+
flags = ",".join(applied) if applied else "clean"
|
| 207 |
+
|
| 208 |
+
conf_scores.append(conf)
|
| 209 |
+
vol_scores.append(vol)
|
| 210 |
+
sig_scores.append(sig)
|
| 211 |
+
edge_raws.append(edge_raw)
|
| 212 |
+
edge_filtered_vals.append(edge_filt)
|
| 213 |
+
edge_flag_strs.append(flags)
|
| 214 |
+
|
| 215 |
+
df["confidence_score"] = conf_scores
|
| 216 |
+
df["volatility_score"] = vol_scores
|
| 217 |
+
df["signal_strength_score"] = sig_scores
|
| 218 |
+
df["edge_raw"] = edge_raws
|
| 219 |
+
df["edge_filtered"] = edge_filtered_vals
|
| 220 |
+
df["edge_filter_flags"] = edge_flag_strs
|
| 221 |
+
return df
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
# ---------------------------------------------------------------------------
|
| 225 |
+
# Task 3 — Timing Heuristics
|
| 226 |
+
# ---------------------------------------------------------------------------
|
| 227 |
+
|
| 228 |
+
def _compute_timing_fields(df: pd.DataFrame) -> pd.DataFrame:
|
| 229 |
+
"""Add timing_flag, timing_reason."""
|
| 230 |
+
df = df.copy()
|
| 231 |
+
|
| 232 |
+
timing_flags: list[bool] = []
|
| 233 |
+
timing_reasons: list[str] = []
|
| 234 |
+
|
| 235 |
+
for _, row in df.iterrows():
|
| 236 |
+
reasons: list[str] = []
|
| 237 |
+
|
| 238 |
+
# Aggressive price: this book > 2pp better than median (lower implied)
|
| 239 |
+
this_implied = _safe_implied(row.get("odds_american"))
|
| 240 |
+
median_price = _safe_float(row.get("median_price"))
|
| 241 |
+
if (
|
| 242 |
+
this_implied is not None
|
| 243 |
+
and median_price is not None
|
| 244 |
+
and (median_price - this_implied) > AGGRESSIVE_THRESHOLD
|
| 245 |
+
):
|
| 246 |
+
reasons.append("aggressive_price")
|
| 247 |
+
|
| 248 |
+
# Timestamp presence
|
| 249 |
+
has_ts = any(
|
| 250 |
+
row.get(k) is not None and str(row.get(k)).strip() not in ("", "nan", "None")
|
| 251 |
+
for k in _TIMESTAMP_KEYS
|
| 252 |
+
)
|
| 253 |
+
if has_ts:
|
| 254 |
+
reasons.append("has_timestamp")
|
| 255 |
+
|
| 256 |
+
if not reasons:
|
| 257 |
+
reasons.append("none")
|
| 258 |
+
|
| 259 |
+
timing_flags.append(len(reasons) > 1 or (len(reasons) == 1 and reasons[0] != "none"))
|
| 260 |
+
timing_reasons.append(",".join(reasons))
|
| 261 |
+
|
| 262 |
+
df["timing_flag"] = timing_flags
|
| 263 |
+
df["timing_reason"] = timing_reasons
|
| 264 |
+
return df
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
# ---------------------------------------------------------------------------
|
| 268 |
+
# Task 4 — Correlation Awareness
|
| 269 |
+
# ---------------------------------------------------------------------------
|
| 270 |
+
|
| 271 |
+
def _compute_correlation_fields(df: pd.DataFrame) -> pd.DataFrame:
|
| 272 |
+
"""Add correlation_flag, correlation_direction."""
|
| 273 |
+
df = df.copy()
|
| 274 |
+
|
| 275 |
+
# Count distinct players per game
|
| 276 |
+
game_keys = df.apply(_make_game_key, axis=1)
|
| 277 |
+
df["_game_key"] = game_keys
|
| 278 |
+
|
| 279 |
+
player_counts: dict[str, int] = {}
|
| 280 |
+
for key, grp in df.groupby("_game_key"):
|
| 281 |
+
player_counts[key] = grp["player_name"].nunique()
|
| 282 |
+
|
| 283 |
+
corr_directions: list[str] = []
|
| 284 |
+
for _, row in df.iterrows():
|
| 285 |
+
key = row["_game_key"]
|
| 286 |
+
count = player_counts.get(key, 1)
|
| 287 |
+
corr_directions.append("positive_stacked" if count > 2 else "positive")
|
| 288 |
+
|
| 289 |
+
df["correlation_flag"] = True # always True for HR props
|
| 290 |
+
df["correlation_direction"] = corr_directions
|
| 291 |
+
|
| 292 |
+
df.drop(columns=["_game_key"], inplace=True)
|
| 293 |
+
return df
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
# ---------------------------------------------------------------------------
|
| 297 |
+
# Task 5 — Final Execution Score
|
| 298 |
+
# ---------------------------------------------------------------------------
|
| 299 |
+
|
| 300 |
+
def _compute_execution_score(df: pd.DataFrame) -> pd.DataFrame:
|
| 301 |
+
"""Add final_recommendation_score."""
|
| 302 |
+
df = df.copy()
|
| 303 |
+
|
| 304 |
+
scores: list[float | None] = []
|
| 305 |
+
|
| 306 |
+
for _, row in df.iterrows():
|
| 307 |
+
edge_filtered = _safe_float(row.get("edge_filtered"))
|
| 308 |
+
if edge_filtered is None:
|
| 309 |
+
scores.append(None)
|
| 310 |
+
continue
|
| 311 |
+
|
| 312 |
+
confidence_score = _safe_float(row.get("confidence_score"), default=0.3)
|
| 313 |
+
volatility_score = _safe_float(row.get("volatility_score"), default=0.0)
|
| 314 |
+
market_width = _safe_float(row.get("market_width"), default=0.0)
|
| 315 |
+
timing_flag = bool(row.get("timing_flag") or False)
|
| 316 |
+
|
| 317 |
+
base = edge_filtered * (0.4 + (confidence_score or 0.0) * 0.6)
|
| 318 |
+
vol_penalty = min(0.015, (volatility_score or 0.0) * 0.015)
|
| 319 |
+
market_bonus = min(0.01, max(0.0, 0.01 - (market_width or 0.0) * 0.5))
|
| 320 |
+
timing_bonus = 0.005 if timing_flag else 0.0
|
| 321 |
+
|
| 322 |
+
score = base - vol_penalty + market_bonus + timing_bonus
|
| 323 |
+
score = max(-0.30, min(0.30, score))
|
| 324 |
+
scores.append(score)
|
| 325 |
+
|
| 326 |
+
df["final_recommendation_score"] = scores
|
| 327 |
+
return df
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
# ---------------------------------------------------------------------------
|
| 331 |
+
# Public entry point
|
| 332 |
+
# ---------------------------------------------------------------------------
|
| 333 |
+
|
| 334 |
+
def enrich_with_execution_layer(df: pd.DataFrame) -> pd.DataFrame:
|
| 335 |
+
"""
|
| 336 |
+
Run all five execution-layer passes on the mapped props DataFrame.
|
| 337 |
+
|
| 338 |
+
Passes (in order):
|
| 339 |
+
1. Market Disagreement — best_price, median_price, market_width, flags
|
| 340 |
+
2. Edge Quality — confidence, volatility, signal, edge_filtered
|
| 341 |
+
3. Timing Heuristics — timing_flag, timing_reason
|
| 342 |
+
4. Correlation — correlation_flag, correlation_direction
|
| 343 |
+
5. Execution Score — final_recommendation_score
|
| 344 |
+
|
| 345 |
+
Returns the enriched DataFrame. Does not modify simulation logic or
|
| 346 |
+
model probabilities.
|
| 347 |
+
"""
|
| 348 |
+
if df.empty:
|
| 349 |
+
return df
|
| 350 |
+
|
| 351 |
+
df = _compute_market_fields(df)
|
| 352 |
+
df = _compute_edge_quality(df)
|
| 353 |
+
df = _compute_timing_fields(df)
|
| 354 |
+
df = _compute_correlation_fields(df)
|
| 355 |
+
df = _compute_execution_score(df)
|
| 356 |
+
return df
|
|
@@ -251,4 +251,12 @@ def map_hr_props_to_model(
|
|
| 251 |
with_edge = hr_df[has_edge].sort_values("edge", ascending=False)
|
| 252 |
without_edge = hr_df[~has_edge]
|
| 253 |
|
| 254 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
with_edge = hr_df[has_edge].sort_values("edge", ascending=False)
|
| 252 |
without_edge = hr_df[~has_edge]
|
| 253 |
|
| 254 |
+
result = pd.concat([with_edge, without_edge], ignore_index=True)
|
| 255 |
+
|
| 256 |
+
try:
|
| 257 |
+
from analytics.execution_layer import enrich_with_execution_layer
|
| 258 |
+
return enrich_with_execution_layer(result)
|
| 259 |
+
except Exception as exc:
|
| 260 |
+
from utils.logger import logger
|
| 261 |
+
logger.warning("execution_layer enrichment failed: %s", exc)
|
| 262 |
+
return result
|
|
@@ -92,6 +92,7 @@ def build_upcoming_hitter_recommendations(
|
|
| 92 |
game_row: dict,
|
| 93 |
statcast_df: pd.DataFrame,
|
| 94 |
odds_df: pd.DataFrame | None = None,
|
|
|
|
| 95 |
weather_row: dict | None = None,
|
| 96 |
) -> list[dict]:
|
| 97 |
"""
|
|
@@ -109,9 +110,62 @@ def build_upcoming_hitter_recommendations(
|
|
| 109 |
weather_row=weather_row,
|
| 110 |
)
|
| 111 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
recommendations: list[dict] = []
|
| 113 |
|
| 114 |
for row in rows:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
slot = row.get("slot", "Current")
|
| 116 |
lineup_distance = _lineup_distance_from_slot(slot)
|
| 117 |
|
|
|
|
| 92 |
game_row: dict,
|
| 93 |
statcast_df: pd.DataFrame,
|
| 94 |
odds_df: pd.DataFrame | None = None,
|
| 95 |
+
prop_odds_df: pd.DataFrame | None = None,
|
| 96 |
weather_row: dict | None = None,
|
| 97 |
) -> list[dict]:
|
| 98 |
"""
|
|
|
|
| 110 |
weather_row=weather_row,
|
| 111 |
)
|
| 112 |
|
| 113 |
+
# Build lookup: normalized_player_name → best HR american odds from real prop feed
|
| 114 |
+
_prop_odds_lookup: dict[str, int] = {}
|
| 115 |
+
if prop_odds_df is not None and not prop_odds_df.empty:
|
| 116 |
+
try:
|
| 117 |
+
from data.odds_name_map import map_odds_name_to_model_name
|
| 118 |
+
hr_props = (
|
| 119 |
+
prop_odds_df[prop_odds_df["market"].isin(["batter_home_runs", "hr"])]
|
| 120 |
+
if "market" in prop_odds_df.columns
|
| 121 |
+
else prop_odds_df
|
| 122 |
+
)
|
| 123 |
+
if not hr_props.empty and "odds_american" in hr_props.columns and "player_name" in hr_props.columns:
|
| 124 |
+
# Explicit sort: MAX(odds_american) per player = best price for bettor
|
| 125 |
+
best_hr = (
|
| 126 |
+
hr_props
|
| 127 |
+
.sort_values("odds_american", ascending=False)
|
| 128 |
+
.drop_duplicates(subset=["player_name"])
|
| 129 |
+
)
|
| 130 |
+
for _, prow in best_hr.iterrows():
|
| 131 |
+
norm_name = map_odds_name_to_model_name(str(prow.get("player_name") or ""))
|
| 132 |
+
odds_val = prow.get("odds_american")
|
| 133 |
+
if norm_name and odds_val is not None:
|
| 134 |
+
try:
|
| 135 |
+
_prop_odds_lookup[norm_name] = int(float(odds_val))
|
| 136 |
+
except (TypeError, ValueError):
|
| 137 |
+
pass
|
| 138 |
+
except Exception as exc:
|
| 139 |
+
logger.warning("[prop_odds_lookup] build failure: %s", exc)
|
| 140 |
+
|
| 141 |
recommendations: list[dict] = []
|
| 142 |
|
| 143 |
for row in rows:
|
| 144 |
+
# Inject real book HR odds if available; fall back to simulator placeholder
|
| 145 |
+
if _prop_odds_lookup:
|
| 146 |
+
from data.odds_name_map import map_odds_name_to_model_name
|
| 147 |
+
_norm_batter = map_odds_name_to_model_name(str(row.get("batter_name") or ""))
|
| 148 |
+
_real_hr_odds = _prop_odds_lookup.get(_norm_batter)
|
| 149 |
+
|
| 150 |
+
# Fallback: raw name match if normalized mapping misses
|
| 151 |
+
if _real_hr_odds is None:
|
| 152 |
+
_real_hr_odds = _prop_odds_lookup.get(str(row.get("batter_name") or ""))
|
| 153 |
+
if _real_hr_odds is not None:
|
| 154 |
+
row["book_hr_odds_source"] = "live_feed_unmapped"
|
| 155 |
+
|
| 156 |
+
if _real_hr_odds is not None:
|
| 157 |
+
row["book_hr_odds"] = _real_hr_odds
|
| 158 |
+
row.setdefault("book_hr_odds_source", "live_feed")
|
| 159 |
+
else:
|
| 160 |
+
row.setdefault("book_hr_odds_source", "placeholder")
|
| 161 |
+
if prop_odds_df is not None and not prop_odds_df.empty:
|
| 162 |
+
logger.warning(
|
| 163 |
+
"[prop_odds_mapping_miss] batter=%s",
|
| 164 |
+
row.get("batter_name"),
|
| 165 |
+
)
|
| 166 |
+
else:
|
| 167 |
+
row.setdefault("book_hr_odds_source", "placeholder")
|
| 168 |
+
|
| 169 |
slot = row.get("slot", "Current")
|
| 170 |
lineup_distance = _lineup_distance_from_slot(slot)
|
| 171 |
|
|
@@ -467,6 +467,22 @@ def load_statcast_recent() -> pd.DataFrame:
|
|
| 467 |
return enriched
|
| 468 |
|
| 469 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 470 |
@st.cache_data(ttl=REFRESH_TTL_SECONDS)
|
| 471 |
def load_odds() -> pd.DataFrame:
|
| 472 |
return fetch_featured_odds()
|
|
@@ -1998,10 +2014,16 @@ def render_live_games_with_edge_strips(
|
|
| 1998 |
with cols[i % 2]:
|
| 1999 |
render_game_card(game)
|
| 2000 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2001 |
recommendations = build_upcoming_hitter_recommendations(
|
| 2002 |
game_row=game,
|
| 2003 |
statcast_df=statcast_df,
|
| 2004 |
odds_df=odds_df,
|
|
|
|
| 2005 |
weather_row=None,
|
| 2006 |
)
|
| 2007 |
|
|
@@ -2909,35 +2931,193 @@ def render_bet_tracker() -> None:
|
|
| 2909 |
st.plotly_chart(create_bankroll_chart(curve_df), use_container_width=True)
|
| 2910 |
|
| 2911 |
|
| 2912 |
-
def
|
| 2913 |
-
st.subheader("
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2914 |
st.markdown(
|
| 2915 |
"""
|
| 2916 |
-
|
| 2917 |
-
|
| 2918 |
-
|
| 2919 |
-
|
| 2920 |
-
|
| 2921 |
-
|
| 2922 |
-
6. Simulate batter outcomes
|
| 2923 |
-
7. Compare model outputs to no-vig market probabilities
|
| 2924 |
-
|
| 2925 |
-
### Matchup score inputs
|
| 2926 |
-
- EV90
|
| 2927 |
-
- xwOBA average
|
| 2928 |
-
- release speed
|
| 2929 |
-
- spin rate
|
| 2930 |
-
- movement
|
| 2931 |
-
- venue factor
|
| 2932 |
-
- weather factor
|
| 2933 |
-
|
| 2934 |
-
### Simulation outputs
|
| 2935 |
-
- hit probability
|
| 2936 |
-
- home run probability
|
| 2937 |
-
- total bases distribution
|
| 2938 |
"""
|
| 2939 |
)
|
| 2940 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2941 |
|
| 2942 |
def main() -> None:
|
| 2943 |
render_header()
|
|
@@ -2950,7 +3130,7 @@ def main() -> None:
|
|
| 2950 |
"Matchups",
|
| 2951 |
"Betting",
|
| 2952 |
"Bet Tracker",
|
| 2953 |
-
"
|
| 2954 |
"Feedback",
|
| 2955 |
"Debug",
|
| 2956 |
],
|
|
@@ -2969,8 +3149,8 @@ def main() -> None:
|
|
| 2969 |
render_betting()
|
| 2970 |
elif page == "Bet Tracker":
|
| 2971 |
render_bet_tracker()
|
| 2972 |
-
elif page == "
|
| 2973 |
-
|
| 2974 |
elif page == "Feedback":
|
| 2975 |
render_feedback(conn)
|
| 2976 |
elif page == "Debug":
|
|
|
|
| 467 |
return enriched
|
| 468 |
|
| 469 |
|
| 470 |
+
@st.cache_data(ttl=60, show_spinner=False)
|
| 471 |
+
def load_hr_prop_odds_for_game(away_team: str, home_team: str) -> pd.DataFrame:
|
| 472 |
+
"""Fetch live HR prop odds for a specific game. Returns empty df on failure."""
|
| 473 |
+
try:
|
| 474 |
+
from data.live_prop_odds import fetch_live_prop_odds
|
| 475 |
+
game_context = {"away_team": away_team, "home_team": home_team}
|
| 476 |
+
df = fetch_live_prop_odds(
|
| 477 |
+
game_context=game_context,
|
| 478 |
+
markets=["batter_home_runs"],
|
| 479 |
+
)
|
| 480 |
+
return df if df is not None else pd.DataFrame()
|
| 481 |
+
except Exception as exc:
|
| 482 |
+
logger.warning("[load_hr_prop_odds_for_game] failure: %s", exc)
|
| 483 |
+
return pd.DataFrame()
|
| 484 |
+
|
| 485 |
+
|
| 486 |
@st.cache_data(ttl=REFRESH_TTL_SECONDS)
|
| 487 |
def load_odds() -> pd.DataFrame:
|
| 488 |
return fetch_featured_odds()
|
|
|
|
| 2014 |
with cols[i % 2]:
|
| 2015 |
render_game_card(game)
|
| 2016 |
|
| 2017 |
+
prop_odds_df = load_hr_prop_odds_for_game(
|
| 2018 |
+
away_team=str(game.get("away_team", "")),
|
| 2019 |
+
home_team=str(game.get("home_team", "")),
|
| 2020 |
+
)
|
| 2021 |
+
|
| 2022 |
recommendations = build_upcoming_hitter_recommendations(
|
| 2023 |
game_row=game,
|
| 2024 |
statcast_df=statcast_df,
|
| 2025 |
odds_df=odds_df,
|
| 2026 |
+
prop_odds_df=prop_odds_df,
|
| 2027 |
weather_row=None,
|
| 2028 |
)
|
| 2029 |
|
|
|
|
| 2931 |
st.plotly_chart(create_bankroll_chart(curve_df), use_container_width=True)
|
| 2932 |
|
| 2933 |
|
| 2934 |
+
def render_alpha_release() -> None:
|
| 2935 |
+
st.subheader("Alpha Release")
|
| 2936 |
+
|
| 2937 |
+
st.info(
|
| 2938 |
+
"**Kasper is in alpha.** Model probabilities are statistical estimates, not guarantees. "
|
| 2939 |
+
"Edge values reflect model output vs. market implied probability — they do not predict outcomes. "
|
| 2940 |
+
"All outputs are for informational and research purposes only."
|
| 2941 |
+
)
|
| 2942 |
+
|
| 2943 |
st.markdown(
|
| 2944 |
"""
|
| 2945 |
+
**Kasper** is a pre-game and live-game baseball analytics engine built for the 2026 MLB season.
|
| 2946 |
+
It ingests Statcast data, live game feeds, and sportsbook odds to compute batter HR probabilities,
|
| 2947 |
+
compare them against the market, and surface edges in real time.
|
| 2948 |
+
|
| 2949 |
+
This is an **alpha release** — the model stack is functional and actively processing live data,
|
| 2950 |
+
but outputs are under ongoing validation. Calibration data is accumulating each game day.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2951 |
"""
|
| 2952 |
)
|
| 2953 |
|
| 2954 |
+
with st.expander("System Overview", expanded=False):
|
| 2955 |
+
st.markdown(
|
| 2956 |
+
"""
|
| 2957 |
+
**What Kasper currently supports:**
|
| 2958 |
+
- Live game recommendations (Dashboard) — HR, Hit, Total Bases props for batters On Deck / In Hole / 3 Away
|
| 2959 |
+
- Pre-game HR prop analysis (Props tab) — edge vs. retail books (DraftKings, FanDuel, BetMGM, Caesars)
|
| 2960 |
+
- Execution layer (Alpha) — cross-book market comparison, edge quality filtering, final recommendation score
|
| 2961 |
+
- Full debug visibility — adjustment ladders, signal attribution, execution layer diagnostics
|
| 2962 |
+
|
| 2963 |
+
**Data sources:**
|
| 2964 |
+
- Statcast (Baseball Savant) — batter and pitcher features, 14-day rolling window
|
| 2965 |
+
- MLB Schedule API — live game state, lineup, score
|
| 2966 |
+
- Sportsbook odds API — HR prop odds from retail books
|
| 2967 |
+
"""
|
| 2968 |
+
)
|
| 2969 |
+
|
| 2970 |
+
with st.expander("How It Works", expanded=False):
|
| 2971 |
+
st.markdown(
|
| 2972 |
+
"""
|
| 2973 |
+
**Signal flow:**
|
| 2974 |
+
|
| 2975 |
+
```
|
| 2976 |
+
Statcast features
|
| 2977 |
+
→ Batter baseline (EV90, barrel rate, hard-hit rate, xwOBA, launch angle)
|
| 2978 |
+
→ Pitcher adjustment (velo, EV allowed, barrel rate allowed)
|
| 2979 |
+
→ Context adjustments (park, weather, bullpen state)
|
| 2980 |
+
→ Zone / arsenal / family-zone matchup overlays
|
| 2981 |
+
→ Trend & rolling form (5/10-game windows)
|
| 2982 |
+
→ Opportunity adjustment (expected PA given game state)
|
| 2983 |
+
→ Fair probability → American odds
|
| 2984 |
+
→ Compare vs. sportsbook implied probability
|
| 2985 |
+
→ Edge = model prob − book implied prob
|
| 2986 |
+
→ Execution layer (market disagreement, confidence, timing, final score)
|
| 2987 |
+
→ Recommendation: BET / WATCH / PASS
|
| 2988 |
+
```
|
| 2989 |
+
"""
|
| 2990 |
+
)
|
| 2991 |
+
|
| 2992 |
+
with st.expander("Core Math", expanded=False):
|
| 2993 |
+
st.markdown(
|
| 2994 |
+
r"""
|
| 2995 |
+
**Baseline probability** (per batter, pre-game):
|
| 2996 |
+
- EV90, barrel rate, hard-hit rate, xwOBA, launch angle → weighted sum → bounded probability
|
| 2997 |
+
- Bounds: HR [0.5%, 22%], Hit [5%, 50%], TB2P [3%, 42%]
|
| 2998 |
+
|
| 2999 |
+
**Edge:**
|
| 3000 |
+
```
|
| 3001 |
+
edge = model_prob − implied_prob(book_odds)
|
| 3002 |
+
```
|
| 3003 |
+
Positive edge = model believes event is more likely than the market does.
|
| 3004 |
+
|
| 3005 |
+
**Adjusted edge** (live Dashboard):
|
| 3006 |
+
```
|
| 3007 |
+
adjusted_edge = hr_edge + slot_boost
|
| 3008 |
+
slot_boost: On Deck +1.2pp, In Hole +0.6pp, 3 Away +0.0pp
|
| 3009 |
+
```
|
| 3010 |
+
|
| 3011 |
+
**Execution score** (Execution Layer):
|
| 3012 |
+
```
|
| 3013 |
+
base = edge_filtered × (0.4 + confidence × 0.6)
|
| 3014 |
+
score = base − vol_penalty + market_bonus + timing_bonus
|
| 3015 |
+
score clamped to [−0.30, +0.30]
|
| 3016 |
+
```
|
| 3017 |
+
|
| 3018 |
+
**Recommendation tiers:**
|
| 3019 |
+
- BET: adjusted_edge ≥ 6% AND confidence ≥ 78
|
| 3020 |
+
- WATCH: adjusted_edge ≥ 2.5% AND confidence ≥ 62
|
| 3021 |
+
- PASS: all others
|
| 3022 |
+
"""
|
| 3023 |
+
)
|
| 3024 |
+
|
| 3025 |
+
with st.expander("Signal Library", expanded=False):
|
| 3026 |
+
st.markdown(
|
| 3027 |
+
"""
|
| 3028 |
+
| Signal | Source | Type |
|
| 3029 |
+
|--------|--------|------|
|
| 3030 |
+
| EV90 | Statcast (90th pct exit velo) | Batter power |
|
| 3031 |
+
| Barrel rate | Statcast | Batter quality contact |
|
| 3032 |
+
| Hard-hit rate | Statcast | Batter contact strength |
|
| 3033 |
+
| xwOBA | Statcast | Batter overall quality |
|
| 3034 |
+
| Launch angle | Statcast | HR trajectory profile |
|
| 3035 |
+
| Pitcher velo | Statcast | Pitcher difficulty |
|
| 3036 |
+
| EV allowed | Statcast | Pitcher weakness |
|
| 3037 |
+
| Zone matchup | Statcast pitch zones | Pitch-to-zone alignment |
|
| 3038 |
+
| Arsenal matchup | Statcast pitch types | Batter vs. pitch family |
|
| 3039 |
+
| Rolling form | 5/10-game window | Recent batter/pitcher trend |
|
| 3040 |
+
| Bullpen state | Live game feed | Leverage / transition risk |
|
| 3041 |
+
| Park factor | Venue lookup | HR environment |
|
| 3042 |
+
| Platoon | Batter/pitcher handedness | Splits adjustment |
|
| 3043 |
+
| Opportunity | Game state (outs, slot) | Expected PA probability |
|
| 3044 |
+
"""
|
| 3045 |
+
)
|
| 3046 |
+
|
| 3047 |
+
with st.expander("Execution Layer (Alpha)", expanded=False):
|
| 3048 |
+
st.markdown(
|
| 3049 |
+
"""
|
| 3050 |
+
The Execution Layer is a post-model pass that does **not** modify probabilities.
|
| 3051 |
+
It operates on already-computed outputs (model probs + book odds) to improve edge selection.
|
| 3052 |
+
|
| 3053 |
+
**Five passes:**
|
| 3054 |
+
1. **Market Disagreement** — best/median/worst implied prob across books; flags outlier and stale books
|
| 3055 |
+
2. **Edge Quality** — confidence score (source quality), volatility score (market width), signal strength; filters edge_raw → edge_filtered
|
| 3056 |
+
3. **Timing Heuristics** — detects aggressive prices (>2pp better than median) and timestamp presence
|
| 3057 |
+
4. **Correlation** — flags all HR props as positively correlated; detects stacked games (>2 players per game)
|
| 3058 |
+
5. **Final Score** — blends edge_filtered, confidence, volatility, market width, and timing into a [−0.30, +0.30] score
|
| 3059 |
+
|
| 3060 |
+
Visible in: Props tab → "Execution Layer" expander | Debug tab → "Execution Layer (Props)" expander
|
| 3061 |
+
"""
|
| 3062 |
+
)
|
| 3063 |
+
|
| 3064 |
+
with st.expander("System Health", expanded=False):
|
| 3065 |
+
st.markdown(
|
| 3066 |
+
"""
|
| 3067 |
+
| Feed | Refresh | Notes |
|
| 3068 |
+
|------|---------|-------|
|
| 3069 |
+
| Live game feed | 5s TTL | Live only when games in progress |
|
| 3070 |
+
| Scores | 8s TTL | |
|
| 3071 |
+
| Schedule | 300s TTL | |
|
| 3072 |
+
| Statcast | 600s TTL | 14-day rolling window |
|
| 3073 |
+
| Odds (moneyline) | 30s TTL | Used for Betting tab |
|
| 3074 |
+
| HR props (live, per game) | 60s TTL | Wired into Dashboard recommendations |
|
| 3075 |
+
| HR props (pre-game) | On demand | Via Props tab |
|
| 3076 |
+
|
| 3077 |
+
Data is stored in CockroachDB. Tables: `recommendation_logs`, `upcoming_hr_props`,
|
| 3078 |
+
`batter_prop_outcomes`, `game_outcomes`, `feedback_submissions`.
|
| 3079 |
+
"""
|
| 3080 |
+
)
|
| 3081 |
+
|
| 3082 |
+
with st.expander("Alpha Scope", expanded=False):
|
| 3083 |
+
st.markdown(
|
| 3084 |
+
"""
|
| 3085 |
+
**Primary focus:** HR props (home run probability)
|
| 3086 |
+
|
| 3087 |
+
HR is the primary market because:
|
| 3088 |
+
- It has the clearest Statcast signal (EV90, barrel rate, launch angle)
|
| 3089 |
+
- It's a binary outcome — clean to evaluate
|
| 3090 |
+
- Books offer consistent retail HR prop lines (DK, FD, BetMGM, Caesars)
|
| 3091 |
+
|
| 3092 |
+
Hit and Total Bases props are computed and displayed but receive less model focus in alpha.
|
| 3093 |
+
"""
|
| 3094 |
+
)
|
| 3095 |
+
|
| 3096 |
+
with st.expander("Known Limitations", expanded=False):
|
| 3097 |
+
st.markdown(
|
| 3098 |
+
"""
|
| 3099 |
+
- **Pre-game baseline only** (Props tab): No live lineup, park, or weather context. Model uses season Statcast features.
|
| 3100 |
+
- **Live book odds**: When live HR prop odds are unavailable for a game, the Dashboard uses market-neutral reference odds (~+425). These are labeled with `~` in the BOOK column.
|
| 3101 |
+
- **Calibration**: Model has not yet accumulated a full-season outcome dataset. Probability estimates are structurally reasonable but not empirically calibrated to 2026 data.
|
| 3102 |
+
- **Name mapping**: Sportsbook player names sometimes differ from Statcast names. Some players may show "unavailable" source until mapping is added.
|
| 3103 |
+
- **No closing line value (CLV)**: CLV tracking requires final closing odds — not yet wired.
|
| 3104 |
+
- **No account for lineup scratches**: If a player is scratched post-lineup release, the model doesn't know.
|
| 3105 |
+
"""
|
| 3106 |
+
)
|
| 3107 |
+
|
| 3108 |
+
with st.expander("Feedback & Roadmap", expanded=False):
|
| 3109 |
+
st.markdown(
|
| 3110 |
+
"""
|
| 3111 |
+
Use the **Feedback** tab to submit observations, bugs, or suggestions.
|
| 3112 |
+
|
| 3113 |
+
**Near-term roadmap:**
|
| 3114 |
+
- Post-game outcome grading and calibration reports
|
| 3115 |
+
- Closing line value (CLV) tracking
|
| 3116 |
+
- Hit and Total Bases model calibration
|
| 3117 |
+
- XGBoost model integration (currently shadow mode only)
|
| 3118 |
+
"""
|
| 3119 |
+
)
|
| 3120 |
+
|
| 3121 |
|
| 3122 |
def main() -> None:
|
| 3123 |
render_header()
|
|
|
|
| 3130 |
"Matchups",
|
| 3131 |
"Betting",
|
| 3132 |
"Bet Tracker",
|
| 3133 |
+
"Alpha Release",
|
| 3134 |
"Feedback",
|
| 3135 |
"Debug",
|
| 3136 |
],
|
|
|
|
| 3149 |
render_betting()
|
| 3150 |
elif page == "Bet Tracker":
|
| 3151 |
render_bet_tracker()
|
| 3152 |
+
elif page == "Alpha Release":
|
| 3153 |
+
render_alpha_release()
|
| 3154 |
elif page == "Feedback":
|
| 3155 |
render_feedback(conn)
|
| 3156 |
elif page == "Debug":
|
|
@@ -119,7 +119,8 @@ def fetch_live_prop_odds(
|
|
| 119 |
)
|
| 120 |
if not df.empty:
|
| 121 |
frames.append(df)
|
| 122 |
-
except Exception:
|
|
|
|
| 123 |
continue
|
| 124 |
|
| 125 |
if not frames:
|
|
|
|
| 119 |
)
|
| 120 |
if not df.empty:
|
| 121 |
frames.append(df)
|
| 122 |
+
except Exception as exc:
|
| 123 |
+
logger.warning("[fetch_live_prop_odds] provider failure: %s", exc)
|
| 124 |
continue
|
| 125 |
|
| 126 |
if not frames:
|
|
@@ -390,6 +390,35 @@ def render_debug(
|
|
| 390 |
else:
|
| 391 |
st.info("Bullpen candidate data not available.")
|
| 392 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
# ------------------------------------------------------------------
|
| 394 |
# SECTION 6 — Admin Tools
|
| 395 |
# ------------------------------------------------------------------
|
|
|
|
| 390 |
else:
|
| 391 |
st.info("Bullpen candidate data not available.")
|
| 392 |
|
| 393 |
+
# ------------------------------------------------------------------
|
| 394 |
+
# SECTION 5c — Execution Layer
|
| 395 |
+
# ------------------------------------------------------------------
|
| 396 |
+
exec_df = st.session_state.get("props_exec_df")
|
| 397 |
+
with st.expander("Execution Layer (Props)", expanded=False):
|
| 398 |
+
if exec_df is None or (isinstance(exec_df, pd.DataFrame) and exec_df.empty):
|
| 399 |
+
st.info("No execution layer data. Visit the Props tab first.")
|
| 400 |
+
else:
|
| 401 |
+
exec_cols = [
|
| 402 |
+
"player_name", "sportsbook",
|
| 403 |
+
"edge_raw", "edge_filtered", "confidence_score",
|
| 404 |
+
"volatility_score", "signal_strength_score",
|
| 405 |
+
"market_width", "market_outlier_flag", "stale_book_flag",
|
| 406 |
+
"timing_flag", "timing_reason",
|
| 407 |
+
"correlation_flag", "correlation_direction",
|
| 408 |
+
"final_recommendation_score", "edge_filter_flags",
|
| 409 |
+
]
|
| 410 |
+
available = [c for c in exec_cols if c in exec_df.columns]
|
| 411 |
+
if available:
|
| 412 |
+
sort_col = "final_recommendation_score"
|
| 413 |
+
display_exec = exec_df[available].copy()
|
| 414 |
+
if sort_col in display_exec.columns:
|
| 415 |
+
display_exec = display_exec.sort_values(
|
| 416 |
+
sort_col, ascending=False, na_position="last"
|
| 417 |
+
)
|
| 418 |
+
st.dataframe(display_exec, use_container_width=True, hide_index=True)
|
| 419 |
+
else:
|
| 420 |
+
st.info("Execution layer fields not present in props data.")
|
| 421 |
+
|
| 422 |
# ------------------------------------------------------------------
|
| 423 |
# SECTION 6 — Admin Tools
|
| 424 |
# ------------------------------------------------------------------
|
|
@@ -91,6 +91,7 @@ def render_props(statcast_df: pd.DataFrame, conn=None) -> None:
|
|
| 91 |
if mapped.empty:
|
| 92 |
st.info("No mappable HR prop rows.")
|
| 93 |
return
|
|
|
|
| 94 |
|
| 95 |
# Log to durable DB (non-blocking)
|
| 96 |
if conn is not None:
|
|
@@ -252,6 +253,22 @@ def render_props(statcast_df: pd.DataFrame, conn=None) -> None:
|
|
| 252 |
table_df = pd.DataFrame(rows)
|
| 253 |
st.dataframe(table_df, use_container_width=True, hide_index=True)
|
| 254 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
# ---------------------------------------------------------------------------
|
| 256 |
# Disclaimer (HR only)
|
| 257 |
# ---------------------------------------------------------------------------
|
|
|
|
| 91 |
if mapped.empty:
|
| 92 |
st.info("No mappable HR prop rows.")
|
| 93 |
return
|
| 94 |
+
st.session_state["props_exec_df"] = mapped
|
| 95 |
|
| 96 |
# Log to durable DB (non-blocking)
|
| 97 |
if conn is not None:
|
|
|
|
| 253 |
table_df = pd.DataFrame(rows)
|
| 254 |
st.dataframe(table_df, use_container_width=True, hide_index=True)
|
| 255 |
|
| 256 |
+
if market_type == "hr" and "final_recommendation_score" in display.columns:
|
| 257 |
+
with st.expander("Execution Layer", expanded=False):
|
| 258 |
+
exec_cols = [
|
| 259 |
+
"player_name", "sportsbook",
|
| 260 |
+
"edge_raw", "edge_filtered", "confidence_score",
|
| 261 |
+
"volatility_score", "signal_strength_score",
|
| 262 |
+
"market_width", "market_outlier_flag", "stale_book_flag",
|
| 263 |
+
"timing_flag", "correlation_flag",
|
| 264 |
+
"final_recommendation_score", "edge_filter_flags",
|
| 265 |
+
]
|
| 266 |
+
exec_display = display[[c for c in exec_cols if c in display.columns]].copy()
|
| 267 |
+
exec_display = exec_display.sort_values(
|
| 268 |
+
"final_recommendation_score", ascending=False, na_position="last"
|
| 269 |
+
)
|
| 270 |
+
st.dataframe(exec_display, use_container_width=True, hide_index=True)
|
| 271 |
+
|
| 272 |
# ---------------------------------------------------------------------------
|
| 273 |
# Disclaimer (HR only)
|
| 274 |
# ---------------------------------------------------------------------------
|
|
@@ -124,6 +124,16 @@ def render_recommendation_panels(rows: list[dict[str, Any]]) -> None:
|
|
| 124 |
|
| 125 |
badges_html = _fmt_badges(row.get("opportunity_badges", []))
|
| 126 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
body_rows.append(
|
| 128 |
f"""
|
| 129 |
<div class="row-wrap">
|
|
@@ -134,7 +144,7 @@ def render_recommendation_panels(rows: list[dict[str, Any]]) -> None:
|
|
| 134 |
<div class="reason-line">{reason_text}</div>
|
| 135 |
</div>
|
| 136 |
<div>{_fmt_odds(row.get("fair_hr_odds"))}</div>
|
| 137 |
-
<div>{
|
| 138 |
<div>{_fmt_edge(row.get("adjusted_edge", row.get("hr_edge")))}</div>
|
| 139 |
<div>{_fmt_confidence(row.get("confidence"))}</div>
|
| 140 |
<div>{_fmt_tier(row.get("recommendation_tier"))}</div>
|
|
|
|
| 124 |
|
| 125 |
badges_html = _fmt_badges(row.get("opportunity_badges", []))
|
| 126 |
|
| 127 |
+
_book_src = str(row.get("book_hr_odds_source") or "placeholder")
|
| 128 |
+
_book_odds_raw = _fmt_odds(row.get("book_hr_odds"))
|
| 129 |
+
if _book_src == "placeholder":
|
| 130 |
+
_book_display = (
|
| 131 |
+
f'<span title="Reference odds (market data unavailable)" '
|
| 132 |
+
f'style="color:#64748b;">~{_book_odds_raw}</span>'
|
| 133 |
+
)
|
| 134 |
+
else:
|
| 135 |
+
_book_display = _book_odds_raw
|
| 136 |
+
|
| 137 |
body_rows.append(
|
| 138 |
f"""
|
| 139 |
<div class="row-wrap">
|
|
|
|
| 144 |
<div class="reason-line">{reason_text}</div>
|
| 145 |
</div>
|
| 146 |
<div>{_fmt_odds(row.get("fair_hr_odds"))}</div>
|
| 147 |
+
<div>{_book_display}</div>
|
| 148 |
<div>{_fmt_edge(row.get("adjusted_edge", row.get("hr_edge")))}</div>
|
| 149 |
<div>{_fmt_confidence(row.get("confidence"))}</div>
|
| 150 |
<div>{_fmt_tier(row.get("recommendation_tier"))}</div>
|