Skip to content
sidharthjolyPublic

About

Fantasy Premier League analytics and squad optimization platform

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

FPL Quant

CI Coverage Python Linting: ruff Code style: black Type checked: mypy Container License: MIT Demo

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.

Features

  • 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.

Screenshots

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.

Squad optimizer

Player explorer

Market view

Market view, light mode

Quick start

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:8000

Early-season behaviour

Anything 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.

Commands

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.

Examples

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_captain

Alongside 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 points engine

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

News, and when a player is actually back

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.

The same news, read as a team-sheet decision

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.

Reading the press for the dates FPL doesn't give

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:

  1. 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.

  2. 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.

  3. 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.

  4. 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.

The learned minutes model

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 package

Simulation

simulate.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).

Does any of it work?

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.

But what would it have picked?

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.

Transfers as a search, not a swap

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.

Which week should the chip go in?

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.

Multi-period planning

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.

API

uv run fplquant-api    # http://localhost:8000

The 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

docker compose up                                # api (8000) + redis (6379)
docker compose exec api uv run fplquant-ingest   # populate data in the container

The 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:latest

The 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.

Architecture

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)

Data sources

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.

Development

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 head

Deployment

The application is split across two hosts:

  • Frontend — GitHub Pages behind a custom subdomain (fplquant.sidharthjoly.com, CNAMEd to sidharthjoly.github.io), deployed by .github/workflows/pages.yml on every push touching frontend/.
  • Backend — FastAPI and Redis on an Oracle Cloud Always Free VM, fronted by Caddy at https://fplquant.duckdns.org for 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.

License

MIT. Data retrieved from the FPL API and Transfermarkt remains subject to those providers' own terms.

About

Fantasy Premier League analytics and squad optimization platform

Resources

Stars

0 stars

Watchers

0 watching

Forks

Packages

Used by

Contributors

Languages