{ "cells": [ { "cell_type": "markdown", "id": "b325e258", "metadata": {}, "source": [ "# 🏀🌍 International basketball with `sportsdataverse-py`\n", "\n", "The NBA is one league; the rest of the basketball world is dozens. `sportsdataverse-py` wraps the\n", "feeds behind the biggest of them, and this notebook tours four:\n", "\n", "| League | Module | Provider |\n", "|---|---|---|\n", "| EuroLeague | `sportsdataverse.euroleague` | EuroLeague Competition Engine |\n", "| Super League Basketball (UK) | `sportsdataverse.basketball.genius` | Genius Sports LiveStats |\n", "| NBL (Australia) | `sportsdataverse.basketball.nbl_au` | NBL Rosetta |\n", "| Liga Endesa (Spain) | `sportsdataverse.basketball.acb` | ACB |\n", "\n", "No API keys, polars by default (`return_as_pandas=True` for pandas), and at the end a EuroLeague\n", "shot chart drawn on a FIBA court with [sdvplot](https://sdvplot.sportsdataverse.org/). 🎯" ] }, { "cell_type": "markdown", "id": "805ab1f3", "metadata": {}, "source": [ "## 🧰 The toolbox\n", "\n", "| Function | What it gives you |\n", "|---|---|\n", "| [`euroleague_seasons`](../euroleague/reference/euroleague.md#euroleague_seasons) | Seasons of a competition (`E` = EuroLeague, `U` = EuroCup) |\n", "| [`euroleague_games`](../euroleague/reference/euroleague.md#euroleague_games) | Every game of a season, played or not |\n", "| [`euroleague_standings`](../euroleague/reference/euroleague.md#euroleague_standings) | Standings after a round |\n", "| [`euroleague_game_points`](../euroleague/reference/euroleague.md#euroleague_game_points) | Shot chart of one game (`coord_x` / `coord_y` in cm) |\n", "| [`slb_competitions`](../genius/reference/additional.md#slb_competitions) | SLB competitions with their END-year season |\n", "| [`slb_schedule`](../genius/reference/additional.md#slb_schedule) | Every match of an SLB season |\n", "| [`slb_player_box`](../genius/reference/additional.md#slb_player_box) | Player box score of a finished SLB game |\n", "| [`slb_shots`](../genius/reference/additional.md#slb_shots) | Shot chart of a finished SLB game |\n", "| [`nbl_rosetta_standings`](../nbl_au/reference/nbl_rosetta.md#nbl_rosetta_standings) | NBL regular-season ladder |\n", "| [`nbl_rosetta_matches`](../nbl_au/reference/nbl_rosetta.md#nbl_rosetta_matches) | Matches of an NBL season |\n", "| [`acb_standings`](../acb/reference/acb.md#acb_standings) | Liga Endesa table after a round |\n", "| [`acb_calendar`](../acb/reference/acb.md#acb_calendar) | Every round of a season with its games |\n", "\n", "Genius LiveStats serves many leagues (`wbbl_*`, `bal_*`, `nznbl_*`, `esake_*`, `lfb_*`, ...) with the\n", "same shape, so the SLB recipes below work for every one of them." ] }, { "cell_type": "markdown", "id": "6e9da5c4", "metadata": {}, "source": [ "## 🔌 Setup\n", "\n", "```sh\n", "pip install \"sportsdataverse[sdvplot]\"\n", "```\n", "\n", "The `sdvplot` extra brings in sdvplot and sportypy for the court drawing." ] }, { "cell_type": "code", "execution_count": null, "id": "7a5d9353", "metadata": {}, "outputs": [], "source": [ "import matplotlib.pyplot as plt\n", "import polars as pl\n", "\n", "from sportsdataverse import euroleague as el\n", "from sportsdataverse.basketball import acb, genius, nbl_au\n", "\n", "pl.Config.set_tbl_rows(10)" ] }, { "cell_type": "markdown", "id": "72b54658", "metadata": {}, "source": [ "Provider feeds are seasonal and occasionally rate-limited, so a tiny `safe()` helper runs each call\n", "defensively: you get the frame when the feed is up, and a one-line skip when it isn't, naming whether\n", "the answer was *nothing there* (`NoDataError`) or *the fetch failed* (`AssetFetchError`). 🛟" ] }, { "cell_type": "code", "execution_count": null, "id": "8918ab89", "metadata": {}, "outputs": [], "source": [ "from sportsdataverse.errors import AssetFetchError, NoDataError\n", "\n", "def safe(label, thunk):\n", " try:\n", " out = thunk()\n", " print(f\"✅ {label}\")\n", " return out\n", " except (NoDataError, AssetFetchError) as e:\n", " print(f\"\\u23ed\\ufe0f {label}: {type(e).__name__}: {e}\")\n", " return None" ] }, { "cell_type": "markdown", "id": "fbc04949", "metadata": {}, "source": [ "## 🇪🇺 EuroLeague\n", "\n", "Season codes are the competition letter plus the START year (`E2025` is 2025-26). We take the newest\n", "season that has a played game, so the notebook works in the off-season too." ] }, { "cell_type": "code", "execution_count": null, "id": "8f18d876", "metadata": {}, "outputs": [], "source": [ "seasons = safe(\"EuroLeague seasons\", lambda: el.euroleague_seasons(\"E\"))\n", "season_code, games = None, None\n", "if seasons is not None:\n", " for code in seasons.sort(\"year\", descending=True)[\"code\"].head(3):\n", " g = safe(f\"games {code}\", lambda code=code: el.euroleague_games(\"E\", code))\n", " if g is not None and g.filter(pl.col(\"played\") == True).height:\n", " season_code, games = code, g\n", " break\n", "print(\"season:\", season_code, \"| games:\", None if games is None else games.shape)" ] }, { "cell_type": "code", "execution_count": null, "id": "677af82b", "metadata": {}, "outputs": [], "source": [ "played = games.filter(pl.col(\"played\") == True).sort(\"utc_date\") if games is not None else None\n", "cols = [\"game_code\", \"round\", \"utc_date\", \"local_club_name\", \"road_club_name\"]\n", "(played.select([c for c in cols if c in played.columns]).tail(5)\n", " if played is not None and played.height else \"no played games\")" ] }, { "cell_type": "markdown", "id": "858cbe99", "metadata": {}, "source": [ "### Standings after the latest round 🏆\n", "\n", "[`euroleague_standings`](../euroleague/reference/euroleague.md#euroleague_standings) takes the round as a string." ] }, { "cell_type": "code", "execution_count": null, "id": "1c3f8ac2", "metadata": {}, "outputs": [], "source": [ "last_round = str(played[\"round\"].max()) if played is not None and played.height else None\n", "stand = safe(f\"standings round {last_round}\",\n", " lambda: el.euroleague_standings(\"E\", season_code, last_round)) if last_round else None\n", "cols = [\"position\", \"club_name\", \"games_played\", \"games_won\", \"games_lost\", \"points_difference\"]\n", "(stand.select([c for c in cols if c in stand.columns]).head(10)\n", " if stand is not None and stand.height else \"standings unavailable\")" ] }, { "cell_type": "markdown", "id": "d5ff92f8", "metadata": {}, "source": [ "### A shot chart on a FIBA court 🎯\n", "\n", "[`euroleague_game_points`](../euroleague/reference/euroleague.md#euroleague_game_points) returns one row\n", "per field-goal attempt and made free throw, in centimeters with the hoop at the origin.\n", "`sdvplot.court_coords(..., provider=\"euroleague\")` converts that frame to meters on the FIBA court\n", "that `sdvplot.surface(\"fiba\")` draws (free throws, encoded `-1, -1`, become null). Both teams shoot at\n", "one basket, so we draw the defensive half." ] }, { "cell_type": "code", "execution_count": null, "id": "d50ce167", "metadata": {}, "outputs": [], "source": [ "import sdvplot\n", "\n", "gcode = str(played[\"game_code\"][-1]) if played is not None and played.height else None\n", "pts = safe(f\"shots game {gcode}\", lambda: el.euroleague_game_points(gcode, season_code)) if gcode else None\n", "if pts is not None and pts.height:\n", " shots = sdvplot.court_coords(pts, x=\"coord_x\", y=\"coord_y\", provider=\"euroleague\")\n", " shots = shots.filter(pl.col(\"court_x\").is_not_null())\n", " made = shots.filter(pl.col(\"id_action\").is_in([\"2FGM\", \"3FGM\"]))\n", " missed = shots.filter(pl.col(\"id_action\").is_in([\"2FGA\", \"3FGA\"]))\n", " ax = sdvplot.surface(\"fiba\", display_range=\"defense\")\n", " ax.scatter(missed[\"court_x\"], missed[\"court_y\"], marker=\"x\", s=30, c=\"#888888\", zorder=20, label=\"missed\")\n", " ax.scatter(made[\"court_x\"], made[\"court_y\"], s=30, c=\"#d62728\", zorder=21, label=\"made\")\n", " ax.legend(loc=\"upper right\")\n", " ax.set_title(f\"EuroLeague {season_code} game {gcode}: {shots.height} field-goal attempts\")\n", " plt.show()\n", "else:\n", " print(\"no shot data for this game\")" ] }, { "cell_type": "markdown", "id": "3bfd0845", "metadata": {}, "source": [ "## 🇬🇧 Super League Basketball (Genius Sports)\n", "\n", "[`slb_competitions`](../genius/reference/additional.md#slb_competitions) lists the SLB competitions\n", "Genius hosts; [`slb_schedule`](../genius/reference/additional.md#slb_schedule) defaults to the current one." ] }, { "cell_type": "code", "execution_count": null, "id": "c4f4b8a6", "metadata": {}, "outputs": [], "source": [ "comps = safe(\"SLB competitions\", genius.slb_competitions)\n", "comps.select([c for c in [\"comp_id\", \"label\", \"season\"] if c in comps.columns]).head() if comps is not None else None" ] }, { "cell_type": "code", "execution_count": null, "id": "7e22a009", "metadata": {}, "outputs": [], "source": [ "sched = safe(\"SLB schedule\", genius.slb_schedule)\n", "done = sched.filter(pl.col(\"status\") == \"COMPLETE\") if sched is not None else None\n", "cols = [\"match_id\", \"date_time\", \"home_team_name\", \"home_score\", \"away_team_name\", \"away_score\"]\n", "(done.select([c for c in cols if c in done.columns]).tail(5)\n", " if done is not None and done.height else \"no completed SLB games yet\")" ] }, { "cell_type": "markdown", "id": "91856c27", "metadata": {}, "source": [ "### One game: box score and shots 📋\n", "\n", "Take the latest completed `match_id`, then the player box score\n", "([`slb_player_box`](../genius/reference/additional.md#slb_player_box)) and the shot chart\n", "([`slb_shots`](../genius/reference/additional.md#slb_shots), x/y as a percent of the full court)." ] }, { "cell_type": "code", "execution_count": null, "id": "2c26d538", "metadata": {}, "outputs": [], "source": [ "mid = int(done[\"match_id\"][-1]) if done is not None and done.height else None\n", "box = safe(f\"SLB box {mid}\", lambda: genius.slb_player_box(mid)) if mid else None\n", "cols = [\"name\", \"minutes\", \"points\", \"rebounds_total\", \"assists\", \"field_goals_made\", \"field_goals_attempted\"]\n", "(box.select([c for c in cols if c in box.columns]).sort(\"points\", descending=True).head(8)\n", " if box is not None and box.height and \"points\" in box.columns else \"box score unavailable\")" ] }, { "cell_type": "code", "execution_count": null, "id": "b6851de5", "metadata": {}, "outputs": [], "source": [ "sh = safe(f\"SLB shots {mid}\", lambda: genius.slb_shots(mid)) if mid else None\n", "(sh.group_by(\"action_type\", \"result\").len().sort(\"action_type\")\n", " if sh is not None and sh.height else \"no shots for this game\")" ] }, { "cell_type": "markdown", "id": "8a77bae6", "metadata": {}, "source": [ "## 🇦🇺 NBL (Australia)\n", "\n", "The NBL's Rosetta API takes the season as its END year.\n", "[`nbl_rosetta_standings`](../nbl_au/reference/nbl_rosetta.md#nbl_rosetta_standings) gives the ladder;\n", "[`nbl_rosetta_matches`](../nbl_au/reference/nbl_rosetta.md#nbl_rosetta_matches) the match list." ] }, { "cell_type": "code", "execution_count": null, "id": "170ed881", "metadata": {}, "outputs": [], "source": [ "nbl_season = 2026\n", "ladder = safe(f\"NBL ladder {nbl_season}\", lambda: nbl_au.nbl_rosetta_standings(nbl_season))\n", "cols = [\"position\", \"team_name\", \"won\", \"lost\", \"win_percentage\", \"points_for\", \"points_against\", \"streak\"]\n", "(ladder.select([c for c in cols if c in ladder.columns]).sort(\"position\")\n", " if ladder is not None and ladder.height else \"ladder unavailable\")" ] }, { "cell_type": "code", "execution_count": null, "id": "ac0c0755", "metadata": {}, "outputs": [], "source": [ "matches = safe(f\"NBL matches {nbl_season}\", lambda: nbl_au.nbl_rosetta_matches(nbl_season))\n", "(matches.group_by(\"match_status\").len() if matches is not None and \"match_status\" in matches.columns\n", " else \"matches unavailable\")" ] }, { "cell_type": "markdown", "id": "ea9c472a", "metadata": {}, "source": [ "## 🇪🇸 Liga Endesa (ACB)\n", "\n", "[`acb_standings`](../acb/reference/acb.md#acb_standings) defaults to the table after the current round;\n", "[`acb_calendar`](../acb/reference/acb.md#acb_calendar) lists every round of the season in one call.\n", "A bar chart of the table makes the early-season race easy to read." ] }, { "cell_type": "code", "execution_count": null, "id": "2f268562", "metadata": {}, "outputs": [], "source": [ "table = safe(\"ACB standings\", acb.acb_standings)\n", "if table is not None and table.height:\n", " t = table.sort(\"win_pct\", \"plus_minus\")\n", " fig, ax = plt.subplots(figsize=(7, 6))\n", " ax.barh(t[\"team\"], t[\"win_pct\"], color=\"#1f4e79\")\n", " ax.set_xlabel(\"win %\")\n", " end = int(t[\"season\"][0]) # END year\n", " ax.set_title(f\"Liga Endesa {end - 1}-{str(end)[2:]} standings\")\n", " plt.tight_layout()\n", " plt.show()\n", "else:\n", " print(\"ACB standings unavailable\")" ] }, { "cell_type": "code", "execution_count": null, "id": "d1ba0b72", "metadata": {}, "outputs": [], "source": [ "cal = safe(\"ACB calendar\", acb.acb_calendar)\n", "(cal.group_by(\"round_number\").agg(games=pl.len(), played=pl.col(\"home_score\").is_not_null().sum())\n", " .sort(\"round_number\").head(8) if cal is not None and cal.height else \"calendar unavailable\")" ] }, { "cell_type": "markdown", "id": "e2bd8382", "metadata": {}, "source": [ "## 🎉 Where to next\n", "\n", "- Every Genius league (`wbbl_`, `bal_`, `nznbl_`, `esake_`, `lfb_`, `lnbm_`, `lub_`) has the same\n", " `_schedule` / `_player_box` / `_pbp` / `_shots` set as SLB.\n", "- More homes under `sportsdataverse.basketball`: `fiba_hub`, `atrium`, `feb`, `scb`, `lba`, `vtb`,\n", " `kbl`, `bleague`, `cebl`, `aba` and others; each has a reference page in the sidebar.\n", "- Shot charts: [sdvplot](https://sdvplot.sportsdataverse.org/)'s `court_coords` also converts NBA and\n", " WNBA stats-API shots (`provider=\"nba\"`).\n", "- NBA, WNBA and college basketball have their own notebooks (`04`, `08`, `06`, `05`)." ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "name": "python" } }, "nbformat": 4, "nbformat_minor": 5 }