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's seasons block on /players.
  • Weekly projections with deviations, 2025 onward. For those seasons each weekly row also carries projected_points, deviation (actual minus projected) and deviation_percentage. /deviations/{player_id}?season=2025 returns the same data per player. Earlier seasons return weeks_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 /stats carries snap_pct too. 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" in graded_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 reads fantasy_points_ppr first. Point it at fantasy_points_half or fantasy_points, and request ?scoring=half on /stats if 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 history does 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.