draftkings apiDashboard ↗

INDEPENDENT DEVELOPER REFERENCE

Build a prop model baseline in Python

A walkthrough of a standard-library Python baseline: cursor pagination, numeric stat parsing, a SQLite cache and MAE on a later held-out window.

Editorial review: · Not a live coverage report

Setup

The script needs Python 3 and nothing else: urllib, json, sqlite3, argparse and statistics are all in the standard library. It reads your key from DRAFTKINGS_API_KEY and stops at a page budget, so a run cannot spend your quota by accident.

Download and run
export DRAFTKINGS_API_KEY=...
curl -O https://draftkingsapi.com/examples/nba-points-baseline.py
python3 nba-points-baseline.py --season 2027 --min-games 5 --max-pages 5

DRAFTKINGS_API_BASE is an optional override for testing against a local server. The model reasoning is in the NBA model guide; this page covers the code.

Fetch with bounded cursor pagination

Statistics pagination uses a cursor, not an offset. Pass pagination.next as after on the next call, and stop when has_more is false or your page budget runs out. Offset and CSV filters are rejected on these endpoints.

Stop at a budget and report truncation
import json, os, time, urllib.error, urllib.parse, urllib.request
BASE = os.environ.get("DRAFTKINGS_API_BASE", "https://draftkingsapi.com")
KEY = os.environ["DRAFTKINGS_API_KEY"]

def get(path, params):
    url = f"{BASE}{path}?{urllib.parse.urlencode(params)}"
    req = urllib.request.Request(url, headers={"Authorization": f"Bearer {KEY}"})
    with urllib.request.urlopen(req, timeout=20) as res:
        return json.load(res)

def player_games(season, max_pages):
    params = {"sport": "NBA", "season": season, "completed": "true", "limit": 200}
    rows, truncated = [], False
    for _ in range(max_pages):
        page = get("/api/v1/stats/player-games", params)
        rows += page["data"]
        if not page["pagination"]["has_more"]:
            return rows, False
        params["after"] = page["pagination"]["next"]
        truncated = True
    return rows, truncated

Report truncated in your output. A page budget that ended early means your sample is incomplete, and results should say so. Handle 429 by waiting for the relevant UTC boundary, as described in errors and rate limits, and do not retry in a tight loop.

Parse stats by scope and numeric

numeric holds a scalar and is null for compound or time values such as 1-3 or 13:10. The display string keeps the original text. Read numeric for a points target, and only parse display when you need the compound value, such as field goals attempted.

Scalar first, display only when needed
def game_stat(row, key):
    if row["data"].get("did_not_play"):
        return None
    for stat in row["data"].get("stats", []):
        if stat["key"] == key and stat["scope"] == "game":
            return stat["numeric"]
    return None

def made_attempted(row, key):
    for stat in row["data"].get("stats", []):
        if stat["key"] == key and stat["display"] and "-" in stat["display"]:
            made, attempted = stat["display"].split("-", 1)
            return int(made), int(attempted)
    return None

Never fall back to a season-to-date value when a game value is missing. A missing stat is missing, not zero.

Cache in SQLite

Caching pages keeps repeated experiments from spending requests. Key rows by event, athlete and category. API responses are no-store, so the cache is your own decision, and your fetch time should be stored with each row.

One row per athlete, game and category
import sqlite3, time
db = sqlite3.connect("nba-stats-cache.sqlite")
db.execute("""CREATE TABLE IF NOT EXISTS player_game (
  event_id TEXT, athlete_id TEXT, category TEXT,
  starts_at TEXT, fetched_at TEXT, row_json TEXT,
  PRIMARY KEY (event_id, athlete_id, category))""")

def store(rows):
    now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
    db.executemany(
        "INSERT OR REPLACE INTO player_game VALUES (?,?,?,?,?,?)",
        [(r["game"]["event_id"], r["data"]["athlete_id"], r["data"]["category"],
          r["game"]["starts_at"], now, json.dumps(r)) for r in rows])
    db.commit()

INSERT OR REPLACE makes reruns safe but keeps only the latest read. That suits a statistics cache. For an as-of record of Pick6 rows, keep every read instead, as described in training data.

Predict in time order

Pages arrive in cursor order, so sort by game.starts_at first. Each prediction uses earlier games for that athlete, and the current game is appended only after its prediction is recorded.

Rolling mean with no leakage from the current game
from collections import defaultdict

def predictions(rows, min_games, window=5):
    seen, out = defaultdict(list), []
    for row in sorted(rows, key=lambda r: r["game"]["starts_at"]):
        value = game_stat(row, "points")
        if value is None:
            continue
        earlier = seen[row["data"]["athlete_id"]]
        if len(earlier) >= min_games:
            recent = earlier[-window:]
            out.append((row["game"]["starts_at"], sum(recent) / len(recent), value))
        earlier.append(value)
    return out

Evaluate on a later window

Pick a cutoff date, then score only predictions for games after it. MAE is the mean of absolute differences, in points. Compare it with a simpler rule on the same games, and print the sample size.

Report the count beside the error
from statistics import mean

def mae(pairs):
    return mean(abs(pred - actual) for pred, actual in pairs) if pairs else None

scored = [(p, a) for when, p, a in predictions(rows, args.min_games) if when >= args.cutoff]
print(json.dumps({
    "games_scored": len(scored),
    "mae_points": mae(scored),
    "truncated": truncated,
}))

If games_scored is small, the MAE will move a lot between runs. That is a property of the sample. Do not quote it as general accuracy.

Limits of this sample

Independent ESPN statistics cover NBA, NFL and NHL as limited retained game samples, not full seasons. As of 2026-11-03 those samples were captured after the games were played, so they do not show what was known at game time. That constrains what any result here can claim.

  • Few games per athlete means many athletes never reach --min-games.
  • Samples are not a random draw from a season, so errors may not generalize.
  • This scores prediction error against box scores. It says nothing about a Pick6 row or a betting outcome.
  • Results do not establish an edge, a win rate or a profit, and this is not a recommendation.
  • Rerun as retained games grow, and read scope in the coverage report.

Try it

python3 nba-points-baseline.py --season 2027 --min-games 5 --max-pages 5

Check the recorded data

Questions

Do I need pandas or scikit-learn?

No. The example uses only the Python standard library, so it runs anywhere Python 3 does. You can load the SQLite cache into any library you prefer later.

Why is the numeric field null for some stats?

numeric holds a scalar only. Compound and time values such as 1-3 or 13:10 stay in display, so parse those deliberately when you need them.

Why are rows not in date order?

Statistics pages follow a cursor built from event, athlete and category IDs. Sort by the game start time yourself before computing any rolling feature.

Can I adapt this to NFL or NHL?

Yes. Change the sport, category and stat key, such as receivingYards in the NFL receiving category or shotsTotal in NHL rows. NFL season uses its starting year, and NBA and NHL use the ending year.

Related resources

Independent service operated by DD Inc., not affiliated with or endorsed by DraftKings. Technical access is not a redistribution license. No wagering: this service does not place, recommend or settle entries or bets. Check sources and status for actual capture context.