Analyzing Real-Time Sports Odds with Python

Python
Sports Betting
Data Science
APIs
Author

Ben Ballard

Published

February 18, 2026

In Part 1: Unlocking Sports Betting with Python, we pulled live odds from The Odds API and compared bookmaker lines in a Jupyter notebook.

In this tutorial, we’re going deeper. We will:

We’ll do all of this in a standard Jupyter notebook/Python script. Let’s dig in.

I’ve pre-loaded the necessary libraries and the helper functions from Part 1. Now, let’s get to the good stuff: Fetching the Live Odds.

Fetching Live Odds

Let’s pull today’s NBA odds. Same endpoint as Part 1 — we’re just requesting all three market types at once: moneyline (h2h), spreads, and totals.

SPORT = 'basketball_nba'

url = f'https://api.the-odds-api.com/v4/sports/{SPORT}/odds/'
params = {
    'apiKey': API_KEY,
    'regions': 'us',
    'markets': 'h2h,spreads,totals',
    'oddsFormat': 'american',
}

resp = requests.get(url, params=params)
resp.raise_for_status()
events = resp.json()

print(f'Got {len(events)} upcoming games')
print(f'Quota used: {resp.headers.get("x-requests-used", "?")} | Remaining: {resp.headers.get("x-requests-remaining", "?")}')
Got 17 upcoming games
Quota used: 203 | Remaining: 297
df = flatten_odds(events)
print(f'{len(df)} rows across {df["game"].nunique()} games and {df["bookmaker"].nunique()} bookmakers')
df.head()
530 rows across 17 games and 9 bookmakers
game home_team away_team commence_time bookmaker market outcome price point
0 Houston Rockets @ Charlotte Hornets Charlotte Hornets Houston Rockets 2026-02-20T00:11:00Z FanDuel h2h Charlotte Hornets 154 NaN
1 Houston Rockets @ Charlotte Hornets Charlotte Hornets Houston Rockets 2026-02-20T00:11:00Z FanDuel h2h Houston Rockets -200 NaN
2 Houston Rockets @ Charlotte Hornets Charlotte Hornets Houston Rockets 2026-02-20T00:11:00Z FanDuel spreads Charlotte Hornets 108 2.5
3 Houston Rockets @ Charlotte Hornets Charlotte Hornets Houston Rockets 2026-02-20T00:11:00Z FanDuel spreads Houston Rockets -144 -2.5
4 Houston Rockets @ Charlotte Hornets Charlotte Hornets Houston Rockets 2026-02-20T00:11:00Z FanDuel totals Over -102 219.5

Section 1: Live Odds Table

First, let’s look at the moneyline odds. We’ll pivot the data so each bookmaker gets its own column, then highlight the best odds in green using Pandas styling.

# Filter to moneyline odds only
h2h = df[df['market'] == 'h2h'].copy()

# Pivot: game + team as rows, bookmakers as columns
pivot = h2h.pivot_table(
    index=['game', 'outcome'],
    columns='bookmaker',
    values='price',
    aggfunc='first',
)

# Build a Plotly table with best odds highlighted in green
books = pivot.columns.tolist()
row_labels = [f"{game}{team}" for game, team in pivot.index]

cell_values = []
cell_colors = []
for col in books:
    col_vals, col_colors = [], []
    for (game, team), row in pivot.iterrows():
        val = row[col]
        if pd.notna(val):
            col_vals.append(f"{val:.0f}")
            col_colors.append('#2ecc71' if val == row.max() else 'white')
        else:
            col_vals.append('-')
            col_colors.append('white')
    cell_values.append(col_vals)
    cell_colors.append(col_colors)

fig = go.Figure(data=[go.Table(
    header=dict(
        values=['Game / Team'] + books,
        fill_color='#34495e',
        font=dict(color='white', size=12),
        align='left',
    ),
    cells=dict(
        values=[row_labels] + cell_values,
        fill_color=[['#f8f9fa'] * len(row_labels)] + cell_colors,
        align='left',
        font=dict(size=11),
    )
)])

fig.update_layout(
    height=max(300, len(row_labels) * 35 + 80),
    margin=dict(l=0, r=0, t=20, b=0),
)
fig.show()

See how some bookmakers offer slightly better odds? Those differences matter. Let’s make it even easier to spot the best lines.

Section 2: Best Line Finder

This one’s my favorite. For each team in each game, we find which bookmaker has the best moneyline and how much of an “edge” it has over the second-best option.

best_lines = []

