Guide, Python
How to build a player prop model in Python
A baseline FanDuel prop model in four steps: pull graded props with their line history, turn movement into features, fit and evaluate against the market's own closing price, and check closing line value. The point is an honest evaluation, not a clever model.
FanDuel API is an independent data service operated by FF API Inc. It is not affiliated with, endorsed by or sponsored by FanDuel.
1. Pull graded props
The training set gives each prop's open line, closing line and the moves before the start; graded results give what happened. Join them on prop_id and you have one tidy table. Install requests pandas scikit-learn.
import os, requests, pandas as pd
API = "https://fanduelapi.com/api/v1"
H = {"Authorization": f"Bearer {os.environ['FANDUEL_API_KEY']}"}
def rows(path, **params):
r = requests.get(f"{API}/{path}", params=params, headers=H, timeout=30)
r.raise_for_status()
return r.json()["data"]
# One row per prop: open line and price, closing line and price, and the moves before the start.
train = pd.DataFrame(rows("training-set", league="NHL", **{"from": "2026-11-01T00:00:00Z"}))
# Graded outcomes come from /results: closing line against the box score. Join them on prop_id.
results = pd.DataFrame(rows("results", league="NHL", limit=200))[["prop_id", "actual", "result"]]
train = train.merge(results, on="prop_id")
train = train[train["result"].isin(["over", "under"])] # pushes and voids are not outcomes
print(len(train), "graded props")On the free plan, build the same table from /results and /props/{id}/history; the Python guide shows both calls.
2. Features from movement
import numpy as np
df = train.copy()
df["y"] = (df["result"] == "over").astype(int)
df["line_move"] = df["close_line"] - df["open_line"] # how far the line travelled
df["fair_move"] = df["close_fair_over"] - df["open_fair_over"] # how far the price travelled, in probability
df["moves"] = df["line_moves"] + df["price_moves"]
df["market_p"] = df["close_fair_over"] # the market's own no-vig view at the close
X = df[["market_p", "line_move", "fair_move", "moves"]].fillna(0)
y = df["y"]Price-only moves outnumber line moves several times over (see the movement study), so measure movement in probability, not only in line.
3. Fit, and compare with the market
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import brier_score_loss, log_loss
# Split by time, never at random: the future must not leak into training.
df = df.sort_values("starts_at")
cut = int(len(df) * 0.75)
train_idx, test_idx = df.index[:cut], df.index[cut:]
model = LogisticRegression().fit(X.loc[train_idx], y.loc[train_idx])
p = model.predict_proba(X.loc[test_idx])[:, 1]
market = df.loc[test_idx, "market_p"].clip(0.01, 0.99)
print("model brier", brier_score_loss(y.loc[test_idx], p), "log loss", log_loss(y.loc[test_idx], p))
print("market brier", brier_score_loss(y.loc[test_idx], market), "log loss", log_loss(y.loc[test_idx], market))If the model's Brier score is not below the market's, the market already knows what the model knows. That is the usual first result, and the useful one.
4. Closing line value
# Closing line value: did the price you would have taken beat where the market closed?
def decimal(american):
return 1 + (american / 100 if american > 0 else 100 / -american)
test = df.loc[test_idx].assign(p=p)
bets = test[test["p"] > test["market_p"] + 0.03] # only where the model disagrees by 3 points
clv = (1 / bets["open_over_american"].map(decimal)) - (1 / bets["close_over_american"].map(decimal))
print(len(bets), "bets, mean CLV", round(clv.mean(), 4))Positive mean CLV over many bets is the evidence that matters; a short run of wins is not. Keep reading the journal for daily movement and results.
Questions
Related
Make your first call in a few minutes
Sign up, create a key in the dashboard, and send it as a Bearer token. The free plan needs no card and reads the same data five minutes behind.
Get a free API key