Four seasons ago I wrote a post about winning (or doing better) at daily fantasy football with MATLAB. The pitch was simple: DFS is an optimization problem wearing a jersey, so let the Optimization Toolbox pick your lineup. Sixty lines of MATLAB, one salary cap, one answer.
That post still works, and the script still runs. But it taught the wrong objective for half the contests people play. And it assumed the person running it was you, pasting code into a Live Script. Both of those changed, so I rebuilt the thing.
The repo is the same one: github.com/nothans/dfs-optimizer. It now has a function library, an app for exploring the options, a test suite, and a section for coding agents. In 2026 the “user” of a tool like this is as likely to be Claude Code or OpenAI Codex with a MATLAB session open as it is to be a human with a mouse. This post is the new tutorial.
What the 2022 script got right, and what it got wrong
The 2022 model was one binary variable per player and a handful of linear constraints: one QB, one defense, two to three running backs, three to four receivers, one to two tight ends, nine players total, salary under the cap. Maximize projected points. That is still the core, and the new code returns the identical lineup on the same data. There is a test that proves it.
What it got wrong is subtler. “Maximize projected points” is the correct objective for a cash game, a 50/50 or a double-up, where you’re paid if you finish in the top half. You want the highest floor, and the highest projection is a decent proxy.
It’s the wrong objective for a tournament (a GPP, guaranteed prize pool), where a few thousand entries compete for a top-heavy payout and the winner needs a top-1% finish. In a tournament, the highest-projected lineup is the lineup everyone else has too. You want ceiling and you want to be different. That means correlated players (a QB and his receiver score on the same plays), lower-owned players (so a hit separates you from the field), and more than one lineup.
Get the data
The projections still come from Daily Fantasy Fuel. Pick the DraftKings or FanDuel tab, clear any filters, let the whole table load, then click Download CSV and save the file. The button exports the rows on screen, so a filtered view gives you a filtered file. The header looks like this:
first_name, last_name, position, injury_status, week, game_date, slate, team, opp,
spread, over_under, implied_team_score, salary, L5_dvp_rank, L5_fppg_avg,
L10_fppg_avg, szn_fppg_avg, ppg_projection, value_projection, ownership_projection
That last column matters. Projected ownership is what makes the tournament levers work, and the 2022 post ignored it.
You don’t need to import the file by hand anymore. The loader reads it directly, drops anyone marked out, and keeps every original column:
players = dfs.loadProjections("DFF_NFL_cheatsheet.csv");
If you don’t have a download handy, the repo ships a synthetic 13-game slate in the same header, with made-up names, so every example below runs as-is, and you’ll meet a few of those names again before this is over.
The app, in five clicks
Open MATLAB in the repo folder (or click the Open in MATLAB Online badge on the README) and run:
DFSOptimizerApp
It opens on the sample slate. Levers on the left, results on the right.

Click one: the Cash game preset, then Optimize. This is the 2022 script with a face. One lineup, maximum projection, no stacking, ownership ignored. On the sample slate it lands at 176.4 projected points and a summed ownership of 192%, which is a polite way of saying “everyone has these players.”

Click two: the Tournament preset, then Optimize. Now the sidebar asks for a QB stack (one receiver or tight end from the QB’s own team), a bring-back (one skill player from the opponent), and no defense against your own QB. It also wants a small penalty on projected ownership and twenty lineups, where nobody appears in more than half and every pair differs by at least three players. A progress bar counts them off, about a quarter second each.

Click any row to see the roster. The summed ownership drops into the 70 to 150 range and the QB column shows the portfolio spreading across three or four quarterbacks instead of one.
Click three: lock and exclude. The Player pool tab is the whole slate with two checkbox columns. Tick Lock on a player you believe in and Exclude on one you don’t, and every solve from then on honors it.

Click four: Exposure. This is the chart I wanted in 2022 and didn’t have. Your exposure per player across the twenty lineups, next to the field’s projected ownership. The gap is leverage. A player you have at 40% who the field has at 2% is where a tournament gets won or lost.

