A few days ago I wrote that a basketball rules file would come out nearly empty, and that the interesting part of an NBA optimizer is not the optimization. Then I went looking for something else and found a workbook I had forgotten I wrote: FCP Optimizer — Master v6.7, an NBA daily fantasy optimizer in Excel, with a change log that starts at version 4. So rather than speculate about NBA DFS optimizer constraints, here is the actual model, read straight out of the file.
It is a mixed integer program with about 200 binary variables, solved by CBC through the OpenSolver add-in. Everything below is what the sheet and its macros really do, not a cleaned-up version of it.

One binary per player
The decision variable is a single column, named player_picked, one cell per player, constrained to binary. Roughly 200 rows. Everything else in the model is a SUMPRODUCT against that column — which is to say the whole thing is a knapsack problem wearing a spreadsheet.
The objective is a column you swap out
The objective maximizes SUMPRODUCT(player_scores, player_picked). What makes it more interesting than it sounds is that player_scores is not a fixed projection. There are six multipliers sitting in one row, and six one-line macros that set exactly one of them to 1 and the rest to 0:
| Mode | What it optimizes for |
|---|---|
| floor | the player’s worst recent games |
| likely | the central projection |
| ceiling | the best recent games |
| matchup value | projection adjusted for the opponent |
| projection source | the raw subscription projection, untouched |
| 5-game | a short rolling average |
Same constraints, different objective, one click. That is a design decision I would defend today: the constraints encode what the contest permits, and the objective encodes what you are trying to win. Keeping them separate is why the model survived six major versions without being rewritten.
The constraint set
In order of how obvious they are:
Roster size. The picks sum to exactly 9 on FanDuel, 8 on DraftKings.
Salary cap. Salaries dotted with picks, capped at $60,000 or $50,000 depending on the site. A pair of macros rewrites every bound when you switch sites — including a Yahoo variant whose cap is 200, because Yahoo prices players on a completely different scale.
Per-team cap. A SUMIF per team against the picks, capped at four. In the cash-game sheet that same cap is set to three. Nothing forced that difference; I tightened it because cash lineups that lean on one team lose together.
Eligibility. A column that multiplies several filters into a single 0/1 flag: dropped if forced out, if the projection is under a floor I set that night, if the salary is at or below the minimum (an optional toggle for punting out the unplayable), if the salary is zero, and if the injury status maps to OUT. There is a small lookup table for that last one — Probable and Playing map to IN, Questionable, Doubtful, Injured, Out and Out for the season all map to OUT. Treating Questionable as out is an opinion, not a rule, and it is sitting there in two cells where I can change my mind.
Force-in. This one is a hack and I am not going to pretend otherwise. Locking a player into the lineup is not a constraint in this model — it multiplies his projection by 999. The solver then picks him because the arithmetic is overwhelming. It works, it never makes the problem infeasible the way a badly-specified equality can, and it is the kind of thing you do at 6pm when lock is at 7.
The position constraint is the one worth reading
Seven position groups, each with a minimum and a maximum, and the groups deliberately overlap: PF, SF, F (which is defined as SF plus PF), PG, SG, G (PG plus SG), and C.
On FanDuel the minimum equals the maximum on every row — 2, 2, 4, 2, 2, 4, 1 — because FanDuel gives you fixed slots and there is nothing to decide. On DraftKings the bounds go loose: minimums of 1, 1, 2, 1, 1, 2, 1 against maximums of 2, 2, 3, 2, 2, 3, 2. That gap is where the flex slots live. A guard slot that accepts either a point guard or a shooting guard is not modeled as a slot at all; it is modeled as PG + SG must total at least two. The center maximum of two exists only because the utility slot will take a center.
This is Fourier–Motzkin elimination, and I have written about why I eventually retired it in the Python rewrite. Seeing it in the original is a better argument for it than my explanation was: seven rows of minimum and maximum, no assignment variables, no extra binaries, and the entire multi-position problem disappears into arithmetic. There is even a small macro in the file that generates those overlapping sums by comparing position strings, so adding a site meant adding a row, not rewriting a model.
Risk is a constraint, not part of the objective
This is the part I had genuinely forgotten, and it is the best idea in the workbook.
The lineup’s total variance is computed as a SUMPRODUCT, exactly like salary. In the cash sheet it is bounded below by a limit cell. After every solve, a macro sets that limit to the variance the solver just used, plus 0.01, and runs again.
So the second lineup is the best available lineup that is slightly riskier than the first. The third is slightly riskier than the second. Run it twenty times and you have not generated twenty lineups, you have walked the efficient frontier from the safest lineup available upward, one small step at a time, with the best lineup at each level of risk. Cash games take the bottom of that curve. The tournament sheet does the same thing in reverse, dropping the bound by nine each round so variance is free to roam.
I never wrote that down anywhere. It was a checkbox and a macro.
Diversity is written by a macro, not by hand
After each tournament lineup is saved, a macro walks the picked column, builds a formula summing exactly the players that lineup used, and writes it into the next free row of a growing block. Each of those rows is capped. That is a no-good cut — the next lineup may share at most k players with any lineup already built — assembled by string concatenation in VBA because that was the tool available.
A second macro handles exposure: once a player has appeared in as many lineups as you allow, he is added to a constraint that holds him out. Between the two, a run of twenty lineups is a set rather than twenty variations on the same core.
The fossils
The file has 99 defined names. About twenty of them point at #REF!, and they have names like qb_stack, wr_per_team, rb_per_team, te_plus_defop, and a run of last_qb through seventh_last_qb for rotating quarterbacks across lineups.
This workbook started life as a football optimizer. When I converted it to basketball I deleted the stacking columns and the names broke. And that is the part that stopped me, because I spent the last week measuring three seasons of box scores to work out whether NBA teammates correlate enough to justify stacking constraints, and concluded they do not.
Whenever I built this, I had already reached the same answer. I just reached it by deleting the columns and moving on, and left no note explaining why. The measurement I did last week is the note.
What ports
Almost all of it. The binary column, the salary cap, the overlapping position bounds, the team cap, the eligibility filter, the variance ratchet and the diversity cuts are all ordinary MILP constructs that a Python solver handles more comfortably than Excel ever did — and having already measured what the solver choice is worth, I know that part is not where the risk lies.
What does not port is the thing the file was built around: a subscription projection source, pasted in every night. The model is good. The data underneath it was rented, and that is the honest reason this was never a product.
Things that I use, like, and am affiliated with:
Mint Mobile offers great cell phone service for $15 flat, get $15 off using the link. Get discounted phones with service activation and no contract.
I never spend money before I check Mr Rebates or Rakuten to get cashbacks, rebates, discounts, coupons or cheaper gift cards.
