Guide
Backtest fantasy football projections against actuals
Quick answer: Sweep /players once (about 18 requests), then grade every player-week where a weekly projection and three prior games exist. In the 2025 season the projection missed by 5.0 to 6.4 PPR points depending on position, about a point better than a trailing-3-game average, and it was the closer guess 56% to 61% of the time. When two projections were within a point, the higher one won only 52.0% of head-to-head pairs.
- Endpoints
- /players, /deviations
- Requests
- ~18 per sweep
- Seasons
- 2020 onward
- Plan
- Free
Why backtest projections at all?
Because every fantasy tool you build sits on top of one assumption: that the projection is a better guess than something simpler. Most people never check. A backtest replays past weeks, asks each method to predict each game before it happened, and scores the misses. It takes one sweep of the API and about 80 lines of Python, and it answers questions you'll otherwise argue about in the group chat for a whole season:
- How far off is a weekly projection, on average, at each position?
- Does it beat the lazy baseline, a player's average over his last three games?
- When the projection says player A over player B, how often is it right, and how does that change with the size of the gap?
That last one is the number that should decide how much you trust your own start/sit calls.
What data does the API have for a backtest?
Two layers, and it's worth being precise about which is which:
- Actuals, 2020 through the current season. Every week's standard, half-PPR and PPR points for every fantasy-relevant player, on
/stats/{player_id}/{season}and inside each player'sseasonsblock on/players. - Weekly projections with deviations, 2025 onward. For those seasons each weekly row also carries
projected_points,deviation(actual minus projected) anddeviation_percentage./deviations/{player_id}?season=2025returns the same data per player. Earlier seasons returnweeks_analyzed: 0, so the projection side of a backtest starts in 2025.
The trick that keeps this cheap: GET /players returns every season's weekly rows for every player, 100 players per page. One paginated sweep, about 18 requests, gets you every number in this guide. Cache it to a file and every rerun is free.
How do I run the backtest in Python?
Set GRIDIRON_API_KEY and run it. The first run takes about 40 seconds for the sweep; after that it reads players.json. Delete the file to pull fresh data.
import itertools, json, os, pathlib, statistics
from collections import defaultdict
import requests
API = "https://api.gridirondata.com/api/v1"
H = {"x-api-key": os.environ.get("GRIDIRON_API_KEY", "YOUR_KEY")}
CACHE = pathlib.Path("players.json")
SKILL = ("QB", "RB", "WR", "TE")
def all_players():
"""One paginated sweep (~18 requests), cached so reruns cost nothing."""
if CACHE.exists():
return json.loads(CACHE.read_text())
players, cursor = [], None
while True:
params = {"limit": 100, **({"cursor": cursor} if cursor else {})}
r = requests.get(f"{API}/players", headers=H, params=params, timeout=120)
r.raise_for_status()
body = r.json()
players += body["players"]
cursor = body.get("next_cursor")
if not cursor:
break
CACHE.write_text(json.dumps(players))
return players
def ppr(row):
for k in ("fantasy_points_ppr", "fantasy_points"):
if isinstance(row.get(k), (int, float)):
return row[k]
return None
def graded_weeks(players, season):
"""Every player-week with an actual, a projection and 3 prior games."""
for p in players:
if p.get("position") not in SKILL:
continue
weeks = (p.get("seasons", {}).get(season) or {}).get("weekly_stats") or {}
history = []
for wk in sorted(weeks, key=int):
row = weeks[wk]
if not isinstance(row, dict) or ppr(row) is None:
continue
if "deviation" in row and row["projected_points"] >= 5 and len(history) >= 3:
yield {"pos": p["position"], "week": int(wk), "actual": ppr(row),
"proj": row["projected_points"],
"trail3": statistics.mean(history[-3:])}
history.append(ppr(row))
players = all_players()
rows = list(graded_weeks(players, "2025"))
print(f"{len(rows)} graded player-weeks\n")
# 1) Average miss: the projection vs a naive trailing-3-game average.
print(f"{'pos':4} {'n':>5} {'proj miss':>10} {'trail3 miss':>12} {'proj closer':>12}")
for pos in SKILL:
r = [x for x in rows if x["pos"] == pos]
pm = statistics.mean(abs(x["actual"] - x["proj"]) for x in r)
tm = statistics.mean(abs(x["actual"] - x["trail3"]) for x in r)
closer = sum(abs(x["actual"] - x["proj"]) < abs(x["actual"] - x["trail3"]) for x in r) / len(r)
print(f"{pos:4} {len(r):5} {pm:10.2f} {tm:12.2f} {closer:12.0%}")
# 2) Head-to-head: when the projection ranks A over B (same position, same
# week), how often does A actually outscore B? Bucketed by the gap.
by_slate = defaultdict(list)
for x in rows:
by_slate[(x["pos"], x["week"])].append(x)
buckets = {"< 1 pt": [0, 0], "1-3 pts": [0, 0], "3-6 pts": [0, 0], "6+ pts": [0, 0]}
for slate in by_slate.values():
for a, b in itertools.combinations(slate, 2):
gap = abs(a["proj"] - b["proj"])
if gap == 0 or a["actual"] == b["actual"]:
continue
key = "< 1 pt" if gap < 1 else "1-3 pts" if gap < 3 else "3-6 pts" if gap < 6 else "6+ pts"
fav = a if a["proj"] > b["proj"] else b
buckets[key][0] += fav["actual"] == max(a["actual"], b["actual"])
buckets[key][1] += 1
print(f"\n{'projection gap':15} {'favorite won':>12} {'pairs':>7}")
for key, (won, n) in buckets.items():
print(f"{key:15} {won / n:12.1%} {n:7}")
# 3) The baseline across every season in the API (2020-2025 actuals): how far
# a trailing-3 average misses for players who were averaging 8+ PPR.
print(f"\n{'season':7} {'player-weeks':>12} {'trail3 miss':>12}")
for season in ("2020", "2021", "2022", "2023", "2024", "2025"):
misses = []
for p in players:
if p.get("position") not in SKILL:
continue
weeks = (p.get("seasons", {}).get(season) or {}).get("weekly_stats") or {}
pts = [ppr(weeks[w]) for w in sorted(weeks, key=int) if isinstance(weeks[w], dict)]
pts = [x for x in pts if x is not None]
for i in range(3, len(pts)):
base = statistics.mean(pts[i - 3:i])
if base >= 8:
misses.append(abs(pts[i] - base))
print(f"{season:7} {len(misses):12} {statistics.mean(misses):12.2f}")
Two design choices matter. No peeking: the trailing-3 baseline only uses games before the week being predicted, and a player needs three prior games to be graded, so the baseline never gets an easier week than the projection. Projected 5+ only: grading a practice-squad receiver projected for 0.8 points just pads the sample with near-zeros that every method gets right.
What did the 2025 backtest find?
Here's the output from a run on September 26, 2026, grading the full 2025 regular season:
2191 graded player-weeks
pos n proj miss trail3 miss proj closer
QB 323 6.41 7.80 61%
RB 624 6.22 7.23 58%
WR 881 5.40 6.26 57%
TE 363 5.02 5.94 56%
projection gap favorite won pairs
< 1 pt 52.0% 7542
1-3 pts 56.9% 14731
3-6 pts 65.2% 16158
6+ pts 78.1% 14876
season player-weeks trail3 miss
2020 1898 6.93
2021 1926 6.84
2022 1875 6.61
2023 1809 6.71
2024 1925 6.67
2025 2171 7.50
Three things jump out.
- The projection wins, clearly but not overwhelmingly. It misses by about a point less than the trailing average at every position (6.41 vs 7.80 at QB, 5.40 vs 6.26 at WR), and it's the closer guess 56% to 61% of the time. Good. Not magic.
- Quarterbacks are the hardest to call, with an average miss of 6.41 PPR points. Tight ends have the smallest miss, 5.02, mostly because their scores are lower to begin with. Compare misses to the typical score at a position before calling one position "more accurate".
- Small gaps are noise. Within a point, the higher projection won 52.0% of 7,542 head-to-head pairs. It takes a 3-point gap to reach about two-in-three, and 6+ points to reach 78.1%. If your start/sit decision is a 0.7-point difference, you are flipping a coin; pick the tie-breaker you believe in, like snap share.
The bottom table is the multi-season sanity check. Using only actuals, the trailing-3 average for players averaging 8+ PPR missed by 6.6 to 7.5 points every season from 2020 to 2025. The naive baseline is stable year to year, so the projection's edge over it in 2025 isn't a fluke of one strange season's baseline.
How do I extend the backtest?
The grading loop is the reusable part. Swap in any predictor that only looks backward and compare its miss to the projection's:
- A blended forecast. Try
0.8 * proj + 0.2 * trail3. If the blend's miss beats the projection's, you've found something the projection underweights. - Snap share as a filter. Every weekly row on
/statscarriessnap_pcttoo. Split the graded weeks by the prior week's snap share and compare how often each group scored under half its projection. It's the best tie-breaker to layer on top of a projection-driven lineup like the one our start/sit optimizer builds. - This season, as it happens. Change
"2025"to"2026"ingraded_weeks. It grades every completed week so far; early in the year the sample is thin, so compare it with the full-season 2025 numbers before drawing conclusions. - Half-PPR or standard. The
ppr()helper readsfantasy_points_pprfirst. Point it atfantasy_points_halforfantasy_points, and request?scoring=halfon/statsif you pull deviations per player.
Want one player instead of the league? GET /deviations/{player_id}?season=2025 returns his week-by-week deltas and avg_deviation in a single call. Our breakouts and busts guide builds on exactly that.
What mistakes ruin a fantasy backtest?
- Leakage. Any feature computed with the week you're predicting (a season average that includes it, say) makes a method look better than it is. Build features from earlier weeks only, as
historydoes above. - Grading only the famous. Testing on 20 stars you remember is survivorship bias. Grade every player who cleared a projection threshold.
- Mixing scoring formats. A PPR actual against a standard projection manufactures a "beat" for every receiver. Keep both sides in the same format.
- Treating DNPs as zeros. A player who didn't play has no weekly row. Don't fill one in; a zero would count as a bust the projection never made.
- Grading the current week too early. Stats land about 11:00 am ET the day after games, and Monday night's the next day. Grade a week after it's complete.
Frequently asked questions
Which seasons have projections I can backtest?
Weekly projections and deviations are available from 2025 onward. Actual weekly points go back to 2020, so you can backtest actuals-only baselines across every season from 2020 to the current one.
How many requests does the backtest need?
One paginated sweep of /players, about 18 requests at 100 players per page. Cache the result to a file and reruns cost nothing, so one backtest fits the free plan's 50 requests a month. Re-sweeping for fresh data every week of the season is about 80 a month, which is Basic territory.
How accurate were weekly projections in 2025?
Across 2,191 graded player-weeks, the average miss was 6.41 PPR points for QBs, 6.22 for RBs, 5.40 for WRs and 5.02 for TEs, about one point better than a trailing three-game average.
Does the guide show raw projected points?
No. It reports only misses, deltas and win rates. The code fetches projections to grade them, but nothing it prints is a projected point total.
How do I backtest half-PPR?
Read fantasy_points_half instead of fantasy_points_ppr in the ppr helper, and request scoring=half on /stats or /deviations so the deviations match.