๐ฒ Betting odds with sportsdataverse-py
Welcome! In a few lines of Python you're about to pull live betting odds
from a whole market of sportsbooks โ moneylines, spreads, totals, player
props, scores, even point-in-time history. sportsdataverse.odds wraps
The Odds API v4 and hands you back tidy polars
DataFrames that are ready to model. ๐
If you've used the R package oddsapiR,
the toa_* names will feel right at home. Let's dive in!
๐งฐ The toolboxโ
Every function returns a tidy polars DataFrame by default โ pass
return_as_pandas=True for pandas, or return_parsed=False for the raw JSON.
Here's the whole kit (click any name for the full reference):
| Function | What it gives you | Quota |
|---|---|---|
toa_sports | Every in-season sport/league key (the sport= value) | ๐ free |
toa_sports_odds | Current odds for a sport โ one row per outcome | ๐ณ paid |
toa_event_odds | Odds for a single game, including player props | ๐ณ paid |
toa_event_markets | Which markets a game has on offer | ๐ free |
toa_sports_scores | Live + recently-completed scores | ๐ free |
toa_sports_events | Upcoming + live event list (grab event_ids here) | ๐ free |
toa_sports_participants | Teams / participants for a sport | ๐ free |
toa_sports_odds_history | Historical odds snapshot (paid plans) | ๐ณ paid |
toa_sports_events_history | Historical event snapshot | ๐ณ paid |
toa_event_odds_history | Historical single-game odds | ๐ณ paid |
toa_usage | Your remaining quota (reads cached headers) | ๐ free |
๐ Setupโ
pip install sportsdataverse
The Odds API needs a key โ grab a free one at
the-odds-api.com. Set it once as the
ODDS_API_KEY environment variable (the same name oddsapiR uses) or pass
api_key= to any call. The live cells below run only when a key is present,
so this page is happy either way. ๐
import os
import polars as pl
import sportsdataverse.odds as odds
HAS_KEY = bool(os.environ.get("ODDS_API_KEY"))
print("ODDS_API_KEY set:", HAS_KEY, "โ live cells will" + ("" if HAS_KEY else " NOT") + " run")
๐๏ธ What's on the board?โ
Start with toa_sports โ it lists every sport/league key,
and it's free (doesn't touch your quota). The key column is what you
pass as sport= everywhere else.
if HAS_KEY:
sports = odds.toa_sports(all_sports=True)
out = sports.select([c for c in ["key", "group", "title", "active"] if c in sports.columns]).head(12)
else:
out = "set ODDS_API_KEY to run: odds.toa_sports(all_sports=True)"
out
๐ฐ The main event: live oddsโ
toa_sports_odds is the workhorse. It returns long
format โ one row per event ร bookmaker ร market ร outcome โ which is
exactly the shape you want for filtering and modelling. Knobs:
regionsโ bookmaker regions:us,us2,uk,eu,au(comma-separate to mix).marketsโh2h(moneyline),spreads,totals,outrights, โฆ (comma-separated).odds_formatโamericanordecimal.bookmakersโ pin specific books (takes precedence overregions).
if HAS_KEY:
board = odds.toa_sports_odds(sport="americanfootball_nfl", regions="us", markets="h2h,spreads")
keep = ["home_team", "away_team", "bookmaker_key", "market_key", "outcome_name", "outcome_point", "outcome_price"]
out = board.select([c for c in keep if c in board.columns]).head(10)
else:
board = None
out = "set ODDS_API_KEY to run: odds.toa_sports_odds(sport='americanfootball_nfl', regions='us')"
out
๐ณ Cookbook: common odds tasksโ
Because everything is one tidy long frame, the fun stuff is just a few polars expressions away. Twelve recipes you'll reach for constantly โ every live cell is key-guarded, so the page renders fine with or without a key.
Recipe 1 โ Best available moneyline (line shopping ๐)โ
For each team, find the highest moneyline price across every book โ and which book is offering it. Sort by price descending, group, take the top.
if HAS_KEY and board is not None:
h2h = board.filter(pl.col("market_key") == "h2h")
best = (
h2h.sort("outcome_price", descending=True)
.group_by(["home_team", "away_team", "outcome_name"], maintain_order=True)
.agg(pl.first("outcome_price").alias("best_price"), pl.first("bookmaker_key").alias("best_book"))
)
out = best.head(10)
else:
out = "needs ODDS_API_KEY"
out
Recipe 2 โ Spreads & totals for a slate ๐โ
Ask for markets="spreads,totals" and the outcome_point column carries the
line (the spread number / the over-under total).
if HAS_KEY:
st = odds.toa_sports_odds(sport="americanfootball_nfl", regions="us", markets="spreads,totals")
out = (
st.filter(pl.col("bookmaker_key") == st["bookmaker_key"][0])
.select(["home_team", "away_team", "market_key", "outcome_name", "outcome_point", "outcome_price"])
.head(10)
if st.height else "no spreads/totals on the board right now"
)
else:
out = "needs ODDS_API_KEY"
out
Recipe 3 โ Just one book ๐ฏโ
Pin a single sportsbook with bookmakers=. Great for tracking your book's
line without paying for a whole region.
if HAS_KEY:
dk = odds.toa_sports_odds(sport="americanfootball_nfl", bookmakers="draftkings", markets="h2h")
out = dk.select(["home_team", "away_team", "outcome_name", "outcome_price"]).head() if dk.height else "no lines yet"
else:
out = "needs ODDS_API_KEY"
out
Recipe 4 โ Implied probability & the hold ๐งฎโ
American moneyline prices convert to implied win probability with a tiny formula. Add up both sides and the excess over 100% is the book's hold (the vig). Pure polars math on the frame you already pulled โ no extra API call.
if HAS_KEY and board is not None:
h2h = board.filter(pl.col("market_key") == "h2h")
devig = (
h2h.with_columns(
pl.when(pl.col("outcome_price") < 0)
.then(-pl.col("outcome_price") / (-pl.col("outcome_price") + 100))
.otherwise(100 / (pl.col("outcome_price") + 100))
.alias("implied_prob")
)
.group_by(["home_team", "away_team", "bookmaker_key"], maintain_order=True)
.agg(pl.sum("implied_prob").alias("market_total"))
.with_columns(((pl.col("market_total") - 1) * 100).round(2).alias("hold_pct"))
.sort("hold_pct")
)
out = devig.head(10)
else:
out = "needs ODDS_API_KEY"
out
Recipe 5 โ Find the biggest favorite on the board ๐ปโ
Sort the moneyline outcomes by price ascending โ the most negative number is the heaviest chalk on the slate. A classic "find the X" one-liner.
if HAS_KEY and board is not None:
faves = (
board.filter(pl.col("market_key") == "h2h")
.sort("outcome_price")
.select(["home_team", "away_team", "outcome_name", "outcome_price", "bookmaker_key"])
.head(5)
)
out = faves
else:
out = "needs ODDS_API_KEY"
out
Recipe 6 โ Consensus over/under per game ๐โ
Books disagree by a half-point here and there. Take the median total across every book to get a stable market consensus for each matchup.
if HAS_KEY:
tot = odds.toa_sports_odds(sport="americanfootball_nfl", regions="us", markets="totals")
if tot.height:
consensus = (
tot.filter(pl.col("outcome_name") == "Over")
.group_by(["home_team", "away_team"], maintain_order=True)
.agg(
pl.median("outcome_point").alias("consensus_total"),
pl.col("bookmaker_key").n_unique().alias("n_books"),
)
.sort("consensus_total", descending=True)
)
out = consensus.head(10)
else:
out = "no totals on the board right now"
else:
out = "needs ODDS_API_KEY"
out
Recipe 7 โ Just today's slate โฐโ
Narrow the pull to a time window with commence_time_from / commence_time_to
(ISO-8601, UTC). Here: only games kicking off in the next 24 hours.
from datetime import datetime, timedelta, timezone
if HAS_KEY:
now = datetime.now(timezone.utc)
fmt = "%Y-%m-%dT%H:%M:%SZ"
today = odds.toa_sports_odds(
sport="americanfootball_nfl",
regions="us",
markets="h2h",
commence_time_from=now.strftime(fmt),
commence_time_to=(now + timedelta(hours=24)).strftime(fmt),
)
out = (
today.select(["commence_time", "home_team", "away_team"]).unique(maintain_order=True).head(10)
if today.height else "nothing kicks off in the next 24h"
)
else:
out = "set ODDS_API_KEY to run the commence-time filter recipe"
out
Recipe 8 โ Player props for one game ๐ฏโ
Event-level markets (player props!) live on toa_event_odds.
Grab an event_id from toa_sports_events, then ask
for a prop market like player_pass_tds or player_anytime_td.
if HAS_KEY:
events = odds.toa_sports_events(sport="americanfootball_nfl", return_parsed=False)
if events:
eid = events[0]["id"]
props = odds.toa_event_odds(sport="americanfootball_nfl", event_id=eid, markets="player_pass_tds")
out = props.select([c for c in ["outcome_name", "outcome_description", "outcome_point", "outcome_price"]
if c in props.columns]).head()
else:
out = "no upcoming NFL events right now"
else:
out = "set ODDS_API_KEY to run the player-props recipe"
out
Recipe 9 โ Which markets does a game offer? ๐๏ธโ
Not sure which props are even available? toa_event_markets
lists every market on offer per book โ and it's free. Count them up to
see which sportsbook posts the deepest menu.
if HAS_KEY:
events = odds.toa_sports_events(sport="americanfootball_nfl", return_parsed=False)
if events:
eid = events[0]["id"]
mk = odds.toa_event_markets(sport="americanfootball_nfl", event_id=eid)
out = (
mk.group_by("bookmaker_key").agg(pl.col("market_key").n_unique().alias("n_markets"))
.sort("n_markets", descending=True).head(10)
if mk.height else "no markets posted for this event yet"
)
else:
out = "no upcoming NFL events right now"
else:
out = "set ODDS_API_KEY to run the event-markets recipe"
out
Recipe 10 โ Recent finals & margin of victory ๐โ
toa_sports_scores returns live + recently
completed games (days_from=1..3, free). Keep the completed ones and
show the final scoreline โ handy for grading bets after the fact.
if HAS_KEY:
sc = odds.toa_sports_scores(sport="americanfootball_nfl", days_from=3)
keep = [c for c in ["completed", "home_team", "away_team", "scores", "last_update"] if c in sc.columns]
if sc.height and "completed" in sc.columns:
out = sc.filter(pl.col("completed")).select(keep).head(10)
else:
out = sc.select(keep).head(10) if sc.height else "no recent scores right now"
else:
out = "set ODDS_API_KEY to run: odds.toa_sports_scores(sport='americanfootball_nfl', days_from=3)"
out
Recipe 11 โ Tour several leagues at once ๐โ
The sport= key is the only thing that changes between leagues, so one loop
counts the upcoming events across a handful of them. toa_sports_events is
free, so this sweep costs you nothing.
if HAS_KEY:
keys = ["americanfootball_nfl", "basketball_nba", "icehockey_nhl", "baseball_mlb"]
rows = []
for k in keys:
evs = odds.toa_sports_events(sport=k, return_parsed=False)
rows.append({"sport": k, "upcoming_events": len(evs) if isinstance(evs, list) else 0})
out = pl.DataFrame(rows).sort("upcoming_events", descending=True)
else:
out = "set ODDS_API_KEY to tour leagues with odds.toa_sports_events(sport=...)"
out
Recipe 12 โ Who's in the league? (participants ๐ฅ)โ
toa_sports_participants lists every team /
participant for a sport โ the lookup table you join odds against by name.
Also free.
if HAS_KEY:
parts = odds.toa_sports_participants(sport="americanfootball_nfl")
keep = [c for c in ["full_name", "id", "abbreviation"] if c in parts.columns]
out = parts.select(keep if keep else parts.columns).head(10) if parts.height else "no participants listed"
else:
out = "set ODDS_API_KEY to run: odds.toa_sports_participants(sport='americanfootball_nfl')"
out
โฝ Mind your quotaโ
Paid calls cost credits (every 10 bookmakers ร market โ 1 credit). After any
call, toa_usage reads the most recent
x-requests-remaining / x-requests-used headers without spending a
request โ handy to drop at the end of a script.
odds.toa_usage() if HAS_KEY else "set ODDS_API_KEY to track quota with odds.toa_usage()"
โณ Time travel: historical oddsโ
On a paid plan you can pull point-in-time snapshots โ perfect for closing
line value studies. Pass a date= ISO-8601 timestamp; the snapshot is
unwrapped to the same long format and every row is stamped with the snapshot
time.
| Function | Snapshot ofโฆ |
|---|---|
toa_sports_odds_history | a whole sport's odds at date |
toa_sports_events_history | the events at date |
toa_event_odds_history | one game's odds at date |
odds.toa_sports_odds_history(sport="americanfootball_nfl", date="2023-11-29T22:45:00Z")
๐ Where to nextโ
- Pass
return_as_pandas=Truefor a pandas frame, orreturn_parsed=Falsefor raw JSON. - Full reference: the Betting โ Odds section in the sidebar.
- R user? The same surface lives in oddsapiR.
Happy modelling โ may your closing line value be ever positive! ๐