So I spent an embarrassing Tuesday night staring at eleven spreadsheets that were technically other people’s fantasy teams, trying to find one trade that would make mine better. I gave up after twenty minutes, fired off a lazy offer to a friend, and got back a one-word “no” before I’d finished my coffee. That little humiliation is the whole reason this thing exists.
Let me back up, because this only makes sense if I explain the game first. I promise to keep the jargon to a minimum and define every word I use.
What this thing actually does (no football required)
Imagine a year-round game where each person manages a pretend team made of real NFL athletes. You don’t own the humans, you own their performance. When your running back scores in a real Sunday game, your fantasy team gets points, and you beat the friend whose team scored fewer. That’s fantasy football. A team fields a fixed lineup with a slot for each position (quarterback, running back, receiver, and so on), so you can only start a handful of players at once, no matter how many you own.
Normal fantasy resets every year. Everyone re-picks from scratch. Dynasty is the variant where you keep your roster year after year, like a stock portfolio of athletes instead of a fresh hand of cards each season. That one change makes the whole thing addictive, because now a 21-year-old rookie who might break out in three years can matter as much as this week’s starters.
Because you keep your team, the main way to improve it is a trade: two managers agree to swap assets. An asset is either a player or a future draft pick (the right to choose an incoming rookie next year, which holds value the way a gift card does before you spend it). A trade is two collectors swapping baseball cards. Both sides have to say yes. Nobody can force a swap.
Here’s the catch that makes trading miserable in real life. A typical league has eleven other managers. Each has fifteen-plus players plus picks. To find one good trade you’d have to mentally walk every rival’s roster, imagine every combination of players-for-players, and for each one ask three separate questions: is this fair, is it actually good for me, and would the other person ever accept it. People don’t do this. They fire off one lazy offer, get rejected, and quit. (See above. Hi.)
The Shop/Trade Finder does that walk for you. You tell it either “I want to get rid of Player X” or “I want to land Player Y,” and it searches every rival team and hands back a ranked list of specific, realistic swap packages.
The analogy I kept coming back to while building it: a real-estate matching engine that doesn’t just confirm two houses are priced the same. It also checks that the buyer actually wants that house and that the seller isn’t wrecking their own life by selling. Price-matching is the easy 10%. The wanting and the not-wrecking is the interesting 90%.
One last piece of vocabulary. Every player and pick carries a dynasty value, a single market-price number scraped from public sources that says roughly what the community thinks an asset is worth, like a Kelley Blue Book price for athletes. Hold onto that, because almost everyone, including me on day one, builds the wrong thing with it.
The version I almost shipped
The naive build is seductive. Add up the dynasty values on each side. If they’re within some percent, stamp it “fair.” Done. A trade value calculator.
I almost shipped that. It feels rigorous. It has numbers in it. And it is a lie.
A fair trade and a trade that happens are different objects. The calculator answers “are these two piles of value roughly equal,” which is a real question, but it’s maybe a third of what you need to know. It says nothing about whether the deal helps you, and nothing about whether the other manager would ever click accept. I shipped a private version to a few friends and watched them ignore almost every “fair” suggestion it produced. The suggestions weren’t wrong, exactly. They were useless. Which is worse.
Three cracks opened up, and each one taught me something.
Crack one: the quantity trap
The calculator loves giving you three mediocre players for one star, because three small numbers can add up to one big number. No human alive accepts that. Nobody trades a top-5 running back for a backup tight end plus a backup quarterback plus a benchwarmer, even when the values “add up.” Your roster only starts so many players, so three spare parts will never replace one cornerstone.
So raw value sums needed a tax. The best asset on each side keeps its full value, and every extra “filler” piece gets discounted based on how far below the headliner it sits, plus a small stacking penalty that grows with each additional piece in the package (0.08 per extra asset, regardless of position). In code it’s computePackageAdjustedDelta, and the discount per filler piece is min(0.6, (1 - val/anchor) * 0.5 + 0.08 * rank), capped so no single piece loses more than 60%.
The effective “filler tax” lands here:
flowchart LR A["1:1<br/>tax ~0%"] --> B["2:1<br/>tax ~17%"] B --> C["3:1<br/>tax ~30%"] C --> D["4:1<br/>tax ~40%"] D --> E["6:1<br/>tax ~48%"]
Those percentages are for value-even packages. A lopsided package taxes much less: a 2:1 where one big anchor carries 90% of the value and a scrap carries 10% only loses about 8%, because the filler barely matters. The curve is a model inspired by KTC’s package-value adjustment, and the rates above fall straight out of the formula. The affordability and anchor-ratio thresholds elsewhere in the engine are the part that’s calibrated against 1,084 real dynasty trades pulled from 100 Sleeper leagues. The more bodies you stack to match one star, the more the market quietly discounts the pile.
Crack two: value is path-dependent
Here’s the one that broke my brain a little. The same player is a HOLD for one team and a SELL for another, on the same day, at the same market price.
If you’re trying to win a championship this year (a “contender”), an aging star running back is exactly who you want. If you’re rebuilding for three years from now, that same player is a melting ice cube you should cash out for youth and picks while he’s still worth something. The market value number is identical, but the right move runs in the opposite direction.
There is no single correct value for a player. It depends on your timeline, the league’s scoring rules, the player’s age, and how deep you already are at his position. The calculator can’t see any of that. It just sees a number and declares two numbers equal. So I had to teach the engine where the user sits in their own competitive arc and score every trade through that lens.
Crack three: it takes two
This is the heart of the thing. A perfectly value-even deal is worthless if the other manager would never, ever say yes.
Picture offering someone a swap where they’d give up their only startable quarterback, or hand over their best player and get back a bag of low-demand filler. The dynasty values can match to the dollar, and the human on the other end still closes the app without replying. “Good for me” and “would they accept” are not the same question, and you cannot collapse them into one. So I split them into two completely separate scoring dimensions.
That split is the whole reason it stopped feeling like a calculator and started feeling like a trade shop.