Click five: Simulate. A projection is a mean, not a promise. The Monte Carlo tab draws fifty (or five hundred) noisy versions of the projections, re-solves the lineup for each one, and counts who made it. A player who’s optimal in 60% of the draws is a play. A player who’s optimal only at the exact point estimate is a coin flip with a good agent.

There’s a sixth button, Copy as code, and it’s the one I use most. It writes your current sidebar as a function call and puts it on the clipboard, so a session in the app turns into a script you can rerun next week:
players = dfs.loadProjections("data/sample_DFF_NFL.csv");
[lineups, summary, exposure] = dfs.generateLineups(players, 20, ...
Site="DraftKings", SalaryCap=50000, OwnershipWeight=0.05, StackSize=1, BringBack=1, ...
AvoidQBvsDST=true, MaxExposure=0.5, MinUnique=3, Randomness=0.1, Seed=1);
The levers, one constraint each
The app is a thin layer over a package called +dfs, and every lever is a name-value option on dfs.optimizeLineup. Each one is a single linear constraint on the same binary variables the 2022 script used. Plain words first, then the code, lever by lever.
Stack. For every quarterback in the pool, “the number of his own receivers and tight ends you pick is at least k times whether you picked him.” When you don’t pick him the right side is zero and the constraint sleeps. When you do, it demands k partners. No big-M tricks, one row per QB.
L = dfs.optimizeLineup(players, StackSize=2);
Bring-back. The same shape, aimed at the opponent’s skill players. It bets on a shootout.
L = dfs.optimizeLineup(players, StackSize=1, BringBack=1);
No QB against your own defense. A pairwise exclusion: QB plus the opposing defense is at most one. Your defense scores when his offense fails. Do not root against yourself.
L = dfs.optimizeLineup(players, AvoidQBvsDST=true);
Ownership penalty. Instead of maximizing projection, maximize projection minus lambda times projected ownership. On the sample slate, a lambda of 0.05 with a two-man game stack gives up 3.8 points and sheds 23 points of summed ownership.
[L, info] = dfs.optimizeLineup(players, OwnershipWeight=0.05);
Exposure and uniqueness. When you build twenty lineups in a row, each new solve gets two extra rules: players at their exposure cap sit out, and “the overlap with every earlier lineup is at most nine minus u players.”
[lineups, summary, exposure] = dfs.generateLineups(players, 20, MaxExposure=0.4, MinUnique=3);
FanDuel. Different cap, a four-per-team limit, three-team minimum. One word.
L = dfs.optimizeLineup(players, Site="FanDuel");
If you like your math in one block, this is the whole model:
maximize sum_i (p_i - lambda * o_i) x_i
subject to sum_i x_i = 9
1 QB, 1 DST, 2-3 RB, 3-4 WR, 1-2 TE
sum_i s_i x_i <= cap
players from at least 2 games (DraftKings)
at most 4 per team, at least 3 teams (FanDuel)
partners of QB q >= k * x_q (stack)
opponents of QB q >= b * x_q (bring-back)
x_q + DST facing q <= 1 (no QB vs DST)
overlap with earlier lineup <= 9 - u (uniqueness)
x_i in {0, 1}
The FLEX spot isn’t a variable. It’s the slack between each position’s base count and its maximum, pinned by the total of nine. That trick was in the 2022 script and it’s still the reason the model stays at one variable per player.
The academic version, stacking constraints included, is Hunter, Vielma and Zaman’s “Picking Winners in Daily Fantasy Sports Using Integer Programming” from MIT. The levers above are the practical subset every serious optimizer ends up with.
Trust, then verify
Two things happen after every solve, and both are new.
First, the code checks the solver’s exit flag and refuses to hand you a lineup the solver didn’t actually finish. Second, the lineup goes through dfs.validateLineup, a function that knows nothing about the solver. It counts positions, adds salary, checks the games and teams, and complains in plain English if anything is off. If the solver and the validator ever disagree, you get an error, not a lineup.
There are 22 tests. The one I care about most feeds the same slate to the 2022 formulation and the new one and asserts they return the same players and the same 176.4 points. The model got a lot of new rules, but the old answer didn’t move.
The other user: a coding agent
MathWorks ships the MATLAB Agentic Toolkit. It does two things. It installs the MATLAB MCP Server, which gives a coding agent (Claude Code, GitHub Copilot, Codex, Gemini CLI, Amp) a live MATLAB session it can run code in, lint with, and test through. And it installs skills: curated MathWorks knowledge so the agent writes idiomatic MATLAB and stops inventing toolbox functions.
I used two of those skills to build the refresh. matlab-solve-optimization is where “check the exit flag, then validate independently” comes from. matlab-build-app is where the app’s layout comes from (it calls the sidebar-plus-display shape an Explorer, and it’s right). The skills read like design documents from someone who has built a lot of MATLAB apps and solved a lot of optimization problems, which is what they are.
Setting it up takes three steps:
- Download agenticToolkitInstaller.mltbx, open it in MATLAB, and run
setupAgenticToolkit("install"). Pick the skill groups you want; for this repo that’s Math and Optimization, MATLAB App Building, and MATLAB Core. (The installer is shared with the Simulink Agentic Toolkit, which is why the download lives in that repo’s releases.) - In MATLAB, run
shareMATLABSession()so the agent works in the session you can see. - Open the repo in your agent. There’s an
AGENTS.md(and aCLAUDE.mdfor Claude Code) that gives it the map, the rules for changes, and prompts that work.
Then you stop clicking and start asking. This is a real exchange from my session, with the agent driving MATLAB through the MCP server:
Me: Load the sample slate, build 20 DraftKings tournament lineups with a 2-man stack, 40% max exposure and 3 unique players per pair, and tell me the five players where my exposure is furthest above projected ownership.
Agent: Built 20 lineups (164.2 to 173.1 projected points, summed ownership 106% to 187%). Your biggest leverage plays:
Name Pos Team Exposure Field Leverage Nico Ironwood WR IND 40% 1.7% +38.3 Cameron Quillon WR DEN 40% 7.5% +32.5 Gideon Okafor WR LAR 40% 7.7% +32.3 Elliot Ashcombe TE MIA 40% 8.7% +31.3 Xavi Fenwick WR NO 40% 11.7% +28.3
The names are fake because the slate is synthetic. The workflow isn’t. The agent called dfs.generateLineups, sorted the exposure table by leverage, and showed me the top five. Then I asked it to add a rule I had been meaning to write for a year (cap the players from any one game), and it added the constraint, a validator clause, and a test, ran the suite, and reported 23 of 23.
The README’s “getting started” section used to be steps for a person. Now the useful section is a list of prompts, each of which is a claim the agent can check by running the code.
The caveats, because it’s still football
Projections are still projections. The optimizer is only as good as the numbers you feed it, and the numbers are a mean over a game that hasn’t happened.
The Monte Carlo view uses per-position volatility guesses (quarterbacks are steadier than tight ends, defenses are chaos) that I picked, not measured. They’re labeled as working values in the code. Replace them with your own standard deviations if you have them; the simulator will use a StdDev column if it finds one.
The stacking wisdom is what the DFS sites and the MIT paper agree on, but the correlation numbers you see quoted around the internet are site-reported, not something I verified. Treat the levers as structure, not as guarantees.
And the sample slate’s players don’t exist. Please do not roster Nico Ironwood.
Go build one
The code is MIT-licensed at github.com/nothans/dfs-optimizer and on MATLAB File Exchange. The 2022 post is still there if you want to optimize a lineup.
Download a slate, run the Tournament preset, look at the exposure chart, and then argue with it. Let me know what your lineup looked like, and send a pull request if you teach it a new trick.
