Syntrex Claude Sonnet 4.6 commited on
Commit
7b92abe
·
1 Parent(s): 07e8342

Fix all odds paths: per-event endpoint + regions + cache bypass

Browse files

Upcoming 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 CHANGED
@@ -49,7 +49,7 @@ BOOK_KEY_MAP = {
49
  "williamhill_us": "Caesars",
50
  }
51
 
52
- _MAX_EVENTS = 20
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
- # Current provider path uses MLB sport key.
125
- # This may not return WBC prop markets even when books offer them.
126
- sport_key = "baseball_mlb"
127
 
128
- url = f"{ODDS_API_BASE}/{sport_key}/odds"
129
- params = {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- response = requests.get(url, params=params, timeout=30)
139
- response.raise_for_status()
140
- payload = response.json()
141
-
142
- rows: list[dict[str, Any]] = []
143
-
144
- for event in payload:
145
- event_away = _canon_team(event.get("away_team", ""))
146
- event_home = _canon_team(event.get("home_team", ""))
147
-
148
- if event_away != away_key or event_home != home_key:
149
- continue
150
 
151
- event_id = str(event.get("id", "") or "")
152
- commence_time = str(event.get("commence_time", "") or "")
 
 
153
 
154
- for bookmaker in event.get("bookmakers", []) or []:
155
- book_key = str(bookmaker.get("key", "") or "")
156
- book_name = BOOK_KEY_MAP.get(book_key, book_key)
157
 
158
- for market in bookmaker.get("markets", []) or []:
159
- market_key = str(market.get("key", "") or "")
160
- market_name = MARKET_NAME_MAP.get(market_key, market_key)
161
 
162
- for outcome in market.get("outcomes", []) or []:
163
- player_name_raw = str(
164
- outcome.get("description", "") or outcome.get("name", "") or ""
165
- ).strip()
166
- if not player_name_raw:
167
- continue
168
 
169
- price = outcome.get("price")
170
- if price is None:
171
- continue
 
 
 
172
 
173
- line = _safe_float(outcome.get("point"))
 
 
174
 
175
- rows.append(
176
- {
177
- "provider": self.provider_name,
178
- "event_id": event_id,
179
- "commence_time": commence_time,
180
- "away_team": event.get("away_team", ""),
181
- "home_team": event.get("home_team", ""),
182
- "sportsbook": book_name,
183
- "sportsbook_key": book_key,
184
- "market_key": market_key,
185
- "market": market_name,
186
- "player_name_raw": player_name_raw,
187
- "player_name": map_odds_name_to_model_name(player_name_raw),
188
- "odds_american": int(price),
189
- "line": line,
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("[upcoming_hr_props] total rows=%d", len(rows))
 
 
 
 
 
 
 
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)
visualization/props_page.py CHANGED
@@ -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; fall back to direct fetch
59
- if raw_props is not None and not raw_props.empty:
 
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..."):