Syntrex Claude Sonnet 4.6 commited on
Commit
dba351a
·
1 Parent(s): 95e27f5

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 ADDED
@@ -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
analytics/props_mapper.py CHANGED
@@ -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
- return pd.concat([with_edge, without_edge], ignore_index=True)
 
 
 
 
 
 
 
 
 
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
analytics/recommendation_engine.py CHANGED
@@ -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
 
app.py CHANGED
@@ -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 render_algorithm_breakdown() -> None:
2913
- st.subheader("Algorithm Breakdown")
 
 
 
 
 
 
 
2914
  st.markdown(
2915
  """
2916
- ### WBC-first data flow
2917
- 1. Pull official WBC schedule page
2918
- 2. Pull WBC Statcast events from Baseball Savant
2919
- 3. Engineer batter and pitch features
2920
- 4. Build pitcher baseline from recent WBC events
2921
- 5. Score batter-vs-pitcher matchups
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
- "Algorithm Breakdown",
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 == "Algorithm Breakdown":
2973
- render_algorithm_breakdown()
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":
data/live_prop_odds.py CHANGED
@@ -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:
visualization/debug_page.py CHANGED
@@ -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
  # ------------------------------------------------------------------
visualization/props_page.py CHANGED
@@ -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
  # ---------------------------------------------------------------------------
visualization/recommendation_panels.py CHANGED
@@ -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>{_fmt_odds(row.get("book_hr_odds"))}</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>