After a decade of watching United stumble from one false dawn to the next, fantasy premier league (FPL) has become my sole source of joy in football. This is my seventh season playing, and in a game where 11.2 million managers compete, I have reached the top 100k1 twice: 35,192nd in 2023/24 and 66,805th in 2020/21, good enough to justify the hours spent watching games over the weekend.
Strip away the football and you’re left with a constrained optimisation problem where you maximise points scored by players based on real-life performances (goals, assists, clean sheets, etc.). You get £100m to build a 15-player squad consisting of 2 goalkeepers, 5 defenders, 5 midfielders, and 3 forwards. Only 11 play each gameweek, following standard formation rules2. Other constraints include a maximum of three players per club, and one free transfer per week, with additional transfers costing -4 points. You captain one player for double points.
The past six seasons, I picked teams like everyone else: browsing r/FantasyPL 10 minutes before the deadline, checking fixture difficulties, and convincing myself that Bruno will finally haul. But last year, while studying optimisation in uni, I realised I was manually attempting what a computer could solve in seconds.
I built four optimisers:
- Single gameweek - pick the best team for one week
- Multiple gameweek - plan ahead without transfers
- Multiple gameweeks with transfers - plan ahead with squad changes
- Current team - optimise from your existing squad
You can find them here as Pluto.jl notebooks.
Julia
I came across this blog post a few months ago about FPL optimisation in Julia and JuMP. The syntax looked clean and I wanted to try something that wasn’t Python for once.
I was worried about speed initially. Given the nonillion3 possible squads, I imagined the solver churning away for minutes to hours. Julia supposedly runs optimisations faster than Python, though I never benchmarked this myself. It turns out this doesn’t really matter as modern solvers crush this problem in seconds regardless of language. But by then I’d already written everything in Julia.
To run the optimisers:
cd /fpl/optimiser
julia --project=. # After installing Julia and Pluto
using Pluto
Pluto.run()
The Data
I’m using Solio Analytics for the expected points projections. They offer free data for August and September, with predictions 12 gameweeks ahead. The CSV has player names, buy/sell values (different because FPL taxes you 50% on profits when selling), and expected points for each gameweek.
begin
df = CSV.read("../data/gw$(START_GW)-projection.csv", DataFrame)
sort!(df, "$(START_GW)_Pts", rev=true)
end
The expected points come from their model which factors in fixture difficulty, player form and historical data. Another alternative is FPL Review’s Massive Data Planner at around $6 SGD per month. I’ll probably build my own model at some point, but that’s another rabbit hole entirely.
Level 1: Single Gameweek
This is the simplest optimiser; we just want to pick the best possible team for one gameweek. No transfers or future planning to worry about here.
function optimise_single_gameweek(df)
model = Model(HiGHS.Optimizer)
n = nrow(df)
@variable(model, squad[1:n], Bin) # 15-player squad
@variable(model, starter[1:n], Bin) # 11 starters
@variable(model, captain[1:n], Bin) # 1 captain (2x points)
@objective(model, Max, sum(df[i, "$(START_GW)_Pts"] *
(starter[i] + captain[i] + BENCH_WEIGHT * (squad[i] - starter[i]))
for i in 1:n))
The objective function has three parts: starters get full points, the captain gets double4, and bench players contribute via a token BENCH_WEIGHT
5 because they might play if someone gets injured.
# Budget
@constraint(model, sum(df[i, "BV"] * squad[i] for i in 1:n) <= BUDGET)
# Squad composition
@constraint(model, sum(squad) == SQUAD_SIZE)
@constraint(model, sum(starter) == STARTING_XI)
@constraint(model, sum(captain) == 1)
@constraint(model, sum(squad[i] for i in 1:n if df[i, "Pos"] == "G") == GK_REQUIRED)
@constraint(model, sum(squad[i] for i in 1:n if df[i, "Pos"] == "D") == DEF_REQUIRED)
@constraint(model, sum(squad[i] for i in 1:n if df[i, "Pos"] == "M") == MID_REQUIRED)
@constraint(model, sum(squad[i] for i in 1:n if df[i, "Pos"] == "F") == FWD_REQUIRED)
# Max 3 players per club, plus my own constraint: max 2 defenders from same team
for team in unique(df.Team)
@constraint(model, sum(squad[i] for i in 1:n if df[i, "Team"] == team) <= MAX_PLAYERS_PER_TEAM)
@constraint(model, sum(squad[i] for i in 1:n if df[i, "Team"] == team && (df[i, "Pos"] == "G" || df[i, "Pos"] == "D")) <= MAX_DEFENDERS_SAME_TEAM)
end
The second constraint in the for loop is based on my experience. Having multiple defenders from the same team results in correlation risk, where one conceded goal wipes multiple clean sheets. Therefore, we limit to 2 defenders (including goalies) from a single club.
# Hierarchy: captain must be starter, starter must be in squad
for i in 1:n
@constraint(model, starter[i] <= squad[i])
@constraint(model, captain[i] <= starter[i])
end
# Formation constraints
@constraint(model, sum(starter[i] for i in 1:n if df[i, "Pos"] == "G") == 1)
@constraint(model, 3 <= sum(starter[i] for i in 1:n if df[i, "Pos"] == "D") <= 5)
@constraint(model, 2 <= sum(starter[i] for i in 1:n if df[i, "Pos"] == "M") <= 5)
@constraint(model, 1 <= sum(starter[i] for i in 1:n if df[i, "Pos"] == "F") <= 3)
Running the optimiser with just these basic constraints gives a team which passes the eye test. A Liverpool triple up with Salah as captain is what you’d expect from a soulless optimiser.
Level 2: Multiple Gameweeks
Unfortunately for us, FPL runs for 38 weeks. This version plans across multiple gameweeks without transfers, essentially picking a “set and forget” team.
The key addition is weighting future gameweeks.
const NUM_GAMEWEEKS = 5
const GAMEWEEK_WEIGHTS = [1.0, 0.9, 0.8, 0.7, 0.6]
These weights work like a discount factor in finance where future points are worth less because predictions decay over time. Players get injured, managers rotate squads and teams may over/underperform expectations. The predictions in the first gameweek are fairly reliable (weight = 1.0), but by gameweek 10, you’re not really sure if Haaland and Salah are still fit.
I’m using 5 gameweeks because that’s when I plan to wildcard. The transfer window closes a few weeks before that, giving me a chance to bring in new signings early. Plus, 5 gameweeks should give me enough data to see if the optimiser holds up or needs tweaking. Solio’s data covers 12 gameweeks if you want to experiment with longer horizons.
The objective function now sums across all gameweeks:
@objective(model, Max, sum(GAMEWEEK_WEIGHTS[gw - START_GW + 1] * df[i, "$(gw)_Pts"] * (starter[i] + captain[i] + BENCH_WEIGHT * (squad[i] - starter[i])) for i in 1:n, gw in START_GW:END_GW))
Running this gives a much more balanced squad that is predicted to perform across 5 gameweeks.
Level 3: Multiple Gameweeks with Transfers
Now we’re playing actual FPL. This optimiser can make transfers between gameweeks. I’ve kept transfers simple here: one per week with no -4 hits6 for additional transfers.
@variable(model, squad[1:n, START_GW:END_GW], Bin)
@variable(model, transfer_in[1:n, (START_GW + 1):END_GW], Bin)
@variable(model, transfer_out[1:n, (START_GW + 1):END_GW], Bin)
# Squad evolution
for gw in (START_GW + 1):END_GW, i in 1:n
@constraint(model, squad[i, gw] == squad[i, gw-1] + transfer_in[i, gw] - transfer_out[i, gw])
end
# One free transfer per gameweek
for gw in (START_GW + 1):END_GW
@constraint(model, sum(transfer_in[i, gw] for i in 1:n) <= 1)
@constraint(model, sum(transfer_out[i, gw] for i in 1:n) <= 1)
end
Here, squad[i, gw]
tracks whether player i
is in your squad at gameweek gw
. The evolution constraint ensures squad continuity: each week’s squad equals last week’s plus transfers in, minus transfers out.
The output now shows the squads for the next few gameweeks, with the necessary transfers.
Level 4: Optimising from Your Current Team
Mid-season optimisation is different. You’ve already got a squad, maybe some money in the bank, and you can’t just tear everything down without a wildcard. This optimiser starts from a given team and plans the transfers.
current_team = [
"Sánchez",
"Pedro Porro",
"Romero",
"Tarkowski",
"Gabriel",
"Wirtz",
"B.Fernandes",
"Palmer",
"M.Salah",
"Mateta",
"Luís Hemir",
"Dúbravka",
("Barnes", "F"), # Position check since there are multiple Barnes in the PL
"Tielemans",
"Estève"
]
function optimise_from_current_team(df, current_team, initial_bank=0.0)
current_team_indices = Int[]
current_team_sell_value = 0.0
for name in current_team
idx = findfirst(row -> row.Name == name, eachrow(df))
if idx !== nothing
push!(current_team_indices, idx)
current_team_sell_value += df[idx, "SV"]
else
@warn "Player $name not found in data"
end
end
The tricky bit here is tracking your bank balance. As mentioned earlier, FPL taxes you 50% of profit when selling7. Fortunately, Solio’s data makes this easy to handle with separate buy (BV) and sell (SV) values, computed based on the team ID.
# Track bank balance across gameweeks
@variable(model, bank[START_GW:END_GW] >= 0)
# Initial bank = what you have + sales - purchases
@constraint(model, bank[START_GW] == initial_bank +
sum(df[i, "SV"] * transfer_out[i, START_GW] for i in 1:n) -
sum(df[i, "BV"] * transfer_in[i, START_GW] for i in 1:n))
# Bank balance carries forward each week
for gw in (START_GW+1):END_GW
@constraint(model, bank[gw] == bank[gw-1] + sum(df[i, "SV"] * transfer_out[i, gw] for i in 1:n) - sum(df[i, "BV"] * transfer_in[i, gw] for i in 1:n))
end
# Initialise squad with your current team
for i in 1:n
@constraint(model, squad[i, START_GW] == (i in current_team_indices ? 1 : 0) + transfer_in[i, START_GW] - transfer_out[i, START_GW])
end
Coda
The optimisers obviously miss some stuff. They don’t handle chip strategy8, don’t model -4 points hits properly and definitely cannot predict Pep’s tactical nous of benching your City midfielders right after you captain one of them.
FPL at the top level has become somewhat boring. The game has become an XPts spreadsheet simulator, where everyone converges on identical template teams based on the same underlying projections.
So why build another optimiser? Because I want to win my mini-leagues! If the meta is mathematical optimisation, you either join or accept mediocrity. Also, it’s fun to build. Getting all of FPL’s weird rules into proper constraint form and watching the solver churn through them in seconds feels satisfying.
It’s worth remembering this is fundamentally a game about guessing which footballers will perform well. I’ll be running two teams this season: one following the optimiser, and another where I make all the calls. Either the math works or I’ve built an elaborate way to finish exactly where gut instinct would have gotten me anyway.
Footnotes
-
The FPL community considers top 100k the benchmark for being good. Some say top 10k is where ‘good’ starts, but I’m not saying that because I haven’t reached it. ↩
-
Formation constraints: 1 goalkeeper, 3-5 defenders, 2-5 midfielders, and 1-3 forwards make up the first 11. ↩
-
Roughly 10^30 squads given 685C15. ↩
-
Hence adding
captain[i]
again. ↩ -
BENCH_WEIGHT = 0.1
was completely arbitrary. The optimal value probably depends on injury rates and your bench strategy: are you going for cheap enablers or rotating options? Worth investigating properly! ↩ -
Sometimes, taking -4 hits can be worth it: bringing in a player before a price rise, or getting a captain option for a great fixture. Adding that complexity here requires additional constraints that would make the code harder to follow. Consider it a future enhancement. ↩
-
Typical British behaviour. ↩
-
Triple captain, bench boost and free hits. ↩