Skip to content
← Projects

The watchlist that re-ranked itself

A draft tool for my fantasy football league that keeps autopick's list in the right order while eleven other people pick.

Sleeper Agent is the tool i built to draft my fantasy football team. in FPL Draft, twelve friends take turns picking real Premier League players, and once a player is picked nobody else can have him.

last season i finished 12th of 13. the champion let the site's autopick make all fifteen of his picks. when your clock runs out, autopick takes the highest player on your watchlist who's still available. so what should be on that list, in what order, and how do you keep it right while everyone else is picking?

a practice draft against bots, seat 9 of 12. team names made up

that's the war room in a practice draft. every time a bot picks, two things change on the list: who's left, and how likely each player is to still be there when it's my turn again.

Autopick beat my own picks

before writing any code, i graded my own draft from last season. every pick against the average pick of its round, scored by what the player actually did.

bar chart of my 15 picks last season against their rounds: my ten picks net −119 points, the five autopicks +41

four midfielders in the first four rounds. no defender until round 8, in a draft where the defenders other managers took in rounds 4 to 6 were the best value anywhere. and my last five picks were autopick's. those five came out 41 points ahead of their rounds. my ten came out 119 behind.

so the job was to feed autopick a better list.

a few rules decide what "better" means. squads are 15 players. the order snakes: it runs 1 to 12, then 12 back to 1. after the draft each manager sets a lineup every gameweek, plays one rival head to head, and fights over whoever is left on the waiver wire.

snake order for 12 teams; seat 9 picks 9, 16, 33, 40, 57 with waits of 7 and 17

from seat 9 my picks are 9, 16, 33, 40, 57. the waits alternate between 7 and 17 picks. every decision in a snake draft is really a bet about the next wait.

the plan, written the night i started, was "local web app (Vite + React; no deploy needed — runs on the laptop during the draft)." under two hours after the first commit it was a deployed Next.js site with a login, a snake board, a best-available list and a sim mode where bots draft against you. two days later it was draft night.

no deploy needed

to order the list i needed four numbers per player: what he's worth over a season, what he's worth in this format, whether he'll last to my next pick, and what the other eleven will do in between. and i needed some way to know if any of it worked.

How much of last season to believe

everything starts from one number per player, projected season points. it's built from last season, and most of the work is deciding which parts of last season repeat.

fantasy points are mostly appearances, goals, assists, clean sheets and defensive contributions. appearances repeat. finishing mostly doesn't. so the per-90 rate swaps 75% of a player's goal and assist points for what his chances were worth: Understat's penalty-free xG and xA, blended 70/30 across two seasons, and penalties stay with the taker, so penalty xG goes back on top.

scatter of goal and assist points scored against what the chances were worth; Wilson sits 30.5 points above the line

Harry Wilson is the clearest case. he scored 168 points last season, 30.5 of them from finishing above his chances. the board projects him at 102 and ranks him 89th. the public drafts i crawled were still taking him around pick 46.

clean sheets get the same swap, for a Poisson estimate from the goals his team was expected to concede.

then minutes, because a rate is useless without them. the first version used a hand-tuned formula with a cliff at 20 starts. the replacement is a nearest-neighbour model over six seasons of real moves: for a defender who played 70% of the minutes, find the 80 past defenders with the closest share, weight them by distance, and take the weighted median of what they played next.

773 defender season pairs with the nearest-neighbour median line; 91% of minutes last season becomes 78% the next

regulars lose more than you'd think. a defender who played 91% of the minutes last season plays a median of 78% the next. the old formula assumed 88%. for established starters a quarter of their own share is blended back in, so the elite tier isn't projected to lose a fifth of its minutes.

the rest is ordinary shrinkage toward position means and the market, plus a few multipliers that each had to pass the backtest further down. and the build refuses to publish if established players project above 0.95× of last season's points in total, because that means the shrinkage didn't run.

What a player is worth in this format

projected points aren't draft value. a 160-point defender and a 160-point midfielder are different picks, because the midfielder you could get later for free is better than the defender you could get later for free.

