2026_MLB_Model / data /provider_theoddsapi.py
Syntrex's picture
Odds tab: add pitcher strikeout alt lines with Standard/Alt Lines/Both view toggle
17be445
raw
history blame
22.8 kB
from __future__ import annotations
import concurrent.futures
import logging as _logging
from typing import Any
import pandas as pd
import requests
_diag_log = _logging.getLogger(__name__)
from config.settings import ODDS_API_KEY
from data.market_provider_base import MarketProviderBase
from data.odds_name_map import map_odds_name_to_model_name
ODDS_API_BASE = "https://api.the-odds-api.com/v4/sports"
# ---------------------------------------------------------------------------
# Provider strategy (Batch 14)
# Active v1: The Odds API → DraftKings, FanDuel, BetMGM, Caesars (williamhill_us)
# Sharp feed: Pinnacle — planned as separate PinnacleProvider class with its own
# API key; register it in live_prop_odds.py when ready
# Deferred: Bet365, Circa (unclear API availability on The Odds API)
# Enterprise: ENABLE_ENTERPRISE_PROVIDER flag in config/settings.py
# ---------------------------------------------------------------------------
SUPPORTED_BOOKS = {
"draftkings",
"fanduel",
"betmgm",
"williamhill_us", # Caesars
}
SUPPORTED_MARKETS = {
"batter_home_runs",
"batter_hits",
"batter_total_bases",
"pitcher_strikeouts",
"pitcher_strikeouts_alternate",
}
MARKET_NAME_MAP = {
"batter_home_runs": "hr",
"batter_hits": "hit",
"batter_total_bases": "tb",
"pitcher_strikeouts": "k",
"pitcher_strikeouts_alternate": "k",
}
BOOK_KEY_MAP = {
"draftkings": "DraftKings",
"fanduel": "FanDuel",
"betmgm": "BetMGM",
"williamhill_us": "Caesars",
}
_MAX_EVENTS = 15
_MAX_PARALLEL_ODDS_WORKERS = 8 # concurrent per-event HTTP calls
_TOTAL_ODDS_FETCH_TIMEOUT_S = 45 # wall-clock cap for all parallel fetches
TEAM_NAME_ALIASES = {
"usa": "united states",
"united states": "united states",
"japan": "japan",
"korea": "korea",
"south korea": "korea",
"chinese taipei": "chinese taipei",
"taiwan": "chinese taipei",
"czech republic": "czechia",
"czechia": "czechia",
"dominican republic": "dominican republic",
"puerto rico": "puerto rico",
"great britain": "great britain",
"netherlands": "netherlands",
"venezuela": "venezuela",
"mexico": "mexico",
"canada": "canada",
"colombia": "colombia",
"cuba": "cuba",
"panama": "panama",
"brazil": "brazil",
"italy": "italy",
"australia": "australia",
"china": "china",
"nicaragua": "nicaragua",
"israel": "israel",
}
def _canon_team(name: str) -> str:
text = str(name or "").strip().lower()
return TEAM_NAME_ALIASES.get(text, text)
def _safe_float(value: Any) -> float | None:
try:
if value is None:
return None
text = str(value).strip().lower()
if text in {"", "nan", "none"}:
return None
return float(value)
except Exception:
return None
def _fetch_event_odds(
event: dict,
books: list[str],
market_keys: list[str],
provider_name: str,
) -> tuple[list[dict[str, Any]], bool]:
"""
Fetch and parse odds for a single event.
Returns (rows, is_rate_limited).
Designed to be called from a thread pool.
"""
event_id = str(event.get("id", "") or "")
away_team = str(event.get("away_team", "") or "")
home_team = str(event.get("home_team", "") or "")
commence_time = str(event.get("commence_time", "") or "")
odds_url = f"{ODDS_API_BASE}/baseball_mlb/events/{event_id}/odds"
odds_params = {
"apiKey": ODDS_API_KEY,
"regions": "us",
"markets": ",".join(market_keys),
"bookmakers": ",".join(books),
"oddsFormat": "american",
"dateFormat": "iso",
}
_diag_log.info(
"[upcoming_hr_props] Step2 event_id=%s %s@%s",
event_id, away_team, home_team,
)
try:
r2 = requests.get(odds_url, params=odds_params, timeout=30)
_diag_log.warning(
"[upcoming_hr_props] Step2 HTTP %s | remaining=%s | event_id=%s %s@%s",
r2.status_code,
r2.headers.get("x-requests-remaining", "?"),
event_id, away_team, home_team,
)
r2.raise_for_status()
except (requests.HTTPError, requests.RequestException) as exc:
_is_429 = (
isinstance(exc, requests.HTTPError)
and exc.response is not None
and exc.response.status_code == 429
)
_diag_log.warning(
"[upcoming_hr_props] event %s@%s odds failed (429=%s): %s",
away_team, home_team, _is_429, exc,
)
return [], _is_429
event_data = r2.json()
bookmakers = (
event_data.get("bookmakers", []) if isinstance(event_data, dict) else []
)
rows: list[dict[str, Any]] = []
for bookmaker in bookmakers:
book_key = str(bookmaker.get("key", "") or "")
book_name = BOOK_KEY_MAP.get(book_key, book_key)
for market in bookmaker.get("markets", []) or []:
market_key = str(market.get("key", "") or "")
if market_key not in market_keys:
continue
market_name = MARKET_NAME_MAP.get(market_key, market_key)
for outcome in market.get("outcomes", []) or []:
player_name_raw = str(
outcome.get("description", "") or outcome.get("name", "") or ""
).strip()
if not player_name_raw:
continue
price = outcome.get("price")
if price is None:
continue
rows.append(
{
"provider": provider_name,
"event_id": event_id,
"commence_time": commence_time,
"away_team": away_team,
"home_team": home_team,
"sportsbook": book_name,
"sportsbook_key": book_key,
"market_key": market_key,
"market": market_name,
"player_name_raw": player_name_raw,
"selection_label": str(outcome.get("name", "") or "").strip(),
"player_name": map_odds_name_to_model_name(player_name_raw),
"odds_american": int(price),
"line": _safe_float(outcome.get("point")),
}
)
_diag_log.warning(
"[upcoming_hr_props] %s@%s rows=%d", away_team, home_team, len(rows),
)
return rows, False
class TheOddsAPIProvider(MarketProviderBase):
provider_name = "theoddsapi"
def fetch_live_prop_odds(
self,
game_context: dict[str, Any],
sportsbooks: list[str] | None = None,
markets: list[str] | None = None,
) -> pd.DataFrame:
if not ODDS_API_KEY:
return pd.DataFrame()
sportsbooks = sportsbooks or ["draftkings", "fanduel", "betmgm"]
markets = markets or ["batter_home_runs", "batter_hits", "batter_total_bases"]
books = [b for b in sportsbooks if b in SUPPORTED_BOOKS]
mkts = [m for m in markets if m in SUPPORTED_MARKETS]
if not books or not mkts:
return pd.DataFrame()
away_key = _canon_team(game_context.get("away_team", ""))
home_key = _canon_team(game_context.get("home_team", ""))
requested_books = sportsbooks or ["draftkings", "fanduel", "betmgm"]
books = [b for b in requested_books if b in SUPPORTED_BOOKS]
if not books:
_diag_log.warning(
"[upcoming_hr_props] no supported requested books from %s",
requested_books,
)
return pd.DataFrame()
from datetime import datetime, timezone, timedelta
now = datetime.now(timezone.utc)
events_url = f"{ODDS_API_BASE}/baseball_mlb/events"
events_params = {
"apiKey": ODDS_API_KEY,
"dateFormat": "iso",
"commenceTimeFrom": (now - timedelta(hours=6)).strftime("%Y-%m-%dT%H:%M:%SZ"),
"commenceTimeTo": (now + timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%SZ"),
}
try:
r1 = requests.get(events_url, params=events_params, timeout=30)
r1.raise_for_status()
except requests.HTTPError as exc:
body = (exc.response.text[:300] if exc.response is not None else "")
raise RuntimeError(
f"Odds API events list HTTP {exc.response.status_code}: {body}"
) from exc
except requests.RequestException as exc:
raise RuntimeError(f"Odds API events network error: {exc}") from exc
events = r1.json()
# Find the event matching this game's teams
event_id = None
away_team_orig = ""
home_team_orig = ""
commence_time = ""
for ev in events:
ev_away = _canon_team(ev.get("away_team", ""))
ev_home = _canon_team(ev.get("home_team", ""))
if ev_away == away_key and ev_home == home_key:
event_id = str(ev.get("id", "") or "")
away_team_orig = str(ev.get("away_team", "") or "")
home_team_orig = str(ev.get("home_team", "") or "")
commence_time = str(ev.get("commence_time", "") or "")
break
if not event_id:
_diag_log.info(
"[live_prop_odds] no matching event for %s@%s in %d events",
away_key, home_key, len(events),
)
return pd.DataFrame()
odds_url = f"{ODDS_API_BASE}/baseball_mlb/events/{event_id}/odds"
odds_params = {
"apiKey": ODDS_API_KEY,
"regions": "us",
"markets": ",".join(mkts),
"bookmakers": ",".join(books),
"oddsFormat": "american",
"dateFormat": "iso",
}
try:
r2 = requests.get(odds_url, params=odds_params, timeout=30)
r2.raise_for_status()
except requests.HTTPError as exc:
body = (exc.response.text[:300] if exc.response is not None else "")
raise RuntimeError(
f"Odds API event odds HTTP {exc.response.status_code}: {body}"
) from exc
except requests.RequestException as exc:
raise RuntimeError(f"Odds API event odds network error: {exc}") from exc
event_data = r2.json()
bookmakers = (
event_data.get("bookmakers", []) if isinstance(event_data, dict) else []
)
rows: list[dict[str, Any]] = []
for bookmaker in bookmakers:
book_key = str(bookmaker.get("key", "") or "")
book_name = BOOK_KEY_MAP.get(book_key, book_key)
for market in bookmaker.get("markets", []) or []:
market_key = str(market.get("key", "") or "")
market_name = MARKET_NAME_MAP.get(market_key, market_key)
for outcome in market.get("outcomes", []) or []:
player_name_raw = str(
outcome.get("description", "") or outcome.get("name", "") or ""
).strip()
if not player_name_raw:
continue
price = outcome.get("price")
if price is None:
continue
rows.append(
{
"provider": self.provider_name,
"event_id": event_id,
"commence_time": commence_time,
"away_team": away_team_orig,
"home_team": home_team_orig,
"sportsbook": book_name,
"sportsbook_key": book_key,
"market_key": market_key,
"market": market_name,
"player_name_raw": player_name_raw,
"selection_label": str(outcome.get("name", "") or "").strip(),
"player_name": map_odds_name_to_model_name(player_name_raw),
"odds_american": int(price),
"line": _safe_float(outcome.get("point")),
}
)
return pd.DataFrame(rows)
def fetch_all_upcoming_hr_props(
self,
sportsbooks: list[str] | None = None,
markets: list[str] | None = None,
) -> pd.DataFrame:
"""
Fetch HR props for ALL upcoming MLB events in a single API call.
Unlike fetch_live_prop_odds(), this applies no game-level team filter —
every event in the payload is included.
Upcoming supported props for all upcoming MLB events.
"""
if not ODDS_API_KEY:
_diag_log.warning("[upcoming_hr_props] ODDS_API_KEY is empty — aborting")
return pd.DataFrame()
requested_markets = markets or ["batter_home_runs"]
market_keys = [m for m in requested_markets if m in SUPPORTED_MARKETS]
if not market_keys:
_diag_log.warning(
"[upcoming_hr_props] no supported requested markets from %s",
requested_markets,
)
return pd.DataFrame()
from datetime import datetime, timezone, timedelta
now = datetime.now(timezone.utc)
events_url = f"{ODDS_API_BASE}/baseball_mlb/events"
events_params = {
"apiKey": ODDS_API_KEY,
"dateFormat": "iso",
"commenceTimeFrom": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
"commenceTimeTo": (now + timedelta(days=7)).strftime("%Y-%m-%dT%H:%M:%SZ"),
}
_diag_log.info(
"[upcoming_hr_props] Step1 GET %s params=%s",
events_url,
{k: (v if k != "apiKey" else v[:6] + "...") for k, v in events_params.items()},
)
try:
r1 = requests.get(events_url, params=events_params, timeout=30)
_diag_log.warning(
"[upcoming_hr_props] events HTTP %s | remaining=%s",
r1.status_code,
r1.headers.get("x-requests-remaining", "?"),
)
r1.raise_for_status()
except requests.HTTPError as exc:
body = (exc.response.text[:300] if exc.response is not None else "")
raise RuntimeError(
f"Odds API events list HTTP {exc.response.status_code}: {body}"
) from exc
except requests.RequestException as exc:
raise RuntimeError(f"Odds API events network error: {exc}") from exc
events = r1.json()
_diag_log.warning(
"[upcoming_hr_props] events found=%d (cap=%d)", len(events), _MAX_EVENTS
)
events = events[:_MAX_EVENTS]
requested_books = sportsbooks or ["draftkings", "fanduel", "betmgm"]
books = [b for b in requested_books if b in SUPPORTED_BOOKS]
if not books:
_diag_log.warning(
"[upcoming_hr_props] no supported requested books from %s",
requested_books,
)
return pd.DataFrame()
# Deduplicate events
seen_ids: set[str] = set()
valid_events: list[dict] = []
for event in events:
event_id = str(event.get("id", "") or "")
if event_id and event_id not in seen_ids:
seen_ids.add(event_id)
valid_events.append(event)
_diag_log.warning(
"[upcoming_hr_props] fetching odds for %d events in parallel (max_workers=%d, timeout=%ds)",
len(valid_events), _MAX_PARALLEL_ODDS_WORKERS, _TOTAL_ODDS_FETCH_TIMEOUT_S,
)
rows: list[dict[str, Any]] = []
_events_attempted = len(valid_events)
_events_rate_limited = 0
_events_timed_out = 0
with concurrent.futures.ThreadPoolExecutor(
max_workers=_MAX_PARALLEL_ODDS_WORKERS
) as executor:
future_to_event = {
executor.submit(
_fetch_event_odds, event, books, market_keys, self.provider_name
): event
for event in valid_events
}
done, not_done = concurrent.futures.wait(
future_to_event,
timeout=_TOTAL_ODDS_FETCH_TIMEOUT_S,
)
for future in not_done:
future.cancel()
ev = future_to_event[future]
_diag_log.warning(
"[upcoming_hr_props] event %s@%s timed out after %ds",
ev.get("away_team", "?"), ev.get("home_team", "?"),
_TOTAL_ODDS_FETCH_TIMEOUT_S,
)
_events_timed_out += 1
for future in done:
try:
event_rows, is_429 = future.result()
rows.extend(event_rows)
if is_429:
_events_rate_limited += 1
except Exception as exc:
ev = future_to_event[future]
_diag_log.warning(
"[upcoming_hr_props] event %s@%s raised: %s",
ev.get("away_team", "?"), ev.get("home_team", "?"), exc,
)
_diag_log.warning(
"[upcoming_hr_props] SUMMARY books=%s markets=%s events_returned=%d events_attempted=%d "
"events_rate_limited=%d events_timed_out=%d total_rows=%d",
books,
market_keys,
len(events),
_events_attempted,
_events_rate_limited,
_events_timed_out,
len(rows),
)
return pd.DataFrame(rows)
def fetch_upcoming_market_coverage_probe(
self,
sportsbooks: list[str] | None = None,
markets: list[str] | None = None,
max_events: int = 5,
) -> pd.DataFrame:
if not ODDS_API_KEY:
return pd.DataFrame()
requested_books = sportsbooks or ["draftkings", "fanduel", "betmgm", "williamhill_us"]
books = [b for b in requested_books if b in SUPPORTED_BOOKS]
probe_markets = markets or [
"batter_home_runs",
"batter_hits",
"pitcher_strikeouts",
]
probe_markets = [m for m in probe_markets if m]
if not books or not probe_markets:
return pd.DataFrame()
from datetime import datetime, timezone, timedelta
now = datetime.now(timezone.utc)
events_url = f"{ODDS_API_BASE}/baseball_mlb/events"
events_params = {
"apiKey": ODDS_API_KEY,
"dateFormat": "iso",
"commenceTimeFrom": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
"commenceTimeTo": (now + timedelta(days=7)).strftime("%Y-%m-%dT%H:%M:%SZ"),
}
try:
r1 = requests.get(events_url, params=events_params, timeout=30)
r1.raise_for_status()
events = r1.json()[: max(1, int(max_events))]
except Exception as exc:
_diag_log.warning("[coverage_probe] events fetch failed: %s", exc)
return pd.DataFrame()
rows: list[dict[str, Any]] = []
for event in events:
event_id = str(event.get("id", "") or "")
away_team = str(event.get("away_team", "") or "")
home_team = str(event.get("home_team", "") or "")
commence_time = str(event.get("commence_time", "") or "")
if not event_id:
continue
for market_key in probe_markets:
for book_key in books:
odds_url = f"{ODDS_API_BASE}/baseball_mlb/events/{event_id}/odds"
odds_params = {
"apiKey": ODDS_API_KEY,
"regions": "us",
"markets": market_key,
"bookmakers": book_key,
"oddsFormat": "american",
"dateFormat": "iso",
}
bookmaker_count = 0
outcome_count = 0
response_status = None
error_text = ""
returned_books: list[str] = []
try:
r2 = requests.get(odds_url, params=odds_params, timeout=30)
response_status = r2.status_code
r2.raise_for_status()
event_data = r2.json()
bookmakers = (
event_data.get("bookmakers", [])
if isinstance(event_data, dict)
else []
)
bookmaker_count = len(bookmakers)
returned_books = [
str(bookmaker.get("key", "") or "")
for bookmaker in bookmakers
]
outcome_count = sum(
len(market.get("outcomes", []) or [])
for bookmaker in bookmakers
for market in bookmaker.get("markets", []) or []
)
except requests.HTTPError as exc:
response_status = exc.response.status_code if exc.response is not None else None
error_text = f"http_{response_status}"
except requests.RequestException as exc:
error_text = str(exc)
except Exception as exc:
error_text = str(exc)
rows.append(
{
"provider": self.provider_name,
"event_id": event_id,
"away_team": away_team,
"home_team": home_team,
"commence_time": commence_time,
"sportsbook_key": book_key,
"sportsbook": BOOK_KEY_MAP.get(book_key, book_key),
"market_key": market_key,
"bookmakers_returned": bookmaker_count,
"outcomes_returned": outcome_count,
"returned_books": "|".join(returned_books),
"has_data": bookmaker_count > 0 and outcome_count > 0,
"response_status": response_status,
"error": error_text,
}
)
return pd.DataFrame(rows)