SportsDataTutorials

Same Question, Five Sports: Quantifying Home Advantage Across Leagues

Part 5 of 5 in Hockey & Cross-League Projects · course bundle (code + data)

FoundationsAdvancedPython~20 min read

What you'll build

One chart comparing home-win rate across MLB, NBA, soccer, the NFL, and the NHL, counted two ways, plus 27 NFL seasons tested against coin-flip noise.

One chart comparing home-win rate across MLB, NBA, soccer, the NFL, and the NHL, counted two ways, plus 27 NFL seasons tested against coin-flip noise.
Data: MLB Stats API (live) + NHL API (cached) + bundled nflverse, ESPN Premier League and NBA results, retrieved MLB read October 2026; NHL standings cached June 2026; bundled files June-August 2026

This is the capstone, and it asks the oldest argument in any sports bar: how big is home advantage, really? We answer it across five leagues at once, MLB, the NBA, the Premier League, the NFL and the NHL, with one script and one chart. Count it the obvious way, the share of games the home team wins, and the Premier League comes last at 42.6%, as if playing at home were a handicap. Count it fairly and the same season puts the Premier League first, at 58.7% of the matches somebody won. The rest of this page is about why those two numbers disagree, how much any one season's number can be trusted, and what 27 NFL seasons say about the difference between noise and a real change.