so the board ranks by VORP, value over replacement: a player's points minus the points of the best player at his position nobody needs. replacement level comes from real lineups. every team rosters 2 keepers, 5 defenders, 5 midfielders and 3 forwards, and real weekly lineups start about 3.7 defenders, 4.3 midfielders and 2.0 forwards. the board was built for last season's 13 teams, where that makes the replacement defender the 49th best, the midfielder the 57th and the forward the 27th.

bars split at replacement level: Bruno Fernandes 112.1 over, Haaland 109.9, Szoboszlai 75.2, Virgil 90.0

Bruno Fernandes projects 19.6 more points than Haaland. after replacement the gap is 2.2, because a replacement forward is worth 17 points less than a replacement midfielder. Virgil and Szoboszlai project within two points of each other, and Virgil is worth 14.8 more.

below the starting slots, value slides toward a flex baseline over one round of the league. an elite forward is always somebody's first forward. a fourth defender is just an outfielder. each position is also cut into tiers by exact 1-D clustering, and the war room flags the last player in a tier, because that's when waiting stops being free.

i also built a roster-aware version of value that priced each pick by what it added to my likely lineup, so a second or third forward was valued against every outfielder by projected points. the simulator liked it best of anything. it comes back in the backtest.

the last number is the sleeper score: how far the model's value sits above what the market pays for him, at his position, weighted by how much evidence the projection has.

the war room player drawer for Tyrick Mitchell: projected 134, board #14, official rank 395, taken around pick 146 in 1,924 drafts

Tyrick Mitchell is the textbook sleeper. the board has him 14th. the official rank has him 395th. 1,924 crawled drafts took him around pick 146. on draft night autopick took him for me at 81.

Will he still be there at my next pick?

value says who's best. it doesn't say who to take now, because the best player might still be there in seven picks and the second best might not.

to answer that i needed to know when players actually get drafted, and the official rank isn't it. a crawler pulled public FPL Draft leagues: 14,770 complete drafts and 1.8 million picks by draft morning. the 3,198 drafts with ten or more teams give each player a pick-time curve, the chance he's gone by any pick.

short drafts made that harder than it sounds. a 10-team league ends at pick 150, so "never drafted there" isn't evidence a player is bad. a trimmed mean read it that way and pulled the tail 30 to 60 picks early. the curves use a weighted Kaplan–Meier estimate instead, which treats those leagues as censored.

the survival number on the board comes straight from the curve. if F(k) is the chance a player is gone by pick k, the chance he survives from now to my next pick is

S = (1 − F(next−1)) / (1 − F(now−1))

it's conditional on him being there right now, which is the only case the clock cares about. here it is at pick 9, recomputed from the draft-morning board with the eight players who actually went first removed.

pick-time curves for Gabriel and Virgil with guides before picks 9 and 16: Gabriel 50% to 90%, Virgil 0% to 8%

Gabriel was gone by pick 8 in half of the crawled drafts, and by pick 15 in 90%. given he's still there at 9, he has a 20% chance of lasting to 16. Virgil has 92%.

that turns into an order through pickScore, a one-step lookahead. a player's score is his value now plus the expected value of the best player i can still get at my next pick if i take him. the expectation walks down the 25 best other candidates: each one counts with the chance he survives and everyone better than him doesn't.

stacked bars of value now plus expected best at 16: Gabriel 176.4, Virgil 176.1, Guéhi 174.8, Szoboszlai 164.9

the top two work out by hand. take Gabriel now, and Virgil is back at 16 92% of the time: 90.0 × 0.92 = 82.8, plus the 8% where he's gone and Guéhi, a lock to last, fills in: 0.08 × 85.9 = 6.9. that's 89.7, for a score of 176.4. take Virgil now, and Gabriel is back only 20% of the time: 86.7 × 0.20 + 0.80 × 85.9 = 86.1, for 176.1.

Virgil has the higher value. Gabriel has the higher score, by 0.3. on the night, autopick took Gabriel for me at 9, and Virgil was still there at 16.

