Visitar URL original
GitHub - bitniktech/fpl-armband: An AI Fantasy Premier League analyst that runs on your machine. Scores every player with a quantitative model built from FPL's actual scoring rules, interrogate the result through a live tool surface, challenge it, and record what the model cannot see — an injury, a lost place — as a durable override. · GitHub
Skip to content

About

An AI Fantasy Premier League analyst that runs on your machine. Scores every player with a quantitative model built from FPL's actual scoring rules, interrogate the result through a live tool surface, challenge it, and record what the model cannot see — an injury, a lost place — as a durable override.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

FPL Armband — a blue captain's armband carrying a white C, above the wordmark FPL Armband and the line 'Before the team sheet.'

A Fantasy Premier League squad optimiser that runs on your machine. The binary is armband.

One command builds the best 15-man squad it can find under the real rules — £100m, positional quotas, three per club. Another finds the best transfers for the squad you already own. The numbers behind both come from a deterministic model, not a language model: expected goals become points at FPL's actual position-dependent rates, clean sheets come out of a Poisson model, and every term is traceable to the thing that caused it. It reads the public FPL API, it costs nothing to run, and only three of its commands make a paid call of any kind.

Named for the captaincy, which is the biggest single decision you make each week and the one effect in this project's record that stands clear of the noise.

The planner, on the gameweek 1 deadline. The whole fifteen under the real rules: the eleven and the bench in substitution order, the captain, what was spent and what is left, and against every player the gameweeks ahead with their difficulty, expected minutes, the model's score and the price. Every player carries a role band — Nailed · Likely · Rotation risk · Squad · Fringe — because the question that actually costs you a week is not who is injured, it is who is not playing.

The planner's Pitch tab for gameweek 1, with the deadline two days away. A gameweek strip runs GW1 to GW5 above three tabs — Pitch, Players and News. A readout gives 51.6 projected points (an eleven worth 46.3 plus the armband), a 9.1-point bench that does not count, £0.5m in the bank against a £99.5m squad, and no chip. Below it the eleven in a 3-5-2 with B. Fernandes captain, his 5.37 doubled to 10.73, each card carrying the club, the price, a role band such as NAILED or LIKELY, the model's score and three colour-coded fixture-difficulty pills. The four-man bench follows in substitution order. A side panel carries a COMING SOON badge for a live team-news alerts service that is not built yet.

armband serve hosts it on 127.0.0.1: a landing page, then the planner itself — the pitch, the market of every player worth considering, the standing overrides and the briefing. It is yours to change. Pick the eleven, order the bench, give the armband, lock a player in or block one out, clear a correction you disagree with, place a chip, or replace a man from the market — and it is all still there when you come back. The squad, the market and the fixtures come from the model over a JSON endpoint the desktop build and the website will read too, so there is one set of numbers rather than one per surface.

⚠️ The three screenshots in this README are the current planner rendered by the layout test suite from committed fixtures, not from a live run — internal/webui/statefixture_test.go explains why the suite was deliberately built that way. The layout is today's; the football is a fixed document. Refresh them with docs/images/crop-from-goldens.sh.

⚠️ Two of them show panels badged Coming soon. Those are unbuilt features, labelled as such in the application itself, and they are left in rather than cropped out because a doctored screenshot is a worse problem than an honest one. Nothing in this README depends on them.

The shape of the system, and where the split falls: everything up to the agent is deterministic and free, and the language model is judgement layered on top of numbers it never computes itself.

flowchart LR
    api["public FPL API<br/>read-only, unauthenticated"]
    engine["quantitative scoring model<br/>deterministic, never calls the LLM"]
    freecmds["free commands<br/>squad · serve · transfers · fixtures · chips · brief"]
    agentloop["LLM agent<br/>review · advise · due"]
    search["web search<br/>team news, press conferences"]
    you["you make the transfers<br/>no authenticated write path back to FPL"]

    api --> engine
    engine --> freecmds
    engine -->|"the same numbers,<br/>plus judgement — API billed"| agentloop
    search --> agentloop
    freecmds --> you
    agentloop --> you

    classDef io fill:#F4E0E3,stroke:#A8404E,color:#141A21
    classDef pure fill:#E3EDF1,stroke:#1F5F73,color:#141A21
    classDef llm fill:#DFEDE6,stroke:#2F7A57,color:#141A21
    classDef muted fill:#F4F6F9,stroke:#7A8791,color:#141A21
    class api,search io
    class engine,freecmds pure
    class agentloop llm
    class you muted
