Guide

Build a start/sit model with snap share and projection deltas

Quick answer: Pull this week's projections from /projections/{week}, then one /stats/{player_id}/2026 call per player for snap share and deviation. Score each player as projection × (1 − dud rate) + a capped deviation nudge. It matters because projections within a point of each other picked the winner only 52% of the time in 2025, while part-time players (under 50% of snaps) busted 25.2% of the time vs 19.5% for full-timers.

Endpoints
/projections, /stats
Requests
~9 per decision
Plan
Free
Language
Python

Why isn't the projection alone enough for start/sit?

Because the calls that keep you up on Saturday night are the close ones, and close projections are nearly coin flips. We backtested the 2025 season through this API: for every pair of players at the same position in the same week, how often did the player with the higher weekly projection actually outscore the other one?

How often the higher projection won, 2025 (QB/RB/WR/TE projected 5+ PPR)
Projection gapHigher projection outscoredPairs
Under 1 point52.0%7,542
1 to 3 points56.9%14,731
3 to 6 points65.2%16,158
6+ points78.1%14,876

Read the top row twice. When two players are within a point of each other, the "right" answer by projection wins 52% of the time. That's not a model telling you anything; that's a coin with a slight lean. So a good start/sit model doesn't try to out-project the projection. It asks a different question: which of these two is more likely to give me a dud?

This guide builds that model in about 60 lines of Python. It's a different tool from our weekly start/sit optimizer, which fills your lineup slot by slot from projections. Use the optimizer to set the lineup; use this model when the optimizer's top two options are separated by a rounding error.

What does snap share add that projections miss?

Snap share is the percentage of his team's offensive plays a player was on the field for. It is the coach's vote on a player's role, and it's cast before the box score. Here's what it did to the floor of every RB, WR and TE projected for 5+ PPR in 2025, grouped by the snap share he played the week before:

Dud rate by the prior week's snap share, 2025 (RB/WR/TE projected 5+ PPR)
Prior-week snap shareScored under half his projectionPlayer-weeks
Under 50%25.2%488
50% to 80%22.7%884
80% or more19.5%647

A part-time player busts about one week in four. A full-time player busts about one in five. That gap is small per week and large over a season: it's roughly one extra zero every 17 starts, and a zero in the flex is how you lose a matchup you were projected to win. The projection already knows a lot about talent and matchup. What it prices less cleanly is how many chances a player gets to hit his number, and that is exactly what snap share measures.

The third ingredient is deviation: actual points minus projected points, averaged over the season. /deviations and /stats both carry it. A player who keeps beating his number is either being under-projected or is on an offense that's outrunning expectations. It's noisy early in the year, so the model caps it and weights it lightly.

Which endpoints does the model call?

  • GET /projections/{week}?position=RB: this week's projected PPR points and injury_status for every player at a position. One call covers the whole league at that position.
  • GET /stats/{player_id}/2026: every week's actual PPR points with snap_pct and deviation joined on. One call per player gives you both the role signal and the beat-the-number signal.
  • GET /snaps/{player_id}?trend=4 is the alternative if you only want snap share: it returns season_snap_pct_avg, recent_snap_pct_avg and trend_delta in one response.

Request budget: a seven-player bench decision costs 2 projection calls (RB and WR) plus 7 stats calls, so 9 requests. Run it for your whole 15-man roster every week and you're at about 19. The free plan's 50 requests a month covers that five times over.

How do I build the model in Python?

Set GRIDIRON_API_KEY, edit ROSTER and WEEK, and run it. Player IDs are First Last#POS; the script URL-encodes them for you.

import os, statistics, requests
from urllib.parse import quote

API = "https://api.gridirondata.com/api/v1"
H = {"x-api-key": os.environ.get("GRIDIRON_API_KEY", "YOUR_KEY")}
WEEK, SEASON = 3, "2026"
ROSTER = ["Jaylen Warren#RB", "Aaron Jones Sr.#RB", "Will Shipley#RB",
          "Rome Odunze#WR", "Davante Adams#WR", "DJ Moore#WR", "Tre Tucker#WR"]

# How often a player scored under half his projection, by the snap share he
# played the week before (RB/WR/TE projected 5+ PPR, 2025, from this API).
DUD_RATE = [(80, 0.195), (50, 0.227), (0, 0.252)]

def get(path, **params):
    r = requests.get(f"{API}{path}", headers=H, params=params, timeout=30)
    r.raise_for_status()
    return r.json()

# 1) This week's projections, one call per position on the roster.
proj = {}
for pos in sorted({pid.split("#")[1] for pid in ROSTER}):
    for row in get(f"/projections/{WEEK}", position=pos, season=SEASON)["projections"]:
        proj[row["player_id"]] = row

# 2) One /stats call per player: weekly actuals, snap_pct and deviation together.
def profile(pid):
    weeks = get(f"/stats/{quote(pid)}/{SEASON}")["weekly_stats"]
    played = [weeks[w] for w in sorted(weeks, key=int)]
    snaps = [w["snap_pct"] for w in played if w.get("snap_pct")]
    devs = [w["deviation"] for w in played if "deviation" in w]
    last = snaps[-1] if snaps else 0.0
    return {
        "snap": last,
        "trend": last - statistics.mean(snaps[:-1]) if len(snaps) > 1 else 0.0,
        "avg_dev": statistics.mean(devs) if devs else 0.0,
        "dud": next(rate for floor, rate in DUD_RATE if last >= floor),
    }

