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 onlyh2h = df[df['market'] =='h2h'].copy()# Pivot: game + team as rows, bookmakers as columnspivot = h2h.pivot_table( index=['game', 'outcome'], columns='bookmaker', values='price', aggfunc='first',)# Build a Plotly table with best odds highlighted in greenbooks = 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] iflen(row.dropna()) >1else best_price best_lines.append({'Game': game,'Team': outcome,'Best Book': best_book,'Best Odds': int(best_price) if pd.notna(best_price) elseNone,'2nd Best': int(second_best) if pd.notna(second_best) elseNone,'Edge': int(best_price - second_best) if pd.notna(best_price) and pd.notna(second_best) else0, })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 chartgames = 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 >=50else'#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.
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 onlypoints = props_df[(props_df['market'] =='player_points') & (props_df['side'] =='Over')].copy()ifnot 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)