The sell side: it surfaces who on your roster is worth moving, then finds rivals whose needs match. The reasoning under each target (‘your wide receiver is the 6th-best at that position in your lineup of 10 starters, so a contender may sell youth for wins’) is the path-dependent logic made visible.
The reframe: it’s a recommender, not a formula
Once I accepted that, the shape of the solution was obvious. This isn’t arithmetic. It’s a recommendation problem, and recommendation problems have a known skeleton: generate candidates, filter them down, score what’s left, then rank and diversify.
I treated trade finding like search. Here’s the spine of the whole system:
flowchart LR I["Your intent:<br/>sell X or buy Y"] --> G["Candidate<br/>generation"] G --> F["Recall + precision<br/>filters"] F --> S["7-dimension<br/>soft scorer"] S --> R["Rank, dedupe,<br/>diversify"] R --> V["Verdict +<br/>ranked packages"]
Every hard part of this project lives in one of those boxes. Let me walk them.
Candidate generation without melting the laptop
The naive enumeration is a horror. Rosters times partners times players times picks times package shapes. A package “shape” is just how many assets go each way: one-for-one, two-for-one, two-for-two, on up. Enumerate all of it exhaustively and a single partner can produce tens of millions of combinations. Multiply by eleven partners and your laptop fan sounds like a leaf blower.
So I split by shape. Small shapes (one-for-one up through three-for-three, plus a few lopsided ones) get exhaustive enumeration, because the combination counts are tame. The big ugly shapes (three-for-four, four-for-four) use what I call anchor-and-expand: pick the best few “anchor” assets per position, then greedily fill the remaining slots to hit a value target, instead of trying every subset. That alone turns an 8-to-63-million-combo nightmare into something that finishes before you blink.
On top of that, a stack of cheap gates kills junk before it’s ever scored: minimum value floors so you don’t get offered scraps, value bands so the two sides are at least in the same ballpark (plus or minus 20% for one-for-one, looser for multi-asset deals to leave room for the filler tax), liquidity floors so it won’t suggest a player nobody wants to trade for, and a hard cap of 2,000 candidates per partner. Protections, too: it won’t give away your top starters or leave you with zero players at a position.
One detail I’m fond of. The liquidity floor (how often a player actually gets traded) has an elite exemption. Truly great players sometimes look “illiquid” in the data, not because nobody wants them but because their owners never let go. Low liquidity on a 5,000-value asset means “held because valuable,” not “untradeable.” So assets above that threshold skip the liquidity gate entirely. Missing the distinction would have hidden the best targets in the league.
The soft scorer: seven dimensions, no dictators
Surviving candidates hit the scorer. Seven dimensions, each producing a 0-to-1 number, combined into a weighted sum:
| Dimension | Weight | What it asks |
|---|---|---|
| userNeedFit | 0.22 | Does this fill a hole on my roster? |
| userFit | 0.20 | Does it match my timeline (win now vs rebuild)? |
| depthImpact | 0.18 | Does it improve my starting lineup? |
| fairness | 0.16 | Are the values even (after the filler tax)? |
| partnerMotivation | 0.12 | Does the partner have a reason to want this? |
| realism | 0.08 | Would a real human actually do this? |
| shapeMatch | 0.04 | Is the package shape clean and sane? |
Notice fairness, the thing the naive calculator treated as the entire universe, is 16%. The user-side fit dimensions together outweigh it. That’s on purpose.
The key design choice: no single dimension can veto a candidate. A great deal with mediocre shape still scores. But after the weighted sum, a set of hard multipliers can only ever punish, never reward. If you’re trying to acquire an elite player and your offer is mediocre, a rankDisparityMultiplier of 0.25 quietly drops that package to the basement. If you’re a contender and a deal breaks up one of your cornerstone players for lesser pieces, a 0.3 multiplier does the same. The multipliers are floored at 0.15 so a punished package can sink but never fully vanish (useful for debugging: I can always see what got demoted and why). Carrots in the weighted sum, sticks in the multipliers. Keeping those separate made the whole thing far easier to reason about.
Modeling “would they say yes”
The partner-acceptability model is its own little engine, and I’m prouder of it than anything else here. It scores a trade 0-to-100 from the partner’s perspective and calls it acceptable at 40 or above. It starts at 100 and subtracts penalties:
flowchart TD
A["Start: 100"] --> B{"Partner cripples<br/>a position?"}
B -- "yes, post-trade<br/>below 50% league avg" --> B1["-60"]
B --> C{"Giving up a<br/>franchise-tier asset<br/>for a weak return?"}
C -- "yes" --> C1["-50"]
C --> D{"Lopsided value?<br/>giving 1.5x+"}
D -- "yes" --> D1["-55"]
D --> E{"Quality for<br/>quantity?"}
E -- "yes" --> E1["-20"]
B1 --> X{"Pick-compensation<br/>exception?"}
X -- "received pick >= 80%<br/>of player given up" --> X1["self-harm<br/>reduced to -15"]
C1 --> F{"Score >= 40?"}
D1 --> F
E1 --> F
X1 --> F
F -- "yes" --> G["Acceptable"]
F -- "no" --> H["Rejected"]The pick-compensation exception is my favorite piece of encoded domain knowledge. Normally, gutting your own position is self-harm and gets hammered with a 60-point penalty. But if the player you give up comes back as draft picks worth at least 80% of his value, that’s not self-harm, that’s a deliberate rebuild sell-off. A rebuilder cashing in a star for a haul of picks is making a smart, common move. So the penalty drops to 15. That’s the kind of thing a veteran manager knows in their gut and never says out loud. My job was to drag it into code.
The data problem nobody warns you about
The values come from outside, scraped from a public source that publishes player rankings as HTML with no clean IDs. So I match players by normalized name and position, with last-name-and-team fallbacks and collision rejection when two guys could be the same row. It is exactly as fun as it sounds. Two sources, normalized behind one internal contract so nothing downstream knows or cares where a number came from.
The lesson that cost me the most:
Missing data is not negative data.
When a value fetch fails or a name doesn’t resolve, the dangerous failure mode is treating “I don’t know this player’s value” as “this player is worthless.” Do that and your engine confidently tells someone to dump a superstar for free, because to the broken pipeline he reads as junk. The pipeline fails silently and the engine sounds confident while doing it.
So the cutoffs are percentile-based against whatever values did load, never hardcoded numbers, which keeps them sane no matter which source is live. Data-quality gates make a failed fetch degrade confidence instead of inventing nonsense. There’s a fallback waterfall (preferred source, then the other, then synthetic liquidity derived from the value distribution, then empty) and layered caching so a flaky upstream doesn’t take the feature down. The whole point is that a failed fetch has to degrade rather than lie, which is most of what I took away from building this part.
Honesty in the verdict
Two small guards I’d put in any recommender that gives advice. clampVerdictToValue makes it impossible to paint a clear market loss as a win. If you’re hemorrhaging value, the verdict says so, full stop. And magnitude weighting (0.2 + 0.8 * min(1, receiveValue/8000)) keeps trivial little trades from topping the list just because they’re technically clean. A meaningful trade should outrank a rounding error.