board = []
for pid in ROSTER:
    p = proj.get(pid)
    if not p or p.get("injury_status") in ("Out", "Doubtful"):
        print(f"skip {pid}: {p and p.get('injury_status')}")
        continue
    f = profile(pid)
    # Projection is the ceiling; snap share prices the floor.
    f["score"] = p["projected_points"] * (1 - f["dud"]) + 0.25 * max(-4, min(4, f["avg_dev"]))
    f.update(pid=pid, pos=p["position"], status=p.get("injury_status") or "Healthy")
    board.append(f)

board.sort(key=lambda f: -f["score"])
top = board[0]["score"]
print(f"{'player':24} {'snap%':>5} {'trend':>6} {'beat':>6} {'dud':>5} {'gap':>6}  status")
for f in board:
    print(f"{f['pid']:24} {f['snap']:5.0f} {f['trend']:+6.0f} {f['avg_dev']:+6.1f} "
          f"{f['dud']:5.0%} {f['score'] - top:+6.1f}  {f['status']}")

The scoring line is the whole model: projection × (1 − dud rate) + 0.25 × capped deviation. The projection sets the ceiling. The dud rate shaves it by how often a player with that role has laid an egg. The deviation nudges players who keep beating their number, capped at ±4 so one monster week doesn't swamp everything.

What does the output look like?

This is a real run from Week 3 of the 2026 season, made on September 26, 2026 with data through Week 2. The gap column is each player's score minus the top score, so it tells you how far apart two options are without printing anyone's projection:

player                   snap%  trend   beat   dud    gap  status
Davante Adams#WR            65    +11   +9.5   23%   +0.0  Healthy
Jaylen Warren#RB            71    +34   -2.2   23%   -0.9  Questionable
Aaron Jones Sr.#RB          81    +35   -1.3   20%   -2.2  Healthy
DJ Moore#WR                 31    -45   -1.7   25%   -3.8  Questionable
Tre Tucker#WR               59    -12   +4.6   23%   -4.5  Healthy
Rome Odunze#WR              84    +36   -3.4   20%   -6.1  Healthy
Will Shipley#RB             51    +33   +1.9   23%  -10.2  Healthy

How to read it, using that Week 3 board:

  • Warren vs. Jones is a real decision, not a formality. They're 1.3 points apart on score. Jones played 81% of Minnesota's snaps in Week 2 (+35 on his Week 1 share) and is Healthy; Warren is Questionable. If Warren's status is still open near kickoff, Jones is the floor play.
  • DJ Moore's snap share fell 45 points to 31%, and he's Questionable. That's the profile the dud table warns about: a part-time role and an injury tag.
  • Rome Odunze is the model disagreeing with the box score. His points were flat (he's −3.4 against his projections), but his role jumped to 84%. The model docks him for missing his number; the snap trend says the points are coming. That's a hold, and a reason to check again next week.

Your board will look different on your publish day, because the script always pulls the latest completed week. That's the point: the same 60 lines re-price every decision each week.

How should I tune it for my league?

  • Half-PPR or standard: add scoring="half" to both the /projections and /stats calls. Deviation follows the scoring you ask for.
  • Need a ceiling, not a floor? If you're a big underdog this week, flip the logic: prefer the player with the higher projection and the bigger positive deviation, and ignore the dud penalty. Floors win the weeks you're favored in.
  • Early season: with two or three weeks of data, trust the snap share more than the deviation. Deviation needs about six weeks before its average means much.
  • Byes and injuries: the script skips anyone listed Out or Doubtful. For a Questionable player, re-run it after the Sunday injury refresh (about 12:45 pm ET for the early games) before you set your lineup.

For the week-by-week role changes behind the trend column, our weekly usage report shows how we read snap-share risers and fallers, and why a DNP isn't a demotion.

What can't this model tell me?

It can't see a role change that hasn't happened yet. If a starter is ruled out on Sunday morning, his backup's prior-week snap share understates Sunday's role; that's where you override the model by hand. It also doesn't know game script, weather or a quarterback change. And it's honest about what close calls are: at a 1-point gap, even a better model is choosing between two outcomes that are close to 50/50. The goal is to lose those flips less often by favoring the player who's on the field more, not to pretend they aren't flips.

Frequently asked questions

Why not just start the higher projection?

In a backtest of the 2025 season, the higher projection outscored the other player only 52.0% of the time when the two were within 1 point, and 56.9% within 1 to 3 points. Close calls need a tie-breaker, and snap share is the one that measures opportunity.

How many API requests does the model use?

One /projections call per position on your roster plus one /stats call per player. A seven-player decision is about 9 requests; a full 15-man roster is about 19, which the free plan's 50 a month covers about twice; checking a full roster every week of the season takes Basic.

Does the model print projected points?

No. It prints snap share, the snap trend, the average deviation, the dud rate and each player's gap to the top option, which is enough to rank a decision.

Does it work for half-PPR leagues?

Yes. Add scoring=half (or standard) to the /projections and /stats calls; the deviation values follow the scoring format you request.

When should I run it?

After the week's stats land, about 11:00 am ET the day after games, and again after the Sunday injury refresh if a player on your bench is Questionable.