for (game, outcome), row in pivot.iterrows():
    best_book = row.idxmax()
    best_price = row.max()
    second_best = row.nlargest(2).iloc[-1] if len(row.dropna()) > 1 else best_price
    
    best_lines.append({
        'Game': game,
        'Team': outcome,
        'Best Book': best_book,
        'Best Odds': int(best_price) if pd.notna(best_price) else None,
        '2nd Best': int(second_best) if pd.notna(second_best) else None,
        'Edge': int(best_price - second_best) if pd.notna(best_price) and pd.notna(second_best) else 0,
    })

best_df = pd.DataFrame(best_lines)
best_df.sort_values('Edge', ascending=False)
Game Team Best Book Best Odds 2nd Best Edge
0 Atlanta Hawks @ Philadelphia 76ers Atlanta Hawks MyBookie.ag -143 -200 57
8 Dallas Mavericks @ Minnesota Timberwolves Dallas Mavericks DraftKings 525 480 45
1 Atlanta Hawks @ Philadelphia 76ers Philadelphia 76ers Bovada 185 164 21
9 Dallas Mavericks @ Minnesota Timberwolves Minnesota Timberwolves FanDuel -650 -670 20
19 Indiana Pacers @ Washington Wizards Washington Wizards MyBookie.ag -333 -345 12
28 Phoenix Suns @ San Antonio Spurs Phoenix Suns BetRivers 250 240 10
15 Detroit Pistons @ New York Knicks New York Knicks MyBookie.ag -125 -135 10
33 Utah Jazz @ Memphis Grizzlies Utah Jazz DraftKings 136 130 6
32 Utah Jazz @ Memphis Grizzlies Memphis Grizzlies FanDuel -154 -159 5
26 Orlando Magic @ Sacramento Kings Orlando Magic LowVig.ag -345 -350 5
3 Boston Celtics @ Golden State Warriors Golden State Warriors FanDuel 200 195 5
7 Cleveland Cavaliers @ Charlotte Hornets Cleveland Cavaliers FanDuel -188 -192 4
6 Cleveland Cavaliers @ Charlotte Hornets Charlotte Hornets FanDuel 158 154 4
16 Houston Rockets @ Charlotte Hornets Charlotte Hornets FanDuel 154 150 4
25 Milwaukee Bucks @ New Orleans Pelicans New Orleans Pelicans FanDuel -152 -155 3
22 Miami Heat @ Atlanta Hawks Atlanta Hawks FanDuel 130 127 3
29 Phoenix Suns @ San Antonio Spurs San Antonio Spurs BetUS -280 -282 2
27 Orlando Magic @ Sacramento Kings Sacramento Kings BetMGM 290 288 2
24 Milwaukee Bucks @ New Orleans Pelicans Milwaukee Bucks BetRivers 132 130 2
23 Miami Heat @ Atlanta Hawks Miami Heat FanDuel -154 -156 2
17 Houston Rockets @ Charlotte Hornets Houston Rockets DraftKings -188 -190 2
14 Detroit Pistons @ New York Knicks Detroit Pistons BetMGM 115 114 1
2 Boston Celtics @ Golden State Warriors Boston Celtics LowVig.ag -219 -220 1
31 Toronto Raptors @ Chicago Bulls Toronto Raptors LowVig.ag -214 -215 1
20 Los Angeles Clippers @ Los Angeles Lakers Los Angeles Clippers FanDuel 235 235 0
18 Indiana Pacers @ Washington Wizards Indiana Pacers Bovada 280 280 0
4 Brooklyn Nets @ Cleveland Cavaliers Brooklyn Nets BetMGM 3300 3300 0
5 Brooklyn Nets @ Cleveland Cavaliers Cleveland Cavaliers BetMGM -10000 -10000 0
13 Denver Nuggets @ Portland Trail Blazers Portland Trail Blazers FanDuel 130 130 0
12 Denver Nuggets @ Portland Trail Blazers Denver Nuggets FanDuel -154 -154 0
11 Denver Nuggets @ Los Angeles Clippers Los Angeles Clippers BetRivers 155 155 0
30 Toronto Raptors @ Chicago Bulls Chicago Bulls BetMGM 220 220 0
10 Denver Nuggets @ Los Angeles Clippers Denver Nuggets BetOnline.ag -175 -175 0
21 Los Angeles Clippers @ Los Angeles Lakers Los Angeles Lakers FanDuel -290 -290 0

The “Edge” column shows the difference in odds between the best and second-best bookmaker. Higher edge = more value shopping around. In the dashboard, this becomes a sortable table so you can instantly spot the biggest disagreements.

Section 3: Implied Probability Chart

American odds (like -150 or +130) are hard to read at a glance. Let’s convert everything to implied probabilities and visualize them. This tells us the “true” win probability the bookmaker is assigning to each team.

