#!pip install espn_api # install espn_api package if needed
from espn_api.basketball import League
league = League(league_id=727593540, year=2025)Expected Wins Analysis in Fantasy Basketball Using the ESPN API
My friends and I participate each year in a fantasy ESPN league. Invariably, there are always a few that complain of how unlucky they were in scheduling, and that there were teams that were worse that made finals/finished higher/didn’t come last instead.
There is (some) merit to these complaints. Because matchups are head-to-head, your record depends partly on who you played, not just how many points you scored. Each week 2 teams are matched and the team with the higher score wins. Therefore it is possible to have the 2nd highest score of the week, and lose to the highest scorer. Similarly, it is possible to have the 2nd lowest score of the week and still win because you are playing the lowest scorer that week.
While the Points For and Points Against metrics are often pointed to as a way of measuring how ‘unlucky’ you have been with the schedule, this doesn’t exactly reflect how good your team is. For example, if you were really good for the first half of the year, but then had a bunch of injuries, you may have won more games than someone who has the same amount of Points For but was just consistently bad.
Expected Wins
This led me to develop an Expected Wins metric. The Expected Wins considers the schedule to be random, and for each round awards the fraction of wins you would have had against each opponent in the league.
For example, in a 10-team league, there are 9 possible opponents. If you would have beaten 3 of those opponents, you are awarded 3/9 or ~.33 expected wins. If you would have beaten all teams because you had the highest score of the week, you are awarded 1.0 expected wins, while the lowest gets 0 expected wins.
This is written for points leagues; for categories you’d compute ‘expected category wins’ similarly, but ties are more frequent and need explicit handling.
Pulling your League
I pull league data using espn_api (Wendt 2025).
You start by pulling your league. To do this, you need the league_id. In order to do this, go to the ESPN fantasy website on a browser, go the league you want to analyse, and then check the URL. It should be something like https://fantasy.espn.com/basketball/team?leagueId=xxx, where xxx is your league ID. Also note that for this to work, the league needs to be publicly viewable. If it is private, you also need a SWID and ESPN_S2 value. I am using a random league (not mine) with a public ID for this post.
num_teams = len(league.teams)
print(f"There are {num_teams} teams in this league")There are 12 teams in this league
We now want to iterate through each team / round and collect results into a NumPy array. I print the number of games for each team to ensure they are the same.
#!pip install espn-api # install if needed
from espn_api.basketball import League
import numpy as np
league = League(league_id=727593540, year=2025)
teams = league.teams
num_teams = len(teams)
print(f"There are {num_teams} teams in this league")
# Use the minimum completed games across teams
num_weeks = min(t.wins + t.losses + t.ties for t in teams)
results = np.zeros((num_teams, num_weeks, 2), dtype=float)
for i, team in enumerate(teams):
played = team.wins + team.losses + team.ties
print(f"{team} has played {played} games.")
for w in range(num_weeks):
matchup = team.schedule[w]
if team == matchup.home_team:
team_score = matchup.home_final_score
opp_score = matchup.away_final_score
else:
team_score = matchup.away_final_score
opp_score = matchup.home_final_score
results[i, w, 0] = team_score
results[i, w, 1] = opp_scoreThere are 12 teams in this league
Team(Pete's Bucket Boys) has played 20 games.
Team(Boston Celtics) has played 20 games.
Team(Rachid's Rowdy Team) has played 20 games.
Team(Perfect ur Rob) has played 20 games.
Team(All Jams! no Peanut Butter! ) has played 20 games.
Team(Gustavo's Great Team) has played 20 games.
Team(BadrTheBest) has played 20 games.
Team(SAN ANTONIO SPURS) has played 20 games.
Team(Leon Rose On The Phone) has played 20 games.
Team(Msnsnsnsnn's Magnificent Team) has played 20 games.
Team(Trash League) has played 20 games.
Team(EA sports) has played 20 games.
Now we have the results, we can analyse using the Expected Wins metric I described earlier.
expected_wins = np.zeros(num_teams, dtype=float)
# Tie-safe expected wins:
# expected wins that week = (#strictly lower + 0.5*#equal) / (n-1)
for w in range(num_weeks):
week_scores = results[:, w, 0]
for i in range(num_teams):
s = week_scores[i]
lower = np.sum(week_scores < s)
equal = np.sum(week_scores == s) - 1 # exclude self
expected_wins[i] += (lower + 0.5 * equal) / (num_teams - 1)
table = [["Team", "Expected Wins", "Actual Wins", "Difference (Actual - Expected)"]]
for i, team in enumerate(league.teams):
team_name = str(team).replace("Team(", "").replace(")", "")
actual_wins = team.wins
diff = actual_wins - expected_wins[i]
table.append([team_name, round(expected_wins[i], 2), actual_wins, round(diff, 2)])
sorted_table = sorted(table[1:], key=lambda x: x[1], reverse=True)
sorted_table.insert(0, table[0])
#print(tb.tabulate(sorted_table, headers="firstrow"))| Team | Expected Wins | Actual Wins | Difference (Actual - Expected) |
|---|---|---|---|
| Leon Rose On The Phone | 16.82 | 19 | 2.18 |
| All Jams! no Peanut Butter! | 16.45 | 16 | -0.45 |
| EA sports | 15.27 | 14 | -1.27 |
| BadrTheBest | 12.64 | 13 | 0.36 |
| Rachid’s Rowdy Team | 11.27 | 12 | 0.73 |
| Perfect ur Rob | 11 | 8 | -3 |
| Msnsnsnsnn’s Magnificent Team | 10.82 | 12 | 1.18 |
| Gustavo’s Great Team | 10.73 | 13 | 2.27 |
| Pete’s Bucket Boys | 8.18 | 7 | -1.18 |
| Boston Celtics | 5 | 4 | -1 |
| SAN ANTONIO SPURS | 1.73 | 2 | 0.27 |
| Trash League | 0.09 | 0 | -0.09 |
And there you have it. Those with a positive difference had more expected wins than actual wins- they were ‘lucky’ in their scheduling, and vice versa with the negative differences- they were ‘unlucky’.
I hope this provides some help in settling the never-ending complaints over who has been hard done by!