the recording at the top runs the same math in a practice draft. after my pick the horizon jumps to my next turn and the bars drop. then every bot that passes on a player fills his bar back up.

it doesn't simulate the rest of the draft. one step ahead was enough to beat plain VORP in the backtest.

What the other eleven will do

survival from a market curve treats the room as one average manager. sim mode needed bots that behave like a room of people with rosters, so the opponent model is fit on the same 1.8 million picks and splits each pick into two questions.

first, which position? each round has a prior from what the room actually takes, and a quota term overrides it wherever there's enough data.

line chart by round of the chance a manager missing a keeper or forward takes one next; the keeper line jumps after round 12

a manager with no keeper takes one about a quarter of the time in round 10, 40% in round 12, about half in round 13 and always in round 14. managers ignore the gap for most of the draft, then all fill it at once.

second, how far down the board does the pick land? the model learned that separately for humans and for autopick, which made about 30% of all picks.

share of picks that were the top legal player: autopick 79%, 79%, 56% by stage; humans 24%, 4%, 5%

they behave nothing alike. through round 8 autopick takes the top-ranked legal player 79% of the time. humans in the middle rounds do it 4% of the time and reach a median of 27 ranks. that gap is the whole reason a well-ordered watchlist is worth building. each seat's mix of the two updates as the live draft shows who's asleep.

i also tried a position-run term, the idea that one keeper pick triggers three more. keeper runs are real. but every version made held-out predictions slightly worse, because quota pressure already explains them, so it went. on 251 held-out drafts the final model cut log-loss per pick from 4.19 to 3.12 against the old ADP softmax.

A backtest that's allowed to say no

by midday on august 18 the engine had a lot of clever parts and a simulator that said they worked. a 600-draft simulation credited a roster-aware value function with +16 points and the lookahead with +10.

the problem was the grader. that simulator scored squads with my own projections, and a model grading itself mostly agrees with itself.

so scripts/backtest_draft.py rebuilds an august 2025 board using only data that existed then: 2024-25 stats, the same minutes model and shrinkage, and only players who were in the league the next season. it runs 300 snake drafts against a room that drafts like the real ones do, then scores every squad's best legal XI by what those players really scored in 2025-26.

average finish of 13: hindsight 1.00, VORP plus lookahead 4.46, plain VORP 4.61, roster-aware value 6.06, draft like the room 8.13

hindsight is the ceiling and finishes first every time. a random seat finishes 7th on average. the roster-aware value that the simulator liked best was the worst engine policy here, about 1.5 places a season worse than plain VORP. it was deleted the same afternoon.

the lookahead earned its place. the chart is the 2025-26 replay from seat 12. across all three recent seasons, joined on FPL's stable player code, it averaged 5.03 against 5.28 for plain VORP.

boring won. mostly

after that the harness was the gate for every idea. anything could be tried. it shipped only if it improved finishes on seasons it wasn't fit on: fit on 2019–23 season pairs, validated on the 2023–26 replays.

the experiment ledger: four multipliers shipped, three signals rejected, each with its training and finish-replay numbers

the late-minutes trend is the useful rejection. it had the best training numbers of the whole search: players who faded late last season delivered 0.89× on a big sample. the replays hated it. the minutes model and the market had already priced the role change, so acting on it again counted it twice.

the ledger closed the day before the draft. the board that went into the draft averaged a 3.91 finish of 13 in the replays, about half the time in the top three, and won 18% of titles.

Draft night: keep the list right, let autopick click

the research from the first night had mapped how the draft site works. reading the draft is a public poll. and the watchlist is a list the site will act on by itself. the research doc called it "the safe autopilot": keep the watchlist sorted to your own board and the site's autopick becomes your agent. a crash or a dropped connection can't cause a missed pick. the worst case is the list as of the last write.

so i wrote a draft guard the day of the draft. the version in the repo does one thing. every five seconds it reads the draft. when anything changed, it rebuilds my legal pool, scores the top 60 with pickScore, and posts the whole ordered list to my watchlist in one request.