What makes this a capstone isn't any one technique; it's that you reuse the data-access patterns the site teaches, side by side: a live API read through a polite session, an API response cached on disk after its first pull, and three bundled CSVs. It draws together the Statcast leaderboard, EPA explained by building it, the NHL shot-location plot and the caching habit from how to read API documentation. The statistics in the second half lean on confidence intervals and statistical power.

  1. One question, five data sources

    Give each league its own small function that returns the same shape: home wins, home losses, draws and a season label. Once every source conforms to that contract, the rest of the program treats all five the same, even though each league arrives a different way.

    python
    import json
    import math
    import os
    
    import matplotlib.pyplot as plt
    import numpy as np
    import pandas as pd
    
    import sdt_common as sdt
    
    sdt.init("quantifying-home-advantage-across-five-leagues")
    HERE = os.path.dirname(os.path.abspath(__file__))
    session = sdt.polite_session()  # retries 429/5xx with backoff
    session.headers["User-Agent"] = "sdt-home-advantage-tutorial/1.0 (learning script; python-requests)"

    polite_session() is the site's requests session that retries the flaky status codes (429, 500, 502, 503 and 504) with exponential backoff. Its default User-Agent is a browser string; this script replaces it with one that says what the script is. A server owner reading their logs should be able to tell a learning script from a person, and a source that wants to refuse automated readers can then do so.

  2. MLB: home records straight from the standings

    The MLB Stats API's standings carry every team's record split several ways under splitRecords, so we never scan individual games. We sum the "home" split across both leagues (leagueId 103 and 104) for the 2025 regular season, and collect the names of every split type while we're there.

    python
    def mlb_home():  # MLB Stats API standings: every team's home W-L sits in splitRecords
        r = session.get("https://statsapi.mlb.com/api/v1/standings",
                        params={"leagueId": "103,104", "season": "2025",
                                "standingsTypes": "regularSeason"}, timeout=30).json()
        wins = losses = 0
        split_types = set()
        for grp in r["records"]:
            for tr in grp["teamRecords"]:
                for s in tr["records"]["splitRecords"]:
                    split_types.add(s["type"])
                    if s["type"] == "home":
                        wins += s["wins"]
                        losses += s["losses"]
        return {"wins": wins, "losses": losses, "draws": 0, "season": "2025",
                "splits": sorted(split_types)}
    
    mlb = mlb_home()
    with sdt.snippet("mlb"):
        print("splitRecords types:", ", ".join(mlb["splits"]))
        print(f"MLB {mlb['season']} home record: {mlb['wins']}-{mlb['losses']}")
    MLB 2025 standings, home split
    splitRecords types: away, day, extraInning, grass, home, lastTen, left, leftAway, leftHome, night, oneRun, right, rightAway, rightHome, turf, winners
    MLB 2025 home record: 1319-1111

    Sixteen split types come back for each team. The loop reads any of them by changing one string, "oneRun" or "extraInning" for example, which is why it's worth printing the list once before you trust a key. MLB has no ties in its standings, so 1,319 home wins and 1,111 home losses are all 2,430 games. As a cross-check we counted the 2025 game logs from Retrosheet the same way and got the same 1,319-1,111.

  3. NHL: a cached standings response, labelled by the data

    The NHL's standings carry home wins, losses and overtime losses for every team. We read the response once and keep it on disk, the pattern from tutorial 5, so reruns don't touch the server at all.

    python
    def nhl_home():  # NHL standings, read once and cached (the tutorial 05 pattern)
        cache = os.path.join(sdt.cache_dir("nhl"), "standings_now.json")
        if os.path.exists(cache):
            with open(cache, encoding="utf-8") as f:
                payload = json.load(f)
        else:
            payload = session.get("https://api-web.nhle.com/v1/standings/now", timeout=30).json()
            with open(cache, "w", encoding="utf-8") as f:
                json.dump(payload, f)
        teams = payload["standings"]
        sid = str(teams[0]["seasonId"])                     # e.g. "20252026"
        total = lambda key: sum(t[key] for t in teams)     # noqa: E731
        return {"wins": total("homeWins"),
                "losses": total("homeLosses") + total("homeOtLosses"),  # an OT loss is a loss
                "draws": 0, "season": f"{sid[:4]}-{sid[6:]}",
                "date": teams[0]["date"], "games_played": sorted({t["gamesPlayed"] for t in teams}),
                "ot_losses": total("homeOtLosses"),
                "home_points": total("homePoints"), "road_points": total("roadPoints")}
    
    nhl = nhl_home()
    with sdt.snippet("nhl"):
        print(f"NHL season {nhl['season']}, standings dated {nhl['date']}, games played {nhl['games_played']}")
        print(f"home record: {nhl['wins']} wins, {nhl['losses']} losses "
              f"({nhl['ot_losses']} of them in overtime or a shootout)")
    NHL standings, home record
    NHL season 2025-26, standings dated 2026-04-17, games played [82]
    home record: 685 wins, 627 losses (166 of them in overtime or a shootout)

    Two details matter. Hockey has no ties, but it has overtime and shootout losses, and those are losses: the denominator is wins plus regulation losses plus OT losses, 1,312 home games in all. And the season label now comes from the payload's own seasonId. The June 2026 version of this tutorial typed the label into the code as "2024-25"; the cached standings it read are dated 2026-04-17 with seasonId 20252026, so that row was the 2025-26 season all along. Same 52.2%, wrong label. If you run the script during a season with an empty cache, standings/now returns the season in progress, and the printed season and games played will say so.

  4. NFL: the nflverse games table, without the neutral sites

    For football we read nflverse's games table from the bundled nfl_games_lines.csv: one row per game with both scores and a location column that says whether the listed home team really played at home.

    python
    def nfl_home():  # nflverse games table (bundled): scores plus a venue flag for every game
        g = pd.read_csv(os.path.join(HERE, "nfl_games_lines.csv"))
        g = g[(g["season"] == 2025) & (g["game_type"] == "REG")].dropna(subset=["home_score"])
        neutral = int((g["location"] == "Neutral").sum())
        g = g[g["location"] == "Home"]                     # a neutral site has no home crowd
        return {"wins": int((g["home_score"] > g["away_score"]).sum()),
                "losses": int((g["home_score"] < g["away_score"]).sum()),
                "draws": int((g["home_score"] == g["away_score"]).sum()),
                "season": "2025", "neutral": neutral}
    
    nfl = nfl_home()
    with sdt.snippet("nfl"):
        print(f"NFL {nfl['season']}: {nfl['neutral']} neutral-site games dropped; "
              f"home {nfl['wins']}-{nfl['losses']}-{nfl['draws']}")
    NFL 2025 regular season, home record
    NFL 2025: 7 neutral-site games dropped; home 142-122-1

    Seven of the 272 games in 2025 were played at neutral sites. A "home" team at a neutral site has no home crowd and no home travel schedule, so counting those games would blur the very thing we're measuring; the June version of this tutorial counted them. One 2025 game ended in a tie, which matters in the next step.

  5. Premier League and NBA: two bundled result files

    The Premier League comes from the bundled 2025-26 results (all 380 matches, read from ESPN's public scoreboard API in August 2026, the same file the ESPN results tutorial falls back to). The NBA comes from the bundled file of every 2023-24 game's score.

    python
    def epl_home():  # bundled 2025-26 Premier League results (ESPN scoreboard API, August 2026)
        e = pd.read_csv(os.path.join(HERE, "epl_results_2025_26.csv"))
        hg, ag = e["home_goals"], e["away_goals"]
        return {"wins": int((hg > ag).sum()), "losses": int((hg < ag).sum()),
                "draws": int((hg == ag).sum()), "season": "2025-26"}
    
    def nba_home():  # bundled 2023-24 results (Basketball-Reference, read June 2026)
        d = pd.read_csv(os.path.join(HERE, "nba_home_results.csv"))
        return {"wins": int((d["home_pts"] > d["away_pts"]).sum()),
                "losses": int((d["home_pts"] < d["away_pts"]).sum()),
                "draws": 0, "season": "2023-24"}
    
    epl, nba = epl_home(), nba_home()

    The June 2026 version of this page read the 2023-24 Premier League straight from football-data.co.uk, at https://www.football-data.co.uk/mmz4281/2324/E0.csv; in that path, 2324 marks the 2023-24 season and E0 is the Premier League. That run found home teams winning 46.1% of 380 matches. We switched to the bundled 2025-26 file for two reasons: it lines the Premier League up with the NHL's 2025-26 season, and football-data.co.uk's robots.txt now turns away a list of automated agents, so our build no longer downloads from it. Its CSVs remain a good source to download yourself. The NBA file covers 1,231 games: the 1,230-game regular season plus the In-Season Tournament final.

  6. The obvious answer: home wins over all games

    Every function returns the same dictionary, so the comparison is one loop and one DataFrame.

    python
    results = {"MLB": ("baseball", mlb), "NBA": ("basketball", nba), "EPL": ("soccer", epl),
               "NFL": ("football", nfl), "NHL": ("hockey", nhl)}
    
    rows = []
    for name, (sport, r) in results.items():
        games = r["wins"] + r["losses"] + r["draws"]
        rows.append({"League": name, "sport": sport, "Season": r["season"], "Games": games,
                     "Draws": r["draws"], "HomeWin%": round(100 * r["wins"] / games, 1)})
    table = pd.DataFrame(rows)
    
    with sdt.snippet("table"):
        show = table.sort_values("HomeWin%", ascending=False).reset_index(drop=True)
        show.index = range(1, len(show) + 1)
        print("Share of all games won by the home team:")
        print(show[["League", "Season", "Games", "Draws", "HomeWin%"]].to_string())
    Home-win rate, all games
    Share of all games won by the home team:
      League   Season  Games  Draws  HomeWin%
    1    MLB     2025   2430      0      54.3
    2    NBA  2023-24   1231      0      54.3
    3    NFL     2025    265      1      53.6
    4    NHL  2025-26   1312      0      52.2
    5    EPL  2025-26    380    104      42.6

    Read naively, MLB and the NBA lead at 54.3%, the NFL and NHL follow, and the Premier League trails far behind at 42.6%, the only league below 50%. The Draws column is the giveaway. A football match can end level, and 104 of the 380 did; a draw is not a home win, so every one of them pulls the rate down. The other four leagues force a winner almost every time (one NFL tie in 265 games). We're not comparing the same quantity five times: soccer's rate includes a third outcome the others barely have.

  7. The fair answer: home share of decided games, with its uncertainty

    The fix the June version left as a challenge: drop the draws and ask, of the games somebody won, what share did the home side win? Then put a 95% interval on each rate, because a season is a sample. We use the Wilson score interval, which behaves better than the textbook plus-or-minus formula when samples are small or rates are far from 50%.

    python
    def wilson(k, n, z=1.96):
        """95% Wilson score interval for k successes in n trials."""
        p = k / n
        centre = (p + z * z / (2 * n)) / (1 + z * z / n)
        half = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / (1 + z * z / n)
        return centre - half, centre + half
    
    for row in rows:
        r = results[row["League"]][1]
        decided = r["wins"] + r["losses"]
        lo, hi = wilson(r["wins"], decided)
        row.update({"Decided": decided, "Decided%": round(100 * r["wins"] / decided, 1),
                    "Low": round(100 * lo, 1), "High": round(100 * hi, 1)})
    table = pd.DataFrame(rows)
    
    with sdt.snippet("fair"):
        show = table.sort_values("Decided%", ascending=False).reset_index(drop=True)
        show.index = range(1, len(show) + 1)
        print("Home share of DECIDED games, with a 95% Wilson interval:")
        print(show[["League", "Decided", "Decided%", "Low", "High"]].to_string())
    Home share of decided games, 95% Wilson interval
    Home share of DECIDED games, with a 95% Wilson interval:
      League  Decided  Decided%   Low  High
    1    EPL      276      58.7  52.8  64.3
    2    MLB     2430      54.3  52.3  56.3
    3    NBA     1231      54.3  51.6  57.1
    4    NFL      264      53.8  47.8  59.7
    5    NHL     1312      52.2  49.5  54.9

    The Premier League goes from last to first. Of the 276 matches somebody won, home sides won 58.7%. That's a point estimate, not a verdict: its interval (52.8% to 64.3%) overlaps MLB's and the NBA's, so the defensible claim is that the Premier League's home edge is at least as large as any other league's here, not that it is clearly the largest. Sample size does the rest of the work. MLB's 2,430 games pin its rate within about two points either way; the NFL's 264 decided games leave an interval from 47.8% to 59.7%, wide enough to include no home edge at all. One NFL season cannot tell you whether football's home edge is bigger than hockey's.

  8. Score it the way the league tables do

    Dropping draws is one fair measure; another is the currency each league actually ranks teams by. The Premier League gives 3 points for a win and 1 for a draw; the NHL gives 2 for a win and 1 for an overtime loss, and its standings report home and road points directly.

    python
    with sdt.snippet("points"):
        games = epl["wins"] + epl["losses"] + epl["draws"]
        home_ppg = (3 * epl["wins"] + epl["draws"]) / games
        away_ppg = (3 * epl["losses"] + epl["draws"]) / games
        print(f"EPL points per game: home {home_ppg:.3f}, away {away_ppg:.3f}, "
              f"home share of points {100 * home_ppg / (home_ppg + away_ppg):.1f}%")
        hp, rp = nhl["home_points"], nhl["road_points"]
        print(f"NHL standings points: home {hp}, road {rp}, home share {100 * hp / (hp + rp):.1f}%")
    Home and away points
    EPL points per game: home 1.553, away 1.174, home share of points 56.9%
    NHL standings points: home 1536, road 1414, home share 52.1%

    In points, Premier League home sides earned 1.553 a game against 1.174 away, 56.9% of all points. NHL home teams took 52.1% of standings points, close to their 52.2% share of wins because an overtime loss pays both sides. Two different fair measures, the same ordering: the Premier League's home edge is the big one, and the draw was hiding it.

  9. Chart both measures on one axis

    One dot per measure per league, on a single percentage axis: the hollow dot is home wins over all games, the coloured dot is home wins over decided games, and the grey bar is that second rate's 95% interval.

    python
    plot_df = table.sort_values("Decided%").reset_index(drop=True)
    y = np.arange(len(plot_df))
    fig, ax = plt.subplots(figsize=(8.5, 4.8))
    ax.hlines(y, plot_df["Low"], plot_df["High"], color="#9AA0A8", linewidth=2, zorder=1)
    ax.scatter(plot_df["HomeWin%"], y, s=60, facecolor="white", edgecolor="#20242B",
               linewidth=1.5, zorder=3, label="home wins / all games")
    ax.scatter(plot_df["Decided%"], y, s=70, zorder=4, label="home wins / decided games",
               color=[sdt.SPORT_COLORS[s] for s in plot_df["sport"]])
    for i, row in plot_df.iterrows():
        ax.annotate(f"{row['Decided%']:.1f}%", (row["High"], i), xytext=(6, 0),
                    textcoords="offset points", va="center", fontsize=9)
    ax.axvline(50, color="#20242B", linestyle="--", linewidth=1)
    ax.set_yticks(y, [f"{lg} {s}" for lg, s in zip(plot_df["League"], plot_df["Season"])])
    ax.set_xlim(38, 68)
    ax.set_xlabel("home win rate (%)  -  grey bar: 95% interval for the decided-games rate")
    ax.set_title("Count the draws as non-wins and soccer looks cursed; drop them and it leads")
    legend = ax.legend(loc="lower left", fontsize=8, frameon=False)
    legend.legend_handles[1].set_color("#5B6E5A")                # a neutral key, not one sport's colour
    sdt.save_fig(fig, "home_advantage", asof="June-October 2026",
                 source="MLB Stats API; NHL API (cached June 2026); nflverse; ESPN; Basketball-Reference")
    Dot chart with one row per league, EPL 2025-26, MLB 2025, NBA 2023-24, NFL 2025 and NHL 2025-26, on a home win rate axis from 38 to 68 percent with a dashed line at 50. For the EPL the hollow all-games dot sits at 42.6 and the coloured decided-games dot at 58.7, the largest gap; for the other four leagues the two dots almost coincide between 52.2 and 54.3. Grey interval bars are widest for the NFL, crossing 50.
    Data: MLB Stats API (live) + NHL API (cached) + bundled nflverse, ESPN Premier League and NBA results, retrieved MLB read October 2026; NHL standings cached June 2026; bundled files June-August 2026

    The chart makes the measurement problem visible: four leagues where the two dots sit on top of each other, and one where a third outcome pulls them 16 points apart. When a definition changes the ranking, the definition is part of the finding, and it belongs in the chart, not in a footnote.

  10. Is a one-season number noise? 27 NFL seasons

    The interval says a single NFL season is a small sample. The bundled nflverse table lets us test that directly: compute the home share of decided games for every regular season from 1999 to 2025, then ask whether the seasons differ more than a coin flip with one fixed rate would make them differ. We simulate 20,000 such constant-rate histories, each season with its real number of games, and compare.

    python
    g = pd.read_csv(os.path.join(HERE, "nfl_games_lines.csv"))
    g = g[(g["game_type"] == "REG") & (g["location"] == "Home")].dropna(subset=["home_score"])
    g = g[g["home_score"] != g["away_score"]]                     # decided games only
    g["home_win"] = (g["home_score"] > g["away_score"]).astype(int)
    by = g.groupby("season")["home_win"].agg(n="size", w="sum")
    by["rate"] = by["w"] / by["n"]
    
    p = by["w"].sum() / by["n"].sum()                             # one rate for every season
    noise_sd = math.sqrt(p * (1 - p) / by["n"].mean())            # binomial spread per season
    seasons = by.index.to_numpy()
    x = seasons - np.average(seasons, weights=by["n"])          # games-weighted centring
    
    def slope(rates):                                             # least squares, games-weighted
        wts = by["n"].to_numpy()
        centred = rates - np.average(rates, weights=wts)
        return np.sum(wts * x * centred) / np.sum(wts * x * x)
    
    rng = np.random.default_rng(20)
    sims = rng.binomial(by["n"].to_numpy(), p, size=(20000, len(by))) / by["n"].to_numpy()
    sim_sd = sims.std(axis=1, ddof=1)
    sim_slope = np.array([slope(s) for s in sims])
    obs_sd, obs_slope = by["rate"].std(ddof=1), slope(by["rate"].to_numpy())
    
    with sdt.snippet("seasons"):
        print(f"NFL {by.index.min()}-{by.index.max()}: {len(by)} seasons, {by['n'].sum():,} decided games, "
              f"pooled home rate {100 * p:.1f}%")
        print(f"lowest {by['rate'].idxmin()} {100 * by['rate'].min():.1f}%, "
              f"highest {by['rate'].idxmax()} {100 * by['rate'].max():.1f}%")
        print(f"spread across seasons: observed sd {100 * obs_sd:.2f} pts, "
              f"coin-flip noise alone {100 * noise_sd:.2f} pts")
        print(f"constant-rate histories at least that spread out: {(sim_sd >= obs_sd).mean():.1%}")
        print(f"trend: {1000 * obs_slope:+.2f} pts per decade; "
              f"constant-rate histories with a trend that steep: {(np.abs(sim_slope) >= abs(obs_slope)).mean():.1%}")
    NFL home share of decided games, 1999-2025
    NFL 1999-2025: 27 seasons, 6,887 decided games, pooled home rate 56.3%
    lowest 2020 50.4%, highest 2003 61.6%
    spread across seasons: observed sd 2.86 pts, coin-flip noise alone 3.11 pts
    constant-rate histories at least that spread out: 68.7%
    trend: -1.67 pts per decade; constant-rate histories with a trend that steep: 3.0%

    Two tests, two answers, and both are right. The seasons range from 50.4% (2020) to 61.6% (2003), which looks like a lot of movement, yet their standard deviation of 2.86 points is smaller than the 3.11 points that pure coin-flip noise produces at about 255 decided games a season; 68.7% of the constant-rate histories spread out at least as much. A test of "do seasons differ?" finds nothing. A test of a specific shape, a steady slide, finds a decline of 1.67 points per decade, a slope only 3.0% of constant-rate histories match. Asked the right question, the same 27 numbers show a real drift that season-to-season noise had buried.

    python
    fig, ax = plt.subplots(figsize=(8.5, 4.6))
    ax.fill_between(by.index, 100 * (p - 2 * noise_sd), 100 * (p + 2 * noise_sd),
                    color="#E4DDCB", label="pooled rate +/- 2 sd of coin-flip noise")
    ax.plot(by.index, 100 * by["rate"], marker="o", markersize=5, linewidth=1.5,
            color=sdt.SPORT_COLORS["football"], label="home share of decided games")
    trend = 100 * (np.average(by["rate"], weights=by["n"]) + obs_slope * x)
    ax.plot(by.index, trend, color="#20242B", linewidth=1.2, linestyle="--", label="weighted trend line")
    ax.axhline(50, color="#9AA0A8", linewidth=1)
    ax.set_ylim(46, 66)
    ax.set_xlabel("NFL regular season (neutral sites and ties removed)")
    ax.set_ylabel("home win rate (%)")
    ax.set_title("Every NFL season sits inside the noise band; the slow slide is the signal")
    ax.legend(loc="upper right", fontsize=8, frameon=False)
    sdt.save_fig(fig, "nfl_home_seasons", source="nflverse games table", asof="June 2026 (bundled)")
    print("\nCapstone complete: one question, five leagues, a fair rate and its noise.")
    Line chart of the NFL home share of decided games for each regular season from 1999 to 2025, with a shaded band from about 50 to 62.5 percent marking the pooled rate plus or minus two standard deviations of coin-flip noise, and a dashed weighted trend line falling from about 58.5 percent to about 54 percent. Every season sits inside the band; the lowest point is 2020 at 50.4 percent and the highest 2003 at 61.6 percent.
    Data: MLB Stats API (live) + NHL API (cached) + bundled nflverse, ESPN Premier League and NBA results, retrieved MLB read October 2026; NHL standings cached June 2026; bundled files June-August 2026

    Read the chart in that light: every season sits inside the noise band, including 2020, the season played largely without crowds, which brushes its lower edge. You could not have called any single season unusual from its own number, but the trend line runs downhill from start to finish. It's the same lesson as the interval on the NFL's 2025 rate, told across time: a single season tells you little, and a pattern only shows up when you test for it directly.

Where this breaks

Five limits. The seasons don't line up. MLB and the NFL are 2025, the Premier League and NHL are 2025-26, and the NBA is 2023-24 because that's the season the bundled file holds; a cross-league ranking from different years mixes league differences with year-to-year noise. Decided games are not the only fair measure. Dropping draws throws away information (a home draw against a strong side is a decent result), which is why the points view is there as a second opinion. A home win rate mixes team quality with venue. Over a full balanced schedule every team plays at home and away about equally often, so the quality differences mostly cancel, but in short schedules with lopsided matchups they don't. The NFL test is one sport. The noise-versus-trend result says nothing about whether the other four leagues' home edges are shrinking; that needs their long histories, which the bundled files don't hold. Twenty thousand simulations are a sample too. The 3.0% would move by a few tenths with a different seed; the conclusion wouldn't.

Sources. MLB standings: the MLB Stats API (statsapi.mlb.com/api/v1/standings, 2025 regular season), read October 2026; the cross-check used Retrosheet's 2025 game logs. The information used here was obtained free of charge from and is copyrighted by Retrosheet. Interested parties may contact Retrosheet at www.retrosheet.org. NHL standings: api-web.nhle.com/v1/standings/now, cached 4 June 2026. NFL games: the nflverse games table (github.com/nflverse/nfldata), bundled June 2026. Premier League 2025-26: ESPN's public scoreboard API, bundled August 2026. NBA 2023-24 results: Basketball-Reference schedule pages, bundled June 2026. The interval: E. B. Wilson, "Probable Inference, the Law of Succession, and Statistical Inference", Journal of the American Statistical Association 22(158), 209-212, 1927 (doi:10.1080/01621459.1927.10502953).

Troubleshooting

KeyError: 'splitRecords' on the MLB step

An upstream response changed shape or the request failed. Print one team before looping and confirm the path records → teamRecords → records → splitRecords exists. If the response is an error message instead of standings, check the season and leagueId parameters.

My NHL row shows a different season, or fewer than 82 games

Expected when your cache is empty and a season is under way: standings/now returns the current standings. The script prints the season and the games-played values so you can see it. Delete the cached standings_now.json to refresh it; keep it to keep your numbers stable.

FileNotFoundError for a CSV

The script reads nfl_games_lines.csv, epl_results_2025_26.csv and nba_home_results.csv from its own folder. Download them with the script (they're in the list on this page) and keep all four files together.

My season-test percentages differ slightly from the page

The simulation is seeded (default_rng(20)); a different seed, a different number of simulations or a different NumPy version's generator can move the 68.7% and 3.0% by a few tenths. The observed standard deviation and slope don't depend on the seed and should match exactly.

A request hangs or returns a 429/503

The polite session retries those codes with backoff. If MLB is genuinely down, comment out that league; every function is self-contained, so the other four still produce a valid, smaller table.

Challenge yourself

Three extensions. First, use the NHL standings' homeRegulationWins and roadRegulationWins to measure the home edge in games settled in regulation only, and see whether overtime dilutes it. Second, rerun the 27-season NFL test on the home team's average winning margin instead of its win share; a margin carries more information per game, so check whether the trend test needs fewer seasons to see the slide. Third, download several Premier League seasons yourself and run the same noise-versus-trend test on the home share of decided matches: is soccer's home edge drifting too?

Take the script home

The finished script behind this tutorial is the one that was run to produce its figures and printouts; download it and run it yourself.

Download the finished script (20_quantifying_home_advantage_across_five_leagues.py)

This script imports a small shared helper (and reads any bundled sample data) that live next to it in /downloads/ — grab these into the same folder so it runs as-is: sdt_common.py, sdt_nflverse.py, nba_home_results.csv, nfl_games_lines.csv, epl_results_2025_26.csv. Or skip the collecting: the Hockey & Cross-League Projects bundle has this whole course’s scripts and data in one ZIP.

Built from public data and the sources named in this tutorial; the printed outputs and charts come from running the tutorial's own script. How this site is made · about the site

Progress is saved only in this browser.