# Average odds across bookmakers for each team (consensus line)
consensus = h2h.groupby(['game', 'outcome'])['price'].mean().reset_index()
consensus['implied_prob'] = consensus['price'].apply(american_to_implied_prob)
consensus['prob_pct'] = (consensus['implied_prob'] * 100).round(1)

consensus[['game', 'outcome', 'price', 'prob_pct']].head()
game outcome price prob_pct
0 Atlanta Hawks @ Philadelphia 76ers Atlanta Hawks -201.333333 66.8
1 Atlanta Hawks @ Philadelphia 76ers Philadelphia 76ers 153.000000 39.5
2 Boston Celtics @ Golden State Warriors Boston Celtics -231.888889 69.9
3 Boston Celtics @ Golden State Warriors Golden State Warriors 191.444444 34.3
4 Brooklyn Nets @ Cleveland Cavaliers Brooklyn Nets 3300.000000 2.9
# Build the stacked bar chart
games = consensus['game'].unique()

fig = go.Figure()

for game in games:
    game_data = consensus[consensus['game'] == game]
    for _, row in game_data.iterrows():
        prob = row['prob_pct']
        fig.add_trace(go.Bar(
            y=[game],
            x=[prob],
            name=row['outcome'],
            orientation='h',
            text=f"{row['outcome']}: {prob:.1f}%",
            textposition='inside',
            showlegend=False,
            marker_color='#2ecc71' if prob >= 50 else '#e74c3c',
        ))

fig.update_layout(
    barmode='stack',
    xaxis_title='Implied Probability (%)',
    yaxis_title='',
    height=max(400, len(games) * 60),
    title='Implied Win Probabilities (Consensus Odds)',
)

fig.show()

Green = favorite, red = underdog. You can hover over each bar (if running locally) to see the exact probability.

Section 4: Player Props Explorer

This is where it gets really fun. We’ll pick a game, pull player props (points, rebounds, assists), and see where bookmakers disagree on the lines. Bigger disagreements = potential value bets.

Let’s grab the first game and see what props are available.

# Pick the first game
event_id = events[0]['id']
game_name = f"{events[0]['away_team']} @ {events[0]['home_team']}"
print(f'Fetching props for: {game_name}')

props_url = f'https://api.the-odds-api.com/v4/sports/{SPORT}/events/{event_id}/odds/'
props_params = {
    'apiKey': API_KEY,
    'regions': 'us',
    'markets': 'player_points,player_rebounds,player_assists',
    'oddsFormat': 'american',
}

props_resp = requests.get(props_url, params=props_params)
props_resp.raise_for_status()
props_data = props_resp.json()

print(f'Quota used: {props_resp.headers.get("x-requests-used", "?")} | Remaining: {props_resp.headers.get("x-requests-remaining", "?")}')
Fetching props for: Houston Rockets @ Charlotte Hornets
Quota used: 206 | Remaining: 294
props_df = flatten_props(props_data)
print(f'{len(props_df)} prop lines across {props_df["player"].nunique()} players')
props_df.head()
416 prop lines across 15 players
bookmaker market player side line price
0 FanDuel player_assists Kevin Durant Over 5.5 136
1 FanDuel player_assists Kevin Durant Under 5.5 -182
2 FanDuel player_assists Brandon Miller Over 4.5 -132
3 FanDuel player_assists Brandon Miller Under 4.5 100
4 FanDuel player_assists Amen Thompson Over 3.5 -110
# Scatter plot: points props, Over side only
points = props_df[(props_df['market'] == 'player_points') & (props_df['side'] == 'Over')].copy()

if not points.empty:
    fig = px.scatter(
        points,
        x='line',
        y='price',
        color='bookmaker',
        hover_data=['player', 'bookmaker', 'line', 'price'],
        text='player',
        title=f'Points Props — {game_name}',
    )

    fig.update_traces(textposition='top center', textfont_size=9)
    fig.update_layout(
        xaxis_title='Points Line',
        yaxis_title='Odds (American)',
        height=600,
    )
    fig.show()
else:
    print('No points props available for this game yet.')

When you see the same player at the same line but with different odds across bookmakers — that’s where line shopping pays off. The scatter plot makes it visual: clusters of dots at the same x-position (line) with spread along the y-axis (odds) means bookmakers disagree.

What’s Next?

We’ve now built a script that:

  • Pulls live odds
  • Finds the best lines
  • Calculates win probabilities
  • Scans for player prop disparities

But there’s one problem: Running a script manually every time you want to check odds is annoying. In the next article, we’re going to take this code and wrap it into a Streamlit Dashboard.

We’ll build a live web app that: - Runs 24/7 - Auto-refreshes every 5 minutes - Visualizes this data in a clean, interactive UI - Can be deployed to the web (so you can check it from your phone)

Stay tuned for the next post.