near-ties within 2.5 points of value go to the market's order, because inside that gap the model can't tell players apart. and legalPositions only offers a position if every other quota can still be filled with the slots left, so the list can never steer autopick into an illegal squad.

the plan was written for seat 12 of 13 on a friday. the draft ran on a wednesday evening from seat 9 of 12. the guard reads the seat from the live draft, so the new seat cost nothing.

my watchlist top three after picks 9, 10, 15 and 16 from the guard log; Virgil stays first until autopick takes him at 16

that's the real list from the guard's log, between my first two picks. Szoboszlai went at 10 and Guéhi at 15, and Virgil sat at the top the whole time. the guard rewrote the list 127 times between 5:38 and 7:25.

two rules went in mid-draft, after my second pick, when the list started stacking a third and fourth defender. the log shows the guard restarting around 5:47 and 5:50, and the top three going from O'Reilly, Truffert, Tarkowski to Enzo, Gakpo, Tavernier. after two defenders, another defender needed a clear margin to stay on top: a penalty of 8 points, and 25 after three. and once only forwards and keepers were left, the list alternated them, so the keepers couldn't fill the slots while the forwards ran out.

autopick made 13 of my 15 picks from that list. by round 10 it had five defenders: Gabriel, Virgil, Tarkowski, Mitchell and Thiaw. last season my first defender came in round 8.

Hold good players, judge them by their chances

a draft is one night. a season is 38 deadlines, and last season's autopsy had a second half: i dropped good players. Van Hecke went to the waiver wire in gameweek 5 and scored 132 more points for someone else.

before writing any season code, a season simulator answered what the squad needed. it drafts with the engine, then plays 38 real gameweeks of an old season: weekly best lineups, waiver swaps within quotas, priority by reverse standings, and opponents who manage their teams.

average finish of 13: draft then leave it 11.91, manage by points 7.46, manage by luck-stripped form 5.81

a squad drafted well and then left alone finishes near the bottom. the waiver wire wears every advantage down. and judging players by luck-stripped form instead of raw points was worth about 1.7 places in every season tested.

luck-stripping works per scoring component. goals and assists become xG and xA. a clean sheet becomes e^(−xGC). the defensive bonus becomes a smoothed hit rate with four pseudo-starts at the position prior, instead of whatever one match's tackle count said. i calibrated the simulator to my league's real transaction counts and lineup mistakes, and the sweep settled the policy: a 10-gameweek window, form weight capped at half, at most two claims a week, each clearing +0.5 points a gameweek.

that runs as a Cloudflare Worker on two crons, a heartbeat every six hours and every 20 minutes through the hours deadlines fall in. the lineup job re-derives the best legal XI every run and only writes when the saved lineup differs, so a late injury or a hand edit fixes itself on the next run.

waiver claims are layered so one bad number can't gut the team. a player in the current best XI can only be dropped if he's ruled out. top-40 players aren't dropped unless they've been absent four gameweeks. nothing is dropped for a pure upgrade before gameweek 4. trades are only ever suggested, because a bot filing trades burns goodwill.

decision feed entry: waivers hold, no free agent clears +0.5 points a gameweek over a droppable player

every run writes what it did and why to a decision feed. this hold is the early-season rule doing its job: nothing on the wire cleared +0.5 a gameweek over a player it was allowed to drop.

players who arrive from abroad mid-season have no Premier League history, so a ridge regression over 4,370 player-seasons translates their per-90 from the league they left. out of sample, on 117 moves, it beat carrying the rate across unchanged by 13.4%. the Worker is also the only thing allowed to refresh the Premier League login, since the token rotates on every refresh. the nightly data refresh runs on my mac mini, and the board is public at pl.arne.ai/rankings so the league can argue with it.

Six weeks in

my score against my opponent's for gameweeks 1 to 6: five wins, then a loss; back five 131 points, forwards 9 after gameweek 5

five wins, then a 24–36 loss in gameweek 6, and still top of the table. most of it came from the back five autopick took: 131 points between them after gameweek 5, against nine from the forwards.

last season autopick beat me. this season i let it.