The honesty guard in action: a lopsided offer gets a blunt ‘Overpay, -8.8k value’ verdict and a dual-sided impact breakdown. The engine will not flatter a bad deal.
No ground truth, so how do I know it works?
This is the genuinely uncomfortable part. There is no labeled dataset of “correct” trades. “Good” is subjective and contested. So I validate four ways, none of which is a clean accuracy number:
First, inspectable invariants. There are things that must never happen: never recommend cutting a good player, never offer a premium pick for junk, never suggest a partner harm themselves. I assert those directly. Second, domain realism: does a deal pass the sniff test of someone who’s played for years. Third, actionability: do real users actually send these. Fourth, a feedback loop. There’s a thumbs-down button wired to analytics, and an eval that replays real community trades through the engine to see whether it agrees with deals humans actually made. Every thumbs-down is a calibration target. The constants in this thing are not guesses, they’re scar tissue.

The simulator side: pick what you’d give and get, and it scores both rosters. The Shop/Trade Finder is this, run backward across every rival team at once.
What it taught me
I went into this braced to fight an LLM. I figured the hard part would be prompt wrangling, hallucinations, the usual. It wasn’t. I barely used one.
The hard part was building a domain substrate trustworthy enough that anything, an LLM or a human or a ranking function, could lean on it without falling over. The filler tax, the path-dependent value, the two-sided acceptability model, the “missing data isn’t negative data” gates: none of that is AI. It’s the slow, unglamorous work of encoding what experts know but never say.
That’s the transferable bit, and it’s why I keep thinking about it. Every domain has a layer of tacit knowledge that the obvious formula misses. The veteran who knows a “fair” trade and a real trade are different animals. The exception that flips self-harm into a smart rebuild. You can bolt a flashy model on top, but if the substrate underneath treats three scrubs as equal to a star, or a missing value as a worthless player, the flashy model just hallucinates with more confidence. The intelligence has to stand on something, so build the boring substrate first.