A Fantasy Premier League analytics and squad optimization platform. It treats players like financial instruments — combining price momentum, volatility, and portfolio theory with sports analytics (injury risk, form, fixture difficulty) to select a risk-adjusted squad.
Live at fplquant.sidharthjoly.com — a static frontend on GitHub Pages talking to a FastAPI backend on an Oracle Cloud VM.
- Data pipeline — FPL API ingestion into SQLite via SQLAlchemy, schema managed by Alembic. One row per player-fixture, so a double gameweek keeps both matches.
- Form analysis — EWMA of points and underlying stats (xG, xA, ICT).
- Injury risk — age, position, minutes load, and Transfermarkt injury history combined into a per-player risk score.
- Market layer — price and ownership momentum, points volatility, and teammate correlation, computed from per-gameweek time series, plus FPL's own published price-change forecast where the game provides one.
- Points engine — fitted team goal rates, allocated top-down to players, scored through FPL's actual scoring table, Defensive Contribution included. Produces a per-rule breakdown and a full distribution rather than a single number.
- Multi-gameweek horizon — projections fixture by fixture over the next N rounds, including double and blank gameweeks.
- News layer — FPL's published player news parsed for the one thing its next-round percentage cannot express: when a player is back. Availability is then projected per gameweek and per club kickoff, so a ban expiring on the Tuesday stops writing a player off for the next two months. Pinned to FPL's own number for the next round, so nothing is discounted twice. The same news is read a second way, as a team-sheet decision rather than a fitness one: a doubt costs more start probability than it costs availability, because a manager being careful with a player benches him rather than dropping him.
- Squad optimizer — ILP selection (PuLP) under budget, position, and club-count constraints, with an optional Sharpe-style risk-adjusted objective.
- Multi-period planner — one integer program over the whole horizon: squad, starting XI, captain, transfers, free-transfer banking, hits, and chip timing, all as decision variables. All four chips, one of each per half-season, with the free hit's squad reverting the week after.
- Monte Carlo — gameweeks simulated match by match, so teammates correlate structurally. Gives floors, ceilings, haul odds, and squad-level risk.
- Fixture-adjusted predictions — opponent strength, venue, and playing chance folded into expected points.
- Lineup and rotation — club formations inferred from who actually gets picked, plus rest days and minutes load, combined into a start-probability nudge on expected points, and surfaced per player as odds to be named in the XI (with the fitness news applied as a hard gate on top).
- Transfer planner — pulls a real FPL team by ID and searches the horizon from the squad you actually own, the way an engine searches from a position: a ranked list of lines, each scored against banking the transfer, accounting for -4 hits, wildcards and free hits, and valued on the starting XI rather than on all fifteen.
- Player similarity — per-90 stat vectors, cosine k-NN, and PCA/t-SNE projections for finding comparable or cheaper alternatives.
- API and dashboard — FastAPI backend with Redis caching, plus a no-build-step frontend (optimizer, player explorer, market ticker, transfers, and a multi-gameweek planner). Every player shown anywhere — on the pitch, in the dugout, in a transfer suggestion, on the market tape — opens their explorer profile.
The optimizer's starting-XI pitch view uses jersey icons colored by each club's real kit. These were captured before the 2026/27 season kicked off, on synthetic gameweek history layered over real FPL players and teams. The season is under way now, so the numbers on the live site are real results and won't match what's shown here. There is no Planner screenshot yet.
Requires uv and Python 3.13+.
uv sync # install dependencies into .venv
cp .env.example .env # optional — defaults work out of the box
uv run alembic upgrade head # create data/fplquant.db and apply the schema
uv run fplquant-ingest # pull live data from the FPL API (~1-2 min)
uv run fplquant-ingest-news # fetch football news feeds for return dates
uv run fplquant-optimize # select an optimal 15-man squad within budget
uv run fplquant-api # serve the dashboard at http://localhost:8000Anything derived from per-gameweek history — the form leaderboard, the market layer, and player similarity — is empty until matches have been played, since the FPL API only publishes gameweek history once the season is underway.
Predictions handle the same scarcity by credibility weighting rather than by
switching over at some threshold. A player's EWMA form is blended toward FPL's
own ep_next in proportion to how many appearances back it, so with no history
the estimate is pure ep_next, and form takes over as evidence accumulates.
This matters more than it sounds: after one gameweek a player's EWMA form is
exactly that gameweek's score, so taken at face value it would have the
optimizer rebuild the whole squad around last week's highest scorers.
The lineup signals behave the same way. Inferred formations are shrunk toward a 4-4-2 prior, start rates toward a positional prior, and the rotation adjustment is expressed as a multiplier centred on 1.0 that does nothing at all until there is something to say. A formation always names ten outfield players, which sounds too obvious to state until it isn't: rounding each position on its own turned Brentford's 3.44/5.11/1.44 — exactly ten — into "3-5-1", and a club's shape is only counted from matches where a whole eleven is on record, because a player's history follows him through a transfer and would otherwise credit his old fixtures to his new club. Early in the season the part that actually carries information is rest days, which come from the fixture calendar rather than from match history.
The points engine follows the same discipline one level up. Team goal rates start at a prior and are moved by the match record in proportion to how many matches back it; player scoring rates start at a price-implied prior and give way to a player's own per-90 numbers as minutes accumulate; and start probabilities start at a softmax over price and give way to who actually gets picked. None of it switches over at a threshold — with an empty database the whole engine still produces a usable projection, built entirely from priors.
Defensive actions are the one rate with no price term in its prior, and deliberately: Defensive Contribution rewards the ball-winning midfielders and centre-backs that the price ladder rates lowest, so scaling that prior by cost would get the sign backwards. It shrinks toward a positional prior instead, and reaches even weight after two full matches — defensive actions arrive at eight or so a game where goals arrive at a fraction of one, so a couple of matches is already a usable rate.
That positional prior is not the pool's average rate, which would be the obvious choice and the wrong one. Clearing a threshold is convex in the rate, so the average player's chance of clearing it is not the chance implied by the average player's rate — plugging in the mean under-credits defenders by about a fifth, which matters most in the weeks when nobody has a rate of their own yet and every player is on the prior. The constants are solved for the right thing instead: the rate at which the model reproduces the share of players who actually cleared the bar, over the real distribution of minutes played.
| Command | Description |
|---|---|
fplquant-ingest |
Pull players, teams, fixtures, and gameweek stats from the FPL API |
fplquant-ingest-injuries |
Resolve Transfermarkt player matches and sync injury history |
fplquant-form |
Print the EWMA form leaderboard |
fplquant-optimize |
Select an optimal 15-man squad and starting XI |
fplquant-risk |
Print the injury risk leaderboard |
fplquant-lineup |
Next-match start probabilities, rest, and inferred club formations |
fplquant-market |
Price/ownership momentum, volatility, and teammate correlation |
fplquant-similar |
Find players most similar to a given player |
fplquant-projection |
Export a PCA/t-SNE projection of the player space |
fplquant-project |
Multi-gameweek expected points, with team ratings and simulation |
fplquant-plan |
Plan squad, transfers, captaincy, and chips over a horizon |
fplquant-import-history |
Import past FPL seasons from the public archive, for training |
fplquant-train-minutes |
Train and evaluate the learned start-probability model |
fplquant-backtest |
Replay past gameweeks and score the engine against a point-in-time baseline |
fplquant-backtest --lineups |
Score the squad, XI and captain the optimizer would actually have fielded |
fplquant-plan --chip-week |
Rank the gameweeks in the horizon by what a chip is worth in each |
fplquant-api |
Run the FastAPI backend and dashboard |
Injury ingestion is deliberately separate from the main ingest: it scrapes Transfermarkt and is rate-limited.
uv run fplquant-optimize --budget 100.0 --max-per-club 3
# Risk-adjusted: maximizes expected_points * (1 - injury_risk) / (1 + volatility
# penalty) instead of raw expected points — see src/fplquant/risk/adjusted.py
uv run fplquant-optimize --risk-adjusted --risk-aversion 1.0 --injury-weight 1.0
uv run fplquant-lineup # start probabilities
uv run fplquant-lineup --shapes # inferred club formations
uv run fplquant-similar "Haaland" # most similar players
uv run fplquant-similar "Haaland" --cheaper-only # cheaper alternatives
uv run fplquant-projection --method pca --output player_projection.json
# Multi-gameweek projections. Blanks show as "—", doubles as "*"
uv run fplquant-project --horizon 5
uv run fplquant-project --ratings # fitted team goal rates
uv run fplquant-project --simulate --seed 1 # floors, ceilings, haul odds
uv run fplquant-project --explain "Haaland" # the model's full reasoning
# Plan a horizon, letting the solver decide when to play the chips
uv run fplquant-plan --horizon 5
uv run fplquant-plan --team-id 1234567 --free-transfers 2 \
--chips wildcard bench_boost triple_captainAlongside the 15-man squad, both the CLI and the /optimize endpoint return a
starting XI: the best of FPL's eight legal formations for that squad
(src/fplquant/optimizer/starting_xi.py
— an exhaustive search, since points are additive per position), a captain and
vice-captain, and the point value of playing Bench Boost or Triple Captain that
week.
The original expected-points model is an EWMA of a player's past FPL points, multiplied by a fixture-difficulty factor. That is a reasonable first pass and it has a structural problem: points are a consequence, not a quantity in their own right. A defender's expected points are dominated by the probability their side keeps a clean sheet, which is a property of the opponent's attack and has nothing to do with the defender's own scoring history — so scaling their past points by one blunt multiplier moves the wrong term.
src/fplquant/engine/ models the components instead,
top-down, in four layers.
Team goal rates (rates.py) fit each club's
attacking and defensive multipliers from the results so far, as a damped
multiplicative fixed point: given what the model currently believes about the
opponents a club faced, how many goals should they have scored, and how many
did they? Because each club's correction depends on its opponents' current
ratings, the passes are iterated until they settle, so beating three
relegation candidates is not mistaken for beating the top three. Expected goals
carry more weight than the scoreline, since xG settles over a handful of games
where goals take most of a season.
Fitting forty parameters against a dozen matches is underdetermined, not merely
noisy, so the multipliers start at a prior and the record moves them by a
credibility weight. The prior is itself two readings blended: FPL's published
team ratings, and the combined price of a club's fifteen most expensive
players. The second is there because the first is unreliable — FPL's granular
strength_attack_* columns are zero for every club for much of preseason,
and the coarse strength_overall_* rating has four distinct values across
twenty clubs. Squad value is continuous, never missing, and reprices itself.
Minutes (minutes.py) estimate the
absolute probability of starting, under the constraint that a club starts
exactly eleven players: probabilities within a position group are normalised to
the slots that group's inferred formation fills. That constraint is what makes
the estimate self-correcting — an injury to a first-choice striker
redistributes his minutes to the rest of the forward line rather than
evaporating. The prior is a softmax over price within each club's position
group, since FPL prices are compressed but their ordering is informative.
Usage (usage.py) splits a club's goals
among its players by credibility-shrunk per-90 rates, normalised so the shares
sum to one. The model is therefore internally consistent: sum a club's players'
expected goals for a fixture and the club's expected goals come back out.
Scoring (scoring.py) converts all of
that into points through FPL's rules — clean sheet probability as exp(-λ)
gated on 60 minutes, the goals-conceded penalty as E[floor(K/2)] rather than
floor(E[K]/2), saves from the opponent's rate, bonus from BPS history.
Defensive Contribution — the two points FPL added in 2025-26 for reaching a
threshold of defensive actions — is modelled as a threshold rather than a rate,
because that is what it is. Scoring the rule at a player's average would give
a defender averaging 9.5 actions nothing and one averaging 10.5 the full two
points, when both cross the line about half the time; instead the actions are
taken as Poisson over the minutes played and the model asks for
P(actions >= threshold). It is worth about 0.42 points an appearance to a
defender — a sixth of what defenders score — and it separates the ball-winning
centre-backs and holding midfielders that nothing else in the model can tell
apart. The thresholds were not looked up but measured out of the archive: take
defenders with no goals, assists, bonus or cards and subtract the points
already accounted for, and the residual is exactly 0 below 10 actions and
exactly 2 at or above it, with no exceptions in 170 observations. The same test
puts midfielders at 12.
fplquant-project --explain prints the whole chain for one player:
Gonzalo (FUL, £6.0m)
starts 97% of the time, 82 expected minutes; rate estimate is 0% their own
record, the rest price-implied
takes 23.4% of their club's goals and 13.2% of the assists, at 0.34 goals
and 0.12 assists per 90
GW2 vs SUN (A): 3.82 pts [xG for 1.39, against 1.41, clean sheet 24%]
appearance +1.83 goals +1.30 assists +0.40 clean sheet +0.00
conceded +0.00 saves +0.00 bonus +0.40 cards -0.12
FPL publishes two things about a player's fitness: a percentage
(chance_of_playing_next_round) and a sentence (news). The model used only
the percentage, and applied it to every gameweek in the horizon. So a player
serving a ban that expired on the Tuesday was worth exactly zero points over the
next five rounds — and zero is not merely a low rank. The multi-gameweek
planner's candidate pool is trimmed on horizon value, so a player worth nothing
is one it can never choose, whatever happens after the ban ends.
The sentence is what fixes that, because it is the only place a date appears. It is also not really free text: five templates covered all 118 non-empty news strings in the pool, with none left unparsed.
Knee injury - Unknown return date
Ankle injury - Expected back 14 Sep
Thigh injury - 75% chance of playing
Suspended until 19 Sep
Has joined Paris Saint-Germain permanently
src/fplquant/news/ reads them and projects availability per gameweek, against
each club's own kickoff — a round is spread over three or four days, so a ban
ending on the Saturday clears a side playing Sunday and not one playing Friday,
and both are the same gameweek. Two rules keep it honest:
- The next round is FPL's number, verbatim. Not recomputed, taken. FPL's percentage is the press-conference summary this parses, so discounting it a second time would charge the same evidence twice — the mistake the lineup and fixture layers each warn about at length. The layer can only ever say something about rounds two onward.
- It can only give availability back, never take it away. Every projection is floored at the published number. A spurious zero silently deletes a fit player from the squad; a spurious one merely makes the model optimistic about somebody FPL has already flagged.
The three shapes of absence run on different clocks, and blending them would be the whole mistake. A suspension ends on a date that is a fact, so it steps to full availability the moment it expires. An injury with a stated return date ends on a club's estimate, and estimates slip late far more often than early, so the date is treated as optimistic and recovery ramps in over a window that widens the further out the forecast is. A doubt — a percentage, no date — is a statement about one match, so it recovers over a window set by the grade: a 75% knock clears in a fortnight, a 25% one in six weeks. Everything else keeps the published number in every gameweek, including the commonest case of all, an injury with no return date given.
The numbers are threaded into compute_player_usage rather than multiplied onto
a finished projection, and that is not an implementation detail. Shares are
normalised within a club, so a striker coming back has to take goal share off
whoever absorbed it while he was out; scaling one side of that trade after the
fact would count those goals twice. On the live pool this moves 29 players'
availability and 156 players' five-gameweek totals — the second number is larger
than the first precisely because of the teammates.
What it changes downstream, measured rather than assumed: the multi-gameweek
planner's candidate pool shifts by one player, and a premium ruled out for a
single round flips from sold to held. On today's pool nobody with a dated
absence is good enough for that to bite — the ten players with return dates are
fringe ones, and selling a £5.0m defender who misses a week is still right. The
squad advice therefore does not change today; the projections it rests on do,
and the case it was built for is a suspended captain rather than a fourth-choice
full-back. Note the single-gameweek transfer planner behind /transfers is untouched by
design: it scores the next match only, via form/fixtures.py, where FPL's own
percentage is already exactly right.
GET /news?only_time_varying=true returns the subset worth acting on, ordered
by when each player is back.
FPL's percentage answers the question FPL asks — will he play? — and the engine was spending it answering a different one: will he start? For a fully fit player those are the same question. For a player carrying a knock they come apart, because a manager has a third option between playing him and leaving him out, and it is the one they usually take: name him on the bench and bring him on if the game needs it. So 75% to feature is not 75% to start; it is a decent chance of half an hour.
Reading the percentage as a start probability therefore overrates exactly the players a manager is being careful with. That is a systematic error rather than a noisy one, and it lands on the ownable end of the pool — Caicedo, Maddison, Mount and Bruno Guimarães were all carrying a doubt on 2026-09-06.
src/fplquant/news/selection.py corrects it with one monotone transform of the
availability the engine already holds:
start_gate(a) = a x (1 - 0.5 x (1 - a)) # 0.75 -> 0.66, 0.50 -> 0.38
Three properties are what make it safe to run over the whole pool. It is inert at the ends — 1.0 stays 1.0 and 0.0 stays 0.0, so the ~97% of players with nothing wrong with them come through bit for bit. It is monotone, so it can never reorder two players the availability number already ordered. And it takes no new input, so there is no second signal to plumb through the horizon and no way for it to disagree with the availability layer, because it is a function of that layer's own output. That last property also gives it the right behaviour over time for free: availability already recovers a doubt as the weeks pass, so the selection discount relaxes on the same schedule and lapses when the doubt does.
Where it is applied matters as much as the shape. It goes into
engine/minutes.py, where start probability is an explicit quantity and a club
still fields eleven players, so the probability a doubtful player gives up is
handed to his teammates rather than deleted — which is what actually happens
when a manager decides to be careful with somebody. It is deliberately not
applied in form/fixtures.py, whose base is an unconditional EWMA that already
averages in benched weeks and which has no normalisation to absorb a third
discount; the next-match path behind /transfers is untouched for the same
reason the news layer leaves it alone.
Measured on the pool as of 2026-08-31 rather than assumed: 22 flagged players
lose start probability and 165 teammates gain it, and the reported
availability field stays bit-identical to chance_of_playing for all 609.
Total appearance probability falls for all 22 — the discount moves a player from
the XI toward the bench without ever making him likelier to feature.
The downstream effect is small and worth stating as such. Over a five-gameweek horizon the candidate projections move by under 1% — the largest single change is +0.10 points on an 11-point projection — which is nonetheless enough, at the pool boundary, to swap Gravenberch out of the planner's candidate pool and Lukić into it. That swap is marginal rather than decisive, and it is the honest headline: this is a correctness fix to a systematic error, not a large new signal. (Live numbers will differ: prod carried 15 doubt players on 2026-09-06 against the 22 in the 2026-08-31 snapshot these figures come from.)
The 0.5 is a prior, not a fitted value, and cannot honestly be presented as
anything else yet: the archived seasons carry no injury news at all
(backtest/hydrate.py sets every status to available), so there is nothing
historical to calibrate against, and player_snapshots — which does archive
status day by day — only began collecting on 2026-08-31. Once a season of those
exists this is the first constant here that should be fitted rather than
assumed. FPLQUANT_NEWS_SELECTION_FEEDS_THE_MODEL=false turns it off.
47 of those 118 FPL news strings read "Unknown return date". For those players
the official feed says that they are out and never when they are back — and
a press report of "out for six weeks" is the only estimate that exists anywhere.
fplquant-ingest-news fetches public RSS (BBC Sport, the Guardian, Sky Sports)
daily, resolves items to players, and reads them for a date.
RSS rather than page scraping, deliberately: a feed is published in order to be syndicated, so it is a contract rather than markup that changes without notice, it carries a stable item id and a timestamp, and it does not attract the bot-blocking that stops this project's Transfermarkt scrape running anywhere but a laptop.
Matching free text to a six-hundred-name pool is the dangerous part, because availability is a hard gate: a wrong match doesn't add noise, it attaches one player's fitness bulletin to another player's projection. So four independent gates stand between an article and a number, and all four have to open:
-
The match is strong. Three rules, each derived from watching the resolver fail on real feeds rather than from imagining how it might:
- Longest match wins, and claims its words. A shorter name sitting inside a longer one cannot match separately, so "Arsenal's Gabriel Jesus" is about Gabriel Jesus and not also about Gabriel Magalhães. Known non-player phrases claim words the same way, which is how "Old Trafford" stops being a story about Leeds' goalkeeper and "Aston Villa" stops being a player.
- A first name never identifies a player alone. Every false positive left after span-claiming was a given name — a byline ("Jonathan Wilson"), a player outside the pool ("Bradley Barcola"), a name in a list ("Anthony Gordon"). The pool knows its own given names, so this needs no curation.
- A surname needs its club, with club names expanded first: FPL stores "Man Utd" and a feed writes "Manchester United", and an unexpanded check silently corroborates nothing.
Matching is over whole tokens, so "Sarr" can never be found inside "Sarri". Measured against a stored day of BBC, Guardian and Sky output, the three rules together removed six false positives and recovered one true one.
-
The text carries a date. "Back in training" is stored, displayed, and consumed by nothing. Durations are read at their upper bound, so "two to three weeks" is three — being early about a return is the expensive mistake.
-
Confidence clears the threshold (0.8 by default). Below it the mention is still in the database and the API, with its confidence shown, and cannot move a number.
-
FPL already ruled the player out and left the date blank. A report cannot change a category, touch a fit player, override FPL's own return date, or contradict FPL about the next round.
On top of all four, the floor from the previous section still holds — so even a
wrong match at full confidence can only affect somebody already at zero, and can
only move them upward. FPLQUANT_NEWS_FEEDS_FEED_THE_MODEL=false keeps the
ingest and the API and stops consumption dead.
Articles and mentions are stored rather than consumed in flight, so anything
that moved is traceable to a URL and a bad rule is a DELETE away. The player
explorer shows the items with their match confidence, tagging the ones that
actually fed the model. Nothing that serves a request touches the network.
uv run fplquant-ingest-news # fetch, resolve, store, prune
uv run fplquant-ingest-news --require-mentions # ...and fail if it matched nobody
uv run fplquant-ingest-news --reresolve # re-match stored articles after a rules change--reresolve is why the articles are kept rather than consumed in flight. When
the matching rules are tightened, rows already written keep their old matches
and the feeds will not carry those stories again — so without it the only remedy
is dropping the table.
That last flag is the point of the daily job. This project has already lost a month to a green workflow writing an empty table, and a broken resolver looks exactly like a quiet news day unless something asserts otherwise.
Minutes are the largest term in FPL scoring, and rotation depends on fixture congestion, rest, competition for the shirt and recent selection all interacting — the shape of problem a gradient-boosted tree handles better than a formula anyone would write by hand. So there is one, trained on four archived seasons (~114k player-gameweeks) and held out against the most recent:
| on the held-out 2025-26 season | log loss | AUC | Brier | accuracy |
|---|---|---|---|---|
| the hand-built heuristic | 0.379 | 0.893 | 0.121 | 0.825 |
| gradient boosting | 0.251 | 0.952 | 0.078 | 0.892 |
Calibration matters more here than accuracy, because the output is multiplied
into an expected-points calculation rather than thresholded — a predicted 0.6
has to mean 60%. On the holdout the deciles track the observed rate to within a
point across the range (fplquant-train-minutes prints the curve).
Three things keep it honest. The split is by season, never at random: one
player's neighbouring gameweeks are strongly correlated, so a shuffled holdout
mostly measures whether the model has memorised who plays. Every feature is
lagged — the archive row for a gameweek contains that gameweek's minutes,
and a model handed them scores beautifully and is worthless. And training and
serving build their inputs through one shared function
(ml/features.py), because two independent
implementations of "the same" feature vector drift silently and no metric shows
it.
The model is optional. With no trained artefact on disk the engine falls back to the heuristic, so a fresh clone still produces predictions.
uv run fplquant-import-history # ~114k player-gameweeks, MIT-licensed archive
uv run fplquant-train-minutes # trains, evaluates, saves into the packagesimulate.py samples a gameweek match by
match: both sides' goals are drawn from the fitted Poisson rates, then
allocated to players by a multinomial over the usage shares. Because every
player in a match reads the same two draws, teammate correlation is
structural rather than estimated — a defender's clean sheet and his
goalkeeper's arrive in the same simulations, and a squad's variance reflects
that three defenders from one club are one bet held three times. It also
double-checks the model: the sampler and the closed form are independent
implementations, and their means agree to within a few hundredths of a point
across the whole player pool (tests/test_engine_simulate.py).
fplquant-backtest replays past gameweeks: the world is rebuilt as it stood
before each deadline, the real engine is asked for its projection — not a
reimplementation that happens to agree — and the answer is scored against what
players went on to do, alongside the baselines it has to beat.
The result, over 131 gameweeks across four seasons:
| GW6-38, 2022-23 to 2025-26 | MAE | rank corr | top-11 realised |
|---|---|---|---|
| rolling 3-GW mean | 1.076 | 0.673 | 47.3 |
| this engine | 1.164 | 0.541 | 54.5 |
It loses to a three-gameweek rolling average on both error and ranking, which is
about the crudest thing you could write. It wins on top-11 realised — what the
eleven players a metric ranks highest actually went on to score — by seven
points a week, which is the metric a squad is actually picked on.
The two disagree for a readable reason. Rank correlation across the whole pool is dominated by separating "will play" from "won't", and a rolling mean of recent points does that well by construction: a player who scored nothing three weeks running is usually a player who is not playing. The engine spends its effort on how many points someone will score given they play, which is the harder question and the one that decides between two players you would actually consider.
Two corrections to an earlier version of this table, both of which mattered more than the numbers did.
It used to carry a third row, FPL's own xP from the archive, reported as the
winner on MAE and top-11. That column is not a pre-deadline projection: it saw
the results it was nominally forecasting. Holding the player and season fixed
and looking only at rounds where they played 60+ minutes, the same player's own
xP runs 1.44 points higher in the weeks they happened to score and 0.76
higher in the weeks they assisted, for 88% of players; 40% of players who
started the week before and the week after but missed a gameweek entirely have
an xP of exactly 0.00. The eleven players it ranked highest realised 71.8
points a gameweek — a free published projection that good would win the game
outright for anyone who copied it, which is the tell that should have caught it
without any statistics at all. It is still available behind --with-fpl-xp,
under a name that says what it is, and it is not a baseline.
And the engine in the old table had its top layer switched off. hydrate marked
past fixtures finished but never gave them a scoreline, and
engine.rates.played_fixtures requires both — so no match counted as played,
every club's credibility stayed at zero, and all 131 gameweeks were scored
against a model whose team ratings were 100% preseason prior. Fitted attack
multipliers equalled their priors for 20 clubs out of 20. The expected-goals
range across every fixture was 0.97 to 1.97, when real 2024-25 team scoring ran
from Southampton's 0.66 a game to Liverpool's 2.24: the model believed every
match in England was a 1.4-goal affair for both sides.
Fixing it moved the ratings a long way — the fitted range is now 0.61 to 3.14, Liverpool have the best attack in 2024-25 and the three relegated sides the worst three of both attack and defence — and moved the headline barely at all (rank correlation 0.542 → 0.541). That is worth stating plainly rather than burying: the structural model, now actually running, is still beaten by a naive average on ranking. Team-strength flattening was never going to explain a gap that lives in "will they play", and it didn't.
So the honest position is unchanged in substance and better founded: the model
is internally consistent, well tested, correct about the components it models,
and does not beat a three-week average at ordering the whole player pool. The
next piece of work is the same one — use ep_next as a feature or learn the
residual on top of it — with the difference that the backtest measuring it is
now measuring the thing being shipped.
The replay disables the trained minutes model by default, because it was fitted
on the same seasons and would otherwise recognise the gameweeks it is being
tested against. --with-minutes-model re-enables it as a diagnostic and says so.
Two limits worth stating: the archive carries no team strength ratings and no injury news, so the replay cannot measure the availability gate, and both apply equally to the baseline.
top-11 realised is an ordering metric wearing a squad's clothes: the eleven
highest-projected players, with no budget, no positions and no club limit. It
was standing in for a measurement nobody had made. fplquant-backtest --lineups makes it — the real integer program, real constraints, the XI and
captain it would actually have fielded, scored against what happened.
| 26/27, GW1-4 | total | ceiling | capture | captaincy cost |
|---|---|---|---|---|
| engine projection | 261 | 321 | 81% | 24 |
| form projection (what they used to use) | 180 | 268 | 67% | 38 |
| FPL's average manager | 251 | — | — | — |
Read capture, not total. A fresh fifteen is bought every gameweek
against a full budget: no transfer limits, no hits, no price rises. That is an
infinite-wildcard manager, and beating FPL's average by ten points under those
conditions is not the same as beating it. Pulling the other way, the replay
does not reconstruct availability — every player looks fit — so the optimizer
starts people who were ruled out on the Friday. The two biases point in
opposite directions and cannot be netted, which is why the honest number is
capture: what share of the points its own fifteen could have returned it
actually took.
Three things fall out of it.
The projection these endpoints fed their solver was the weaker one, and
this measurement is why they no longer do. Same constraints, same solver, same
weeks — only the numbers going in differ. Over GW2-4 the engine's structural
projection is worth 39 points more, 13 a week, and that is before GW1,
where the form path collapses completely: with no history to average it
projects near-zero for everybody, spends £70.5m of a £100m budget on players
it has no opinion about, and returns 10 points with nine of eleven starters
blanking. /optimize and /transfers now default to projection="engine",
which fixes the cold start as a side effect; projection="form" still selects
the old path so the two can be compared on live data.
The difference is not subtle on today's pool. The form path's best-rated player projects 13.0 points for a single match — an EWMA chasing one big week — against the engine's 6.5 for Haaland, and it acts on that: asked to plan transfers for four real teams, it recommended three to five moves each, taking -8 to -16 in hits, and brought in the same handful of recently-hot names every time. The engine path recommends one transfer and no hit for the same squads.
Captaincy is the largest single leak. 24 points over four weeks on the engine path, against a total margin over the average of ten — the armband costs more than the entire edge. Note that picking the highest projected starter is the correct rule when the captain's points are simply doubled, so this is not an argument for different selection logic. Some regret is irreducible and the rest is upstream, in how flat the top of the projection is.
The top of the ranking is where the projection is least calibrated, which is the same finding from the other side:
| engine's own rank | predicted | actual | gap |
|---|---|---|---|
| 1-5 | 4.73 | 7.05 | +2.32 |
| 6-10 | 4.04 | 4.70 | +0.66 |
| 11-20 | 3.69 | 3.25 | -0.44 |
| 21-50 | 3.26 | 4.11 | +0.85 |
| 51-200 | 2.50 | 2.85 | +0.35 |
Every band drifts upward, so part of that is global under-prediction rather
than a top-end effect; the excess at the top over that drift is nearer +1.5,
on twenty observations from the opening rounds of a season. That is one good
week from being noise, and fitting a correction on it would repeat a mistake
this project has already measured twice — see the note on the current-season
replay above. It is tracked, per round, in docs/backtest_lineups.json, and
the decision waits for the data.
Chips are not scoreable yet and will not be for months: whether GW2 was the right bench-boost week depends on the other thirty-four. What is scoreable is whether the valuations behind the decision are honest. Bench boost forecasts of 14.4 / 14.7 / 14.7 against 15 / 15 / 13 realised are calibrated well; triple captain reads 2-3 points low every week, which is the flat top end again. And no week has been worth spending either: the best bench boost available returned 15 points and the best triple captain 9, so the planner holding both is the right call.
A one-gameweek solver cannot tell you to hold. Banking a free transfer is worth exactly what it buys you next week, and next week is not in its model — so "make no transfers" only ever wins by default, when nothing this week clears -4. That is a one-ply search with a myopic evaluation, and it showed: asked about three real squads, it disagreed with the horizon planner on all three, once taking -8 across three moves where the deeper search took -4 on two entirely different ones.
/transfers/plan now searches the horizon from the squad you own
(src/fplquant/transfers/search.py) and
answers with ranked lines rather than a verdict:
rod me trotter: 4 solves, events [5, 6, 7, 8, 9]
1. Palmer->B.Fernandes, João Pedro->Calvert-Lewin hit 4 vs hold +2.55
2. Palmer->B.Fernandes, João Pedro->Wissa hit 4 vs hold +1.50
3. Palmer->B.Fernandes, João Pedro->Barry hit 4 vs hold +1.47
4. hold (bank the transfer) hit 0 vs hold +0.00
Holding is a line, not the absence of one. It is solved for explicitly
(max_first_transfers=0) and every other line is scored as a difference
against it, so "worth it" is a comparison of two evaluations rather than an
assertion. When nothing beats banking, holding comes back at the top of the
list.
The alternatives have to be forced. Ask the solver the same question
again and it repeats its answer; exclude that answer and it says "hold" to
everything after. So alternatives are solved with a move required
(min_first_transfers=1) and each must drop at least one player the line
above it bought. A leg common to every line — B.Fernandes above — is a signal
rather than a repetition: there is no good line that does not sign him.
The eval and the raw total can disagree, and both are right. Lines rank
on the discounted objective, because only this week's move is executed and
the rest is re-solved before it arrives, so bringing points forward is
genuinely worth something. One real squad's best line ranked first on +0.10
while totalling 232.7 undiscounted against holding's 233.2. The API returns
both: gain_vs_hold is the verdict, horizon_points is context.
A chip week still takes the old path — a wildcard or free hit rebuilds the squad outright rather than choosing between moves — and so does a request with no fixtures ahead of it, where there is no horizon to search.
The planner has always placed chips itself — they are binaries per gameweek and it puts each where it is worth most. What it did not do is say what the alternatives were worth, which is the actual question when you are holding one chip for a whole season.
$ uv run fplquant-plan --team-id 500000 --horizon 8 --chip-week wildcard
wildcard · 9 solves · judged over 3-gameweek windows
week window total
GW5 +29.78 +31.14
GW6 +11.10 +19.88
GW7 +5.46 +12.89
GW9 +3.14 +4.77
GW8 +2.81 +7.31
hold the chip +0.00 +0.00
The two columns exist because one of them lies. total is the
whole-horizon difference, and for a wildcard it falls with the week it is
played in whatever the fixtures do: a permanent upgrade in the first week of
a window improves every week in it, one in the last improves one. Measured
over eight gameweeks on two real squads it was perfectly monotone — +24.28,
+19.42, +12.27, +6.72, +3.58, +2.76, +1.01, +0.60. Ranking on that would
answer "now" every single time, to everyone, forever.
window compares the same number of gameweeks after each candidate week, so
every option is judged over the same amount of football. The wildcard still
declines above, but now for a reason: in the do-nothing baseline the planner
keeps making ordinary transfers, so by GW9 the squad has largely fixed itself
and there is little left for a wildcard to add. That is a real answer — your
squad needs work now — rather than an artifact of where the window ends.
The one-week chips behave completely differently, which is the check that the window measure is doing something. On the same squad the bench boost reorders entirely against the raw total (GW7 best, not GW8) and GW5 is worth exactly nothing — there is no time to build a bench worth boosting.
A free hit is never offered in the final week: its cost is the squad reverting the week after, which is outside the model, so the planner bans it there rather than pricing it at zero. And the whole thing ranks the gameweeks it can see — a chip you hold all season is a question about the season, so "hold the chip" winning means the best week may simply be beyond the horizon.
A single-gameweek solver will take a -4 for one good fixture, sell the player next week for another -4, and never notice that banking the free transfer would have got the same squad for nothing. It cannot value a free transfer, because a free transfer is worth exactly the flexibility it gives you later, and later is not in its model.
multiperiod.py solves the whole
horizon as one integer program. Squad membership, the starting XI, the captain,
transfers, hits, and the free-transfer balance are all variables indexed by
gameweek, tied together by a flow constraint — this week's squad is last week's,
plus what you bought, minus what you sold. Chips are opt-in binaries the solver
places wherever they are worth most, which is a question a one-week model cannot
even ask: knowing which week to triple-captain requires looking at all of them
together.
Choosing the XI inside the optimization is a real improvement on the single-gameweek path, which maximizes the 15-man total and then picks an XI from whatever it bought — that values a fourth goalkeeper the same as a first-choice striker.
Only the first gameweek's moves are meant to be executed; re-solve once the next round's news lands. That is model predictive control, and it is why later gameweeks are discounted in the objective.
$ uv run fplquant-plan --horizon 5 --chips wildcard bench_boost triple_captain
Horizon GW1-GW5 · 231.1 expected points · 0 points of hits · solver Optimal
GW4 57.1 pts 3-4-3 [BENCH BOOST]
free transfers 2
OUT Wirtz (3.31) IN Isak (5.20)
C Isak, VC Haaland
The Planner tab in the dashboard shows the same plan as a timeline — one card per gameweek, the first one highlighted because it is the only one you act on — and clicking a card shows the fifteen you would own that week.
One chip per gameweek is a constraint, not an assumption. Without it the solver stacks them: a wildcard makes a whole squad's transfers free, a bench boost then scores the bench it just bought, and all three pile into the week with the best fixtures, producing a plan worth more points than any you are allowed to play.
FPL issues two full sets of chips a season — one usable in gameweeks 1-19, one in 20-38 — so the limit is one of each per half, and a horizon crossing the boundary can legitimately play the same chip twice.
The free hit needs one constraint of its own, because it is a loan rather than a purchase: the squad it buys lasts a week and then reverts. Without that written down it is a strictly better wildcard — unlimited transfers and you keep the squad — and the solver plays it every time. The reversion is the whole cost, and it falls in the following gameweek, which is why the planner will never schedule one in the last week of a horizon: that week's cost would sit outside the model, and free transfers at no price is a plan you cannot execute. Extend the horizon by a week to find out whether the final week is really where it belongs.
Known approximations, all deliberate: prices are held constant across the horizon, so it cannot plan around price rises; selling fees are not modelled; and the pool is trimmed to the top of each position, since several binaries per player per gameweek over 600 players is not a tractable program.
uv run fplquant-api # http://localhost:8000The dashboard is served at /, with Optimizer, Player Explorer, Market
Ticker, Transfers, and Planner tabs. The horizon endpoints are GET /projections (with optional simulate=true) and POST /plan.
/plan is the most expensive thing the API does, so its solve is capped at
FPLQUANT_PLAN_SOLVER_TIME_LIMIT_SECONDS (default 20). The solver keeps the
best plan it has found when the clock runs out; the CLI's --time-limit is
uncapped by comparison, since a terminal can afford to wait and a shared
worker cannot. Auto-generated interactive docs are at /docs (Swagger) and
/redoc.
Redis is optional. Caching is best-effort: if Redis is unreachable, the API logs a warning and falls through to a fresh computation.
docker compose up # api (8000) + redis (6379)
docker compose exec api uv run fplquant-ingest # populate data in the containerThe image is published to
GitHub Container Registry on
every push to main and can be run without cloning the repo:
docker run -p 8000:8000 \
-e FPLQUANT_DATABASE_URL=sqlite:////app/data/fplquant.db \
-v "$(pwd)/data:/app/data" \
ghcr.io/sidharthjoly/fplquant:latestThe container needs a schema (alembic upgrade head) and data
(fplquant-ingest) before /optimize returns anything useful;
docker-compose.yml wires both into its startup command. See
src/fplquant/config.py for the full list of
FPLQUANT_* environment variables (Redis URL, CORS origins, HTTP timeouts).
This is the same image that runs in production.
A FastAPI backend (SQLite plus a Redis cache) is shared by both the CLIs and
the HTTP API, on top of a Poisson points engine, an ILP squad optimizer, and
single- and multi-gameweek transfer planners. The frontend is vanilla HTML/CSS/JS with no
build step. Full write-up in
docs/ARCHITECTURE.md.
src/fplquant/
config.py typed settings (pydantic-settings), env-overridable
models/ SQLAlchemy ORM models + engine/session setup
(incl. daily point-in-time snapshots, for future backtests)
data/ FPL API client + ingestion pipeline
form/ EWMA-based form scoring (points + underlying stats)
lineup/ inferred formations, start probability, fatigue
news/ FPL news + RSS feeds -> per-gameweek availability,
and the same news read as a start-probability discount
optimizer/ ILP squad selection (PuLP), budget/position/club constraints
risk/ injury risk scoring + risk-adjusted expected points
market/ price/ownership momentum, volatility, teammate correlation
similarity/ per-90 stat vectors, cosine k-NN, PCA/t-SNE projection
engine/ Poisson points engine: team rates, minutes, usage, scoring,
multi-gameweek horizon, Monte Carlo simulation
api/ FastAPI backend (routers/, schemas.py, cache.py)
frontend/ static dashboard (vanilla HTML/CSS/JS, served by the API)
alembic/ database migrations
tests/ pytest suite (mirrors src/ layout)
The FPL API (prices, points, fixtures, xG/xA/ICT, and the published player
news), public football RSS feeds (return dates for players FPL rules out without
one), and Transfermarkt (injury history, scraped and fuzzy-matched to FPL
players). Full breakdown, including the resolution rules and the sources
investigated and not pursued, in
docs/DATA_SOURCES.md.
uv run pytest # tests, with coverage
uv run ruff check . # lint
uv run black . # format
uv run mypy src # type check
uv run pre-commit install # enable pre-commit hooks (ruff, black, mypy)Creating a migration after changing src/fplquant/models/orm.py:
uv run alembic revision --autogenerate -m "describe the change"
uv run alembic upgrade headThe application is split across two hosts:
- Frontend — GitHub Pages behind a
custom subdomain (
fplquant.sidharthjoly.com, CNAMEd tosidharthjoly.github.io), deployed by.github/workflows/pages.ymlon every push touchingfrontend/. - Backend — FastAPI and Redis on an Oracle Cloud Always Free VM, fronted by
Caddy at
https://fplquant.duckdns.orgfor automatic HTTPS via DuckDNS and Let's Encrypt. Redeployed by.github/workflows/deploy.yml(manually triggered); the VM pulls the prebuilt image rather than building from source.
The server keeps its own data fresh via cron (scripts/cron_ingest*.sh), and
.github/workflows/keepalive.yml pings /health every 15 minutes to stay clear
of Oracle's idle-instance reclaim policy. Full runbook, including OCI firewall
configuration, in DEPLOYMENT.md.
MIT. Data retrieved from the FPL API and Transfermarkt remains subject to those providers' own terms.