Loading

Quick start

go build -o armband ./cmd/armband

./armband squad          # best 15 it can find under the real rules
./armband transfers      # best transfers for the squad you own
./armband fixtures       # fixture difficulty table
./armband forecast       # projected goals for and against, per fixture
./armband serve          # the planner, on 127.0.0.1
./armband brief          # the whole deterministic picture, as Markdown

None of that needs a credential of any kind, and none of it bills anyone: the FPL endpoints this reads are public and unauthenticated.

Three commands — review, advise and due — do call a language model, and they want a key:

export ANTHROPIC_API_KEY=sk-ant-...     # platform.claude.com/settings/keys
./armband review                        # the weekly decision

First run writes a config.json you can edit, or copy config.example.json over it and set entry_id to your own team. Requires Go 1.26.7 or newer (see go.mod).

Only entry_id is required. Every other key has a shipped default, and a key you leave out of the file keeps that default — config.example.json is deliberately the short version rather than the full one. The long config.json this repository ships is the model's configuration: around forty tunable constants, most of which you should not need to touch and none of which you should change without reading what it does first.

Your own chip plan, personal criteria and squad locks are not in config.json at all — they go in a second file, team.json, loaded with the global -team <path> flag (copy team.example.json to start one). That split exists because config.json is also the file the deployed server mounts for anyone who visits, and a chip plan or a lock has no business reaching a stranger's page. Omit -team entirely and each of the five keeps its shipped default — no chips planned, no locks, and the criteria and lead time that ship — see docs/configuration.md for exactly which five and why.

Two things to know up front

It writes to your config, never to FPL. There is no authenticated write path at all — FPL's session cookie, the my-team endpoint and the auth command were removed outright, and TestTheClientHasNoAuthenticatedSurface fails the build if one comes back. You make the transfers.

That config write is worth understanding, because a review run leaves something behind. When the agent establishes something the model cannot see — a player out for six weeks, one who has lost his place — it records it with set_player_status, which persists to config.json and binds every later run, the free commands included: an excluded player is not offered again by squad, transfers or suggest_transfers until the override is cleared. It refuses to store one without a reason, and every standing override is re-reported for review each run. A lock is the one exception: it persists to team.json instead, and if you did not pass -team the write is refused outright rather than silently dropped or misfiled into config.json, where the next load would hard-error on it. See docs/configuration.md.

The loop is worth seeing whole — one paid run writes a fact down, and every free run afterwards is bound by it until it is cleared:

flowchart LR
    reviewrun["armband review<br/>the agent establishes something<br/>the model cannot see"]
    setstatus["set_player_status<br/>refuses to store an override<br/>without a reason"]
    cfg["config.json<br/>standing override"]
    freecmds["squad · transfers · suggest_transfers<br/>the player is not offered again"]
    audit["re-reported for review<br/>on every later run"]

    reviewrun --> setstatus --> cfg
    cfg --> freecmds
    cfg --> audit
    audit -.->|"until cleared"| cfg

    classDef llm fill:#DFEDE6,stroke:#2F7A57,color:#141A21
    classDef pure fill:#E3EDF1,stroke:#1F5F73,color:#141A21
    classDef test fill:#FBF2E3,stroke:#B9770E,color:#141A21
    classDef muted fill:#F4F6F9,stroke:#7A8791,color:#141A21
    class reviewrun,setstatus llm
    class cfg test
    class freecmds pure
    class audit muted
Loading

It plays its own picks, in public

FPL Armband is this project's own team. Every gameweek it fields what the model says, and the table is there whether that goes well or badly. A model that only ever scores itself against replays of seasons it was tuned on is marking its own homework — so the picks go somewhere anyone can check them. It is also the entry_id that config.example.json ships with, so change that to your own.

One season is a small sample, and a league table is a slow way to learn anything. The measurement that does resolve is below, against thousands of player-gameweeks. Read the live team as skin in the game.

Does it actually work?

