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.
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 5DRAFTKINGS_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.
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, truncatedReport 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.
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 NoneNever 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.
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.
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 outEvaluate 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.
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 5Check 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
Build a chronological NBA points baseline from recorded box scores, score it on later games and compare with Pick6 targets you recorded yourself.
Training data for sports prop modelsWhy there is no ready-made training set here, where point-in-time leakage comes from, and how to record your own as-of dataset from now on.
DraftKings API in Python and cURLCall the API with cURL and the Python standard library: timeouts, retries on 503, bounded pagination and clear error handling.
DraftKings API developer starter kitDownload Python and Node examples, a notebook, Postman templates, a daily archive workflow and a comparison feed. Bounded requests and no embedded credentials.
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.