Spaces:
Sleeping
Fix all odds paths: per-event endpoint + regions + cache bypass
Browse filesUpcoming path (Props page):
- Add regions=us to per-event odds_params (documented as required; absence
caused API to return bookmakers:[] silently, producing zero rows)
- Lower _MAX_EVENTS 20→15 (covers full Opening Day slate)
- Add event-ID dedup guard before each per-event request
- Track 429s specifically in per-event except block
- Replace final log with SUMMARY line (events_returned/attempted/rate_limited/rows)
- Fix render_props fallback: remove `not raw_props.empty` guard so cached empty
results are not bypassed, eliminating uncached re-fetch on every Streamlit rerun
Live path (Dashboard):
- Replace aggregate /odds endpoint (422 on player props) with two-step per-event
approach: GET /events (commenceTimeFrom=now-6h to capture live games) → match
teams via _canon_team → GET /events/{id}/odds with regions=us + requested markets
- Function signature and row dict schema unchanged; all callers unaffected
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- data/provider_theoddsapi.py +120 -56
- visualization/props_page.py +3 -2
|
@@ -49,7 +49,7 @@ BOOK_KEY_MAP = {
|
|
| 49 |
"williamhill_us": "Caesars",
|
| 50 |
}
|
| 51 |
|
| 52 |
-
_MAX_EVENTS =
|
| 53 |
|
| 54 |
TEAM_NAME_ALIASES = {
|
| 55 |
"usa": "united states",
|
|
@@ -121,12 +121,54 @@ class TheOddsAPIProvider(MarketProviderBase):
|
|
| 121 |
away_key = _canon_team(game_context.get("away_team", ""))
|
| 122 |
home_key = _canon_team(game_context.get("home_team", ""))
|
| 123 |
|
| 124 |
-
|
| 125 |
-
# This may not return WBC prop markets even when books offer them.
|
| 126 |
-
sport_key = "baseball_mlb"
|
| 127 |
|
| 128 |
-
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
"apiKey": ODDS_API_KEY,
|
| 131 |
"regions": "us",
|
| 132 |
"markets": ",".join(mkts),
|
|
@@ -135,60 +177,60 @@ class TheOddsAPIProvider(MarketProviderBase):
|
|
| 135 |
"dateFormat": "iso",
|
| 136 |
}
|
| 137 |
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
if event_away != away_key or event_home != home_key:
|
| 149 |
-
continue
|
| 150 |
|
| 151 |
-
|
| 152 |
-
|
|
|
|
|
|
|
| 153 |
|
| 154 |
-
|
| 155 |
-
book_key = str(bookmaker.get("key", "") or "")
|
| 156 |
-
book_name = BOOK_KEY_MAP.get(book_key, book_key)
|
| 157 |
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
).strip()
|
| 166 |
-
if not player_name_raw:
|
| 167 |
-
continue
|
| 168 |
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
|
|
|
|
|
|
|
|
|
| 172 |
|
| 173 |
-
|
|
|
|
|
|
|
| 174 |
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
|
| 193 |
return pd.DataFrame(rows)
|
| 194 |
|
|
@@ -249,11 +291,18 @@ class TheOddsAPIProvider(MarketProviderBase):
|
|
| 249 |
events = events[:_MAX_EVENTS]
|
| 250 |
|
| 251 |
rows: list[dict[str, Any]] = []
|
|
|
|
|
|
|
|
|
|
| 252 |
|
| 253 |
for event in events:
|
| 254 |
event_id = str(event.get("id", "") or "")
|
| 255 |
if not event_id:
|
| 256 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
away_team = str(event.get("away_team", "") or "")
|
| 258 |
home_team = str(event.get("home_team", "") or "")
|
| 259 |
commence_time = str(event.get("commence_time", "") or "")
|
|
@@ -261,6 +310,7 @@ class TheOddsAPIProvider(MarketProviderBase):
|
|
| 261 |
odds_url = f"{ODDS_API_BASE}/baseball_mlb/events/{event_id}/odds"
|
| 262 |
odds_params = {
|
| 263 |
"apiKey": ODDS_API_KEY,
|
|
|
|
| 264 |
"markets": "batter_home_runs",
|
| 265 |
"bookmakers": ",".join(books),
|
| 266 |
"oddsFormat": "american",
|
|
@@ -286,9 +336,16 @@ class TheOddsAPIProvider(MarketProviderBase):
|
|
| 286 |
)
|
| 287 |
r2.raise_for_status()
|
| 288 |
except (requests.HTTPError, requests.RequestException) as exc:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
_diag_log.warning(
|
| 290 |
-
"[upcoming_hr_props] event %s@%s odds failed: %s",
|
| 291 |
-
away_team, home_team, exc,
|
| 292 |
)
|
| 293 |
continue
|
| 294 |
|
|
@@ -390,5 +447,12 @@ class TheOddsAPIProvider(MarketProviderBase):
|
|
| 390 |
_skip_market_mismatch, _skip_empty_name, _skip_missing_price,
|
| 391 |
)
|
| 392 |
|
| 393 |
-
_diag_log.info(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 394 |
return pd.DataFrame(rows)
|
|
|
|
| 49 |
"williamhill_us": "Caesars",
|
| 50 |
}
|
| 51 |
|
| 52 |
+
_MAX_EVENTS = 15
|
| 53 |
|
| 54 |
TEAM_NAME_ALIASES = {
|
| 55 |
"usa": "united states",
|
|
|
|
| 121 |
away_key = _canon_team(game_context.get("away_team", ""))
|
| 122 |
home_key = _canon_team(game_context.get("home_team", ""))
|
| 123 |
|
| 124 |
+
from datetime import datetime, timezone, timedelta
|
|
|
|
|
|
|
| 125 |
|
| 126 |
+
now = datetime.now(timezone.utc)
|
| 127 |
+
events_url = f"{ODDS_API_BASE}/baseball_mlb/events"
|
| 128 |
+
events_params = {
|
| 129 |
+
"apiKey": ODDS_API_KEY,
|
| 130 |
+
"dateFormat": "iso",
|
| 131 |
+
"commenceTimeFrom": (now - timedelta(hours=6)).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
| 132 |
+
"commenceTimeTo": (now + timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
try:
|
| 136 |
+
r1 = requests.get(events_url, params=events_params, timeout=30)
|
| 137 |
+
r1.raise_for_status()
|
| 138 |
+
except requests.HTTPError as exc:
|
| 139 |
+
body = (exc.response.text[:300] if exc.response is not None else "")
|
| 140 |
+
raise RuntimeError(
|
| 141 |
+
f"Odds API events list HTTP {exc.response.status_code}: {body}"
|
| 142 |
+
) from exc
|
| 143 |
+
except requests.RequestException as exc:
|
| 144 |
+
raise RuntimeError(f"Odds API events network error: {exc}") from exc
|
| 145 |
+
|
| 146 |
+
events = r1.json()
|
| 147 |
+
|
| 148 |
+
# Find the event matching this game's teams
|
| 149 |
+
event_id = None
|
| 150 |
+
away_team_orig = ""
|
| 151 |
+
home_team_orig = ""
|
| 152 |
+
commence_time = ""
|
| 153 |
+
for ev in events:
|
| 154 |
+
ev_away = _canon_team(ev.get("away_team", ""))
|
| 155 |
+
ev_home = _canon_team(ev.get("home_team", ""))
|
| 156 |
+
if ev_away == away_key and ev_home == home_key:
|
| 157 |
+
event_id = str(ev.get("id", "") or "")
|
| 158 |
+
away_team_orig = str(ev.get("away_team", "") or "")
|
| 159 |
+
home_team_orig = str(ev.get("home_team", "") or "")
|
| 160 |
+
commence_time = str(ev.get("commence_time", "") or "")
|
| 161 |
+
break
|
| 162 |
+
|
| 163 |
+
if not event_id:
|
| 164 |
+
_diag_log.info(
|
| 165 |
+
"[live_prop_odds] no matching event for %s@%s in %d events",
|
| 166 |
+
away_key, home_key, len(events),
|
| 167 |
+
)
|
| 168 |
+
return pd.DataFrame()
|
| 169 |
+
|
| 170 |
+
odds_url = f"{ODDS_API_BASE}/baseball_mlb/events/{event_id}/odds"
|
| 171 |
+
odds_params = {
|
| 172 |
"apiKey": ODDS_API_KEY,
|
| 173 |
"regions": "us",
|
| 174 |
"markets": ",".join(mkts),
|
|
|
|
| 177 |
"dateFormat": "iso",
|
| 178 |
}
|
| 179 |
|
| 180 |
+
try:
|
| 181 |
+
r2 = requests.get(odds_url, params=odds_params, timeout=30)
|
| 182 |
+
r2.raise_for_status()
|
| 183 |
+
except requests.HTTPError as exc:
|
| 184 |
+
body = (exc.response.text[:300] if exc.response is not None else "")
|
| 185 |
+
raise RuntimeError(
|
| 186 |
+
f"Odds API event odds HTTP {exc.response.status_code}: {body}"
|
| 187 |
+
) from exc
|
| 188 |
+
except requests.RequestException as exc:
|
| 189 |
+
raise RuntimeError(f"Odds API event odds network error: {exc}") from exc
|
|
|
|
|
|
|
| 190 |
|
| 191 |
+
event_data = r2.json()
|
| 192 |
+
bookmakers = (
|
| 193 |
+
event_data.get("bookmakers", []) if isinstance(event_data, dict) else []
|
| 194 |
+
)
|
| 195 |
|
| 196 |
+
rows: list[dict[str, Any]] = []
|
|
|
|
|
|
|
| 197 |
|
| 198 |
+
for bookmaker in bookmakers:
|
| 199 |
+
book_key = str(bookmaker.get("key", "") or "")
|
| 200 |
+
book_name = BOOK_KEY_MAP.get(book_key, book_key)
|
| 201 |
|
| 202 |
+
for market in bookmaker.get("markets", []) or []:
|
| 203 |
+
market_key = str(market.get("key", "") or "")
|
| 204 |
+
market_name = MARKET_NAME_MAP.get(market_key, market_key)
|
|
|
|
|
|
|
|
|
|
| 205 |
|
| 206 |
+
for outcome in market.get("outcomes", []) or []:
|
| 207 |
+
player_name_raw = str(
|
| 208 |
+
outcome.get("description", "") or outcome.get("name", "") or ""
|
| 209 |
+
).strip()
|
| 210 |
+
if not player_name_raw:
|
| 211 |
+
continue
|
| 212 |
|
| 213 |
+
price = outcome.get("price")
|
| 214 |
+
if price is None:
|
| 215 |
+
continue
|
| 216 |
|
| 217 |
+
rows.append(
|
| 218 |
+
{
|
| 219 |
+
"provider": self.provider_name,
|
| 220 |
+
"event_id": event_id,
|
| 221 |
+
"commence_time": commence_time,
|
| 222 |
+
"away_team": away_team_orig,
|
| 223 |
+
"home_team": home_team_orig,
|
| 224 |
+
"sportsbook": book_name,
|
| 225 |
+
"sportsbook_key": book_key,
|
| 226 |
+
"market_key": market_key,
|
| 227 |
+
"market": market_name,
|
| 228 |
+
"player_name_raw": player_name_raw,
|
| 229 |
+
"player_name": map_odds_name_to_model_name(player_name_raw),
|
| 230 |
+
"odds_american": int(price),
|
| 231 |
+
"line": _safe_float(outcome.get("point")),
|
| 232 |
+
}
|
| 233 |
+
)
|
| 234 |
|
| 235 |
return pd.DataFrame(rows)
|
| 236 |
|
|
|
|
| 291 |
events = events[:_MAX_EVENTS]
|
| 292 |
|
| 293 |
rows: list[dict[str, Any]] = []
|
| 294 |
+
seen_ids: set[str] = set()
|
| 295 |
+
_events_attempted = 0
|
| 296 |
+
_events_rate_limited = 0
|
| 297 |
|
| 298 |
for event in events:
|
| 299 |
event_id = str(event.get("id", "") or "")
|
| 300 |
if not event_id:
|
| 301 |
continue
|
| 302 |
+
if event_id in seen_ids:
|
| 303 |
+
continue
|
| 304 |
+
seen_ids.add(event_id)
|
| 305 |
+
_events_attempted += 1
|
| 306 |
away_team = str(event.get("away_team", "") or "")
|
| 307 |
home_team = str(event.get("home_team", "") or "")
|
| 308 |
commence_time = str(event.get("commence_time", "") or "")
|
|
|
|
| 310 |
odds_url = f"{ODDS_API_BASE}/baseball_mlb/events/{event_id}/odds"
|
| 311 |
odds_params = {
|
| 312 |
"apiKey": ODDS_API_KEY,
|
| 313 |
+
"regions": "us",
|
| 314 |
"markets": "batter_home_runs",
|
| 315 |
"bookmakers": ",".join(books),
|
| 316 |
"oddsFormat": "american",
|
|
|
|
| 336 |
)
|
| 337 |
r2.raise_for_status()
|
| 338 |
except (requests.HTTPError, requests.RequestException) as exc:
|
| 339 |
+
_is_429 = (
|
| 340 |
+
isinstance(exc, requests.HTTPError)
|
| 341 |
+
and exc.response is not None
|
| 342 |
+
and exc.response.status_code == 429
|
| 343 |
+
)
|
| 344 |
+
if _is_429:
|
| 345 |
+
_events_rate_limited += 1
|
| 346 |
_diag_log.warning(
|
| 347 |
+
"[upcoming_hr_props] event %s@%s odds failed (429=%s): %s",
|
| 348 |
+
away_team, home_team, _is_429, exc,
|
| 349 |
)
|
| 350 |
continue
|
| 351 |
|
|
|
|
| 447 |
_skip_market_mismatch, _skip_empty_name, _skip_missing_price,
|
| 448 |
)
|
| 449 |
|
| 450 |
+
_diag_log.info(
|
| 451 |
+
"[upcoming_hr_props] SUMMARY events_returned=%d events_attempted=%d "
|
| 452 |
+
"events_rate_limited=%d total_rows=%d",
|
| 453 |
+
len(events),
|
| 454 |
+
_events_attempted,
|
| 455 |
+
_events_rate_limited,
|
| 456 |
+
len(rows),
|
| 457 |
+
)
|
| 458 |
return pd.DataFrame(rows)
|
|
@@ -55,8 +55,9 @@ def _format_edge(val: float | None) -> str:
|
|
| 55 |
def render_props(statcast_df: pd.DataFrame, conn=None, raw_props: pd.DataFrame | None = None) -> None:
|
| 56 |
st.subheader("Props")
|
| 57 |
|
| 58 |
-
# Use pre-fetched (cached) props when available
|
| 59 |
-
|
|
|
|
| 60 |
raw = raw_props
|
| 61 |
else:
|
| 62 |
with st.spinner("Loading props..."):
|
|
|
|
| 55 |
def render_props(statcast_df: pd.DataFrame, conn=None, raw_props: pd.DataFrame | None = None) -> None:
|
| 56 |
st.subheader("Props")
|
| 57 |
|
| 58 |
+
# Use pre-fetched (cached) props when available.
|
| 59 |
+
# If caller passed raw_props (even empty), use it — do not re-fetch and bypass the cache.
|
| 60 |
+
if raw_props is not None:
|
| 61 |
raw = raw_props
|
| 62 |
else:
|
| 63 |
with st.spinner("Loading props..."):
|