A transfer is a question about order — will this player out-score that one — not about hitting a points total. So the number to judge a predictor by is how well it ranks players within a gameweek.

The planner's Players tab asks that question over and over, and it is worth seeing how. Every candidate outside your fifteen is measured not against the league but against the weakest starter you already own in that position — named in the legend, with his score — and the Better by column gives the gap. Green marks what clears the free-transfer gate. The list is filterable by position, name and club and sortable on every column but the fixture strip, and a candidate you cannot currently afford is marked rather than withheld, because "he is worth it but you are £1.0m short" is a different answer from "he is not worth it".

That framing is the point: a transfer does not have to be good, it has to beat the man it replaces.

The planner's Players tab. A readout gives £0.5m in the bank against a £100.0m budget, a £99.5m squad of 15, a cap of five free transfers you may bank, and a "worth it?" gate of +0.40 points a week. A legend reads "Gain is the comparison a transfer actually has to win — the gap to the weakest starter you already own in that position, not a league rank", naming Raya at 3.84 for goalkeepers, Tarkowski at 3.78 for defenders, Rice at 4.01 for midfielders and Calvert-Lewin at 3.58 for forwards. A note says 1 of 60 clear the +0.40 gate and 42 are reachable with £0.5m in the bank. Each row carries the player, position, next five fixtures, a role band, ownership, expected goals and assists per 90, a defensive-contribution rate, the price, the projected score and the gap to that starter — Haaland leads at +1.70, two more are positive but short of the gate, and the rest are negative. A footer names one player left out of the market: Arrizabalaga, excluded on 2026-08-18 for a long-term injury with no named return date.

The baselines are not straw men; they are what an FPL manager actually reasons from today. Recent form here is a player's mean score over his last five gameweeks — the same idea as the form figure FPL itself shows next to every player, and as the rolling averages on every FPL stats site. The flat season average is total points divided by games played. Those are the alternatives a real person is choosing between when they look at a player and guess.

predictor ranks players within a gameweek over-rates its own top 20 by
this model 0.427 0.41 pts/gw
recent form (last five gameweeks) 0.330 2.57
flat season average 0.311 1.03

The first column is a rank correlation: 0 means the ordering is no better than random, 1 means perfect, higher is better. The model orders players 29% better than recent form. Football is noisy and a perfect predictor of it is impossible, so read the column as a comparison between the real alternatives, not as marks out of one.

The second column is the one that costs you money. Every predictor flatters the players it rates highest, and those are exactly the players you would captain or transfer in. This model over-rates its own top twenty by 0.41 points a gameweek; recent form over-rates its own by 2.57 — enough to talk you into a captain it has flattered by two and a half points a week.

Drawn to scale, that second column is the whole pitch — the model's edge is how honest it is about its own favourites:

%%{init: {"themeVariables": {"xyChart": {"plotColorPalette": "#B9762A, #1F5F73, #2F7A57"}}}}%%
xychart-beta
    title "how far each predictor over-rates its own top 20"
    x-axis ["this model", "last 5 gameweeks", "season average"]
    y-axis "points per gameweek" 0 --> 3
    bar [0.41, 2.57, 1.03]
Loading

That is not the whole story, and the whole story is deliberately frank: on some measures — picking out the players who go on to haul — the model is only line-ball with the moving average, and most of its internal constants cannot be shown to be optimal. The full tables, the unflattering columns included, are in docs/accuracy.md. It answers whether the model is right about football; whether a change to it earns points is docs/replay.md's question, and it resolves far less often.

Commands

Most of these cost nothing to run. internal/analysis never calls the LLM, so the optimiser, the fixture model, the chip validator and the replay are all free.

Command What it does API cost
brief The whole deterministic picture as one Markdown document, for pasting into a chat (-html for a page) —
squad Best 15 it can find under the real constraints: £100m, positional quotas, three per club (-plain) —
serve The planner over HTTP, loopback only: the pitch, the market, the overrides and the briefing, drawn from the model over /api/state. Pick the eleven, order the bench, give the armband, lock or block a player, clear a correction, place a chip, or replace a man from the market — all of it saved to your browser session and still there after a reload. The opening fifteen is deliberately varied rather than always the same squad; Optimize returns the model's best. -persist saves corrections back to config.json — and a lock specifically to team.json, if -team was given; without it, persisting a lock is refused rather than lost — -horizon sets how many gameweeks the preview scores over; -now pins the clock to an RFC3339 instant, for serving a frozen capture as of the moment it was taken (-addr, -persist, -horizon, -now after the command). One thing here leaves the machine carrying something you typed: the landing page's email form posts to fplarmband.com, so a signup made against a local copy joins the same list rather than a local one nobody collects. The planner's own network use is FPL's public API, plus — on a cold cache only — last season's totals from the public dataset priors caches —
transfers Best transfers for the squad you own, as a team sheet (-plain) —
fixtures Fixture difficulty per club, easiest run first —
chips Chip windows, plan validation, blanks and doubles —
congestion International breaks, turnarounds, European load —
nations FPL nationality codes with example players —
backtest <season> [payoff-gws] Replay a finished season — see docs/replay.md —
snapshot Model-and-harness accuracy record from a sweep's CSVs —
capture Archive today's live payload, dated and immutable —
backfill <season|all> Recover point-in-time team news for a finished season from the Internet Archive, one snapshot per gameweek, always from strictly before the deadline — see docs/backfill.md —
priors Cache last season's totals before FPL overwrites them —
verify-competitions Record that competition status was checked, with nothing to correct —
schedule Print a crontab line for due —
review Weekly decision: competitions → chips → transfers → news → verdict yes
advise Full pre-deadline analysis, written to a report yes
due Run advise, but only if a deadline is near (for cron) — a one-shot prompt, not the review protocol yes, when it fires

Flags go before the command — armband -html brief.html brief, never armband brief -html brief.html, which is rejected rather than silently ignored.

-html writes the briefing only. The squad and transfer pages used to be written to disk as well; they are served now, by armband serve, and asking for them as a file says so rather than quietly producing nothing. A page on disk was a second renderer for a squad the application already draws, and keeping two in step by hand is this project's most expensive recurring bug. armband with no arguments prints every flag, with worked examples of the ones whose ordering is easy to get wrong; that string is the only complete list. A few commands parse their own flags and take them after the command instead — the usage text names which, and the same before-the-positionals rule applies there.

capture is the one worth putting on a timer. It archives bootstrap-static and fixtures into a dated directory, two requests, so that point-in-time questions become answerable later. It yields nothing this season and a great deal next spring — and every week missed is data that cannot be recovered. armband capture -list audits the series for gaps.

Two ways to run the review

internal/analysis never calls the LLM, so the only thing the API ever pays for is the judgement on top. That split gives you a choice.

armband brief — free. Writes everything the model knows as one self-contained Markdown document: your criteria and review policy, the numbered review protocol, competition status, chip plan, squad and transfers, every flagged player, your standing overrides, research targets, the best squad it found, the fixture table, and a "what this model cannot see" section. Paste it into any chat assistant you already have a subscription for, and get the review there. Roughly 5,000 words pre-season, and longer once you have a squad to describe. Your criteria and chip plan come from team.json — pass -team or the brief describes a manager with none of either.

armband review — API billed. The same protocol, but the agent calls tools iteratively: it can notice something odd and go look, rather than reasoning from a single snapshot. It is instructed to critique the model's answer rather than restate it — the model cannot see team news and it optimises a number, and the prompt says exactly that. advise is the same agent loop on a one-shot prompt, and due runs that unattended on a schedule; what it will never do is submit anything, because there is no authenticated write path and its absence is what the client's guard test exists to keep.

Use brief while you are still vetting the model's judgement, since it costs nothing to run twenty times. Use review when you want it to work on its own.

A chat subscription cannot fund review. Consumer plans cover the chat products; armband calls the Messages API directly and bills an API organization. brief exists precisely so the subscription you have is usable here.


What makes it more than a spreadsheet

The score is auditable. Expected goals become points at the real position-dependent rates; clean-sheet probability comes from expected goals conceded via a Poisson model. No black-box rating — you can trace any number to the term that caused it, and every term is stored separately and reported to the agent rather than folded into one opaque figure.

FPL's step functions are modelled as steps. Appearance points and the clean sheet are paid at sixty minutes and are all-or-nothing, so they scale by the probability a player reaches sixty, not by the fraction of a match he plays. Those two terms are 61% of a defender's per-90 score, so treating them as rates — which the model did once — is a large and position-dependent error.

Expected minutes are treated as a gate, not a tiebreaker. Correlation between season minutes and season points is r = 0.929. Rotation risk is penalised convexly, weighted toward recent gameweeks, with a per-position relaxation for midfielders.

Uncertainty is priced where it was measured, and switched off where it was not. Two discounts survive because the data supports them, and they deliberately act on different channels. The summer-signing discount multiplies Score by 0.88: signings turn out to be marginally better per 90 but about 12% less available, so the discount is calibrated against that availability gap rather than applied to minutes directly. The post-tournament rest discount multiplies expected minutes by 0.83, because minutes are where its effect was measured. Applying a discount on the wrong channel is what retired the new-manager penalty — §6 of docs/model.md tells that story.

The penalties that did not survive measurement ship at 1.00, the neutral value. The new-manager penalty and five of the eight congestion penalties — the three European ones and both short-rest ones — measured as nothing on the channel they are applied to. The remaining three, covering domestic cups, long-haul travel and the week after an international break, sit at 1.00 on the weaker but sufficient argument that an unmeasured multiplier which moves a score is not neutral and 1.00 is. All nine are still reported to the agent so a human can weigh them; none changes a number. §5 and §6 of docs/model.md record what each was measured at, and which were not.

The chip plan feeds squad construction. A wildcard at GW6 shortens the optimiser's horizon; a bench boost makes the optimiser buy fifteen playable footballers instead of eleven plus fodder.

Claims are validated against replayed seasons, not asserted. Six archived seasons entered at six deadlines give 36 matched cells, and a change is judged on the paired difference within each cell rather than on a season total. Most of what that harness has measured is a negative result — five separate corrections of genuinely measured biases lost points. See docs/replay.md.

The agent can see what the model cannot. Web search runs server-side, so it reads actual team news and press conferences rather than relying on FPL's terse news field.

Every run reports its own cost.

14 API calls · 952k prompt tokens (900k cached, 95% hit rate) · 25k output · 3 web searches
estimated cost: $0.540 at the introductory rate active for this run; $0.810 at standard rates
without prompt caching this run would have cost about $5.39

Cost

Prompt caching is on by default with two breakpoints — the system prompt plus tool definitions, and the conversation tail. Cache reads bill at 0.1× input, so a healthy run shows a hit rate above 0.8. A hit rate near zero means something in the prefix is changing between calls — that is a bug, not a tuning problem.

Context editing clears stale tool results once more than four tool calls are behind.

Levers, roughly in order of impact:

Lever Change Effect
effort high → medium Largest single saving
model claude-opus-5 → claude-sonnet-5 $5/$25 → $3/$15 per MTok
Free commands brief, squad, transfers, fixtures, chips Zero cost
max_iterations Lower than the default 25 Bounds a runaway, not typical spend

Pricing is date-aware, so promotional rates are applied while valid and revert automatically.


Pre-season behaviour

Between seasons the FPL API's aggregate stats still hold last season's totals. That is the correct baseline for gameweek 1, and the agent is told so explicitly — along with the caveats: players who changed clubs show their old club's output, new signings from abroad show nothing, and set-piece hierarchies may have changed.

This matters more than it sounds. Those aggregates reset at GW1 and then accumulate, so anything that divides by a fixed 38-game season reports an ever-present as a fringe player for most of the autumn. The model uses Engine.DataWindow() for exactly that reason.

What you must configure by hand

The FPL API does not publish these, so they are hand-maintained and must be re-derived every summer. They fail silently: a stale list simply applies to the wrong clubs, or stops applying at all.

  • European and domestic cup participation — dated windows per club
  • Managerial changes (new_coach_clubs) — ten clubs shipped for 2026/27
  • Nationality groups for travel load — run armband nations to map the opaque codes
  • Post-tournament rest (rest_players) — the 17 Premier League players who were on the pitch for the majority of a 2026 World Cup semi-final. That is a teamsheet test, not a squad list: unused substitutes are excluded on purpose, so an audit against published squads will "find" names missing and be wrong. Read the comment on DefaultRestPlayers before editing the JSON, which has no room for one.

Three of the four lists are display-only. All eight congestion penalties ship at 1.00, and the new-manager penalty with them, so a stale competition window, nationality group or manager list can no longer mis-score a player — only mis-inform the agent reading it. armband congestion prints what was derived from the calendar against what is still missing, and how old it is.

rest_players is the exception, and it is live. It multiplies expected minutes inside blendFor, so a misspelt name there is a mis-scored player, not a cosmetic error — and it bites in GW1 and GW2, the two gameweeks straight after the summer maintenance that was supposed to have checked it.

Known limitations

Worth knowing before you trust a recommendation:

  • Free transfers are reconstructed, not published by FPL. Close, but verify against the site if a decision turns on the exact number.
  • Selling prices are reconstructed too, and that one is checkable. FPL pays you what you paid plus half of any rise, so a squad cannot be sold for its market value. The reconstruction is proved right when it sums to FPL's own published team value, and reported as an assumption when it does not.
  • The model cannot distinguish injury absence from being dropped. Both look like low expected minutes. This is the largest measured gap in the system and the reason the agent layer exists.
  • The replay cannot test judgement. Team news is worthless in hindsight, so a backtest measures the floor the model provides rather than the system's actual output.
  • Not every constant in the model is provably the best one. Seasons, not gameweeks, are the scarce axis, and six seasons of football is a small sample for questions at that resolution. docs/replay.md is candid about which comparisons the harness can settle and which it can't.

The tool says this to your face rather than leaving it in the documentation: armband brief writes a "what this model cannot see" section into every briefing it produces.

The planner's News tab does the same job for the half that moves week to week. It shows every piece of team news that was read into the model, with the source and the date attached, and separately lists who the model thinks may not start — a player who is fit, has no flag against him, and is still not certain to be picked. That second list is the one that earns its place, because the flagged players are the easy half.

The planner's News tab, headed "Most weeks nobody you own is injured, and someone still isn't playing." A panel explains that when something changes what a player is likely to do, it is told to the model with the source and the date. A section titled "Why this tab exists" gives three real cases: a captain found on the bench at three o'clock, Haaland's father giving the real update on his foot by selling him on FPL in December 2023, and Chelsea's team news arriving as a barbershop photo in April 2026 — under the line "We don't know the barber. We do follow his Twitter." Below, "Team news we've read" lists Raya, flagged as needing a look after being named first choice for the season, and Arrizabalaga with a long-term injury, each carrying the date it was read. A final section, "Who may not start", names Kusi-Asare as fit but uncertain, with his modelled minutes cut from 90 to 1. A COMING SOON banner above all this describes team news read as it lands, which is not built yet.

Documentation

Everything in docs/ is reference — a description of the system as the code stands, nothing speculative. Seven documents and an index:

docs/accuracy.md How well it actually predicts, measured against a five-game average and a season average. Start here if you want to know whether it works.
docs/model.md How players are scored: underlying stats, fixtures, expected minutes, congestion, role risk. The intellectual core.
docs/configuration.md Every config field, what it does, and what you must fill in by hand.
docs/workflow.md The weekly review protocol and chip strategy.
docs/architecture.md Code layout, data flow, SDK gotchas, how to extend it.
docs/replay.md The backtest harness: how a scoring change is validated, and what it can and cannot resolve.
docs/backfill.md Recovering historical team news from the Internet Archive, and the one rule that must not be got wrong.
docs/README.md The map: what each document covers and how they fit together.

Testing

go build ./... && go vet ./... && go test ./...

Tests hit the live FPL API and skip cleanly when unreachable. They assert invariants and regressions, not exact values, since the underlying data changes weekly — a test pinned to a specific player or score rots within days.

Each regression test encodes a bug that was actually shipped; see docs/architecture.md for a representative five and the optimiser's three guards. The replay's own diagnostics are behind DIAG=1 and do not run in the ordinary suite — docs/replay.md covers those.

About

An AI Fantasy Premier League analyst that runs on your machine. Scores every player with a quantitative model built from FPL's actual scoring rules, interrogate the result through a live tool surface, challenge it, and record what the model cannot see — an injury, a lost place — as a durable override.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Used by

Contributors

Languages