Building Blockbuster Trades: Ranking a Decade of MLB Deals Without Making Up a Score
Ballpark Genius tracks MLB trades now, who went where, and how big a deal actually was. “How big” is the fun part to get right and the easy part to get wrong: a two-month rental reliever and a controllable six-year ace aren’t the same asset, and a naive trade feed treats them like twins. Everyone’s read Moneyball by now, or at least seen the movie, and the lesson people took from it was “undervalued walks,” but the deeper one was “measure the thing that actually predicts winning, not the thing that’s easy to measure.” A trade score is the same problem, just with more players and worse contract data.

The Skubal and Rutschman deals, the two real trades this whole build kept getting checked against.
Before any of that could matter, the transactions table had to stop lying about who was even
in a trade. Transaction.id was declared the primary key, but MLB’s Stats API id field is a
deal identifier shared by every player in the trade, not a per-person id, so a four-player
deal upserted four rows onto one id and only the last one survived. You can’t rank a trade’s
players when the database only remembers one of them. Re-keyed it to (dealId, personId) and
re-ran a historical backfill from January 2016 forward, which silently dropped 56 of 128
monthly windows on the first pass, because the API client’s schema validation swallowed any Zod
error into a bare empty array. One malformed pre-2020 record (missing a name field the schema
had marked required) killed an entire month with no error, just quietly nothing. Made the field
optional and gave the fetch a strict variant that rethrows instead of shrugging, and the re-run
landed all 132 windows clean, 513,690 transactions, 5,883 of them actual trades going back a
decade.
Control years turned out to be the part I trusted least, and I was right not to. There’s no
contract database here yet, so the estimator works off MLB debut date alone, bucketed into
EXPIRING/SHORT/MEDIUM/LONG. Before shipping it I hand-checked it against the 25 active
players with the highest career WAR, the worst-case population, since a long-tenured star is
exactly who signs an extension a debut-date-only heuristic can’t see coming. 15 of 25, 60%,
were false positives. The bucket that used to say RENTAL with total confidence now says
CONTRACT UNKNOWN, because a heuristic that’s wrong six times out of ten has no business
sounding sure of itself.
The scoring function itself, scoreBlockbuster(), blends six weighted signals, and the weights
aren’t vibes, they’re what happens when you grade 201 real 2026 trades against three candidate
models and throw out the two that didn’t match what a human would actually call “big.” Two bugs
snuck past a green test suite along the way. First: award matching was doing substring checks,
so WS_MVP, ALCS_MVP, and ALL_STAR_MVP were all scoring as a full regular-season MVP because
they all contain the letters M-V-P, and the Skubal fixture’s awardType: 'AL_CY_YOUNG' doesn’t
exist anywhere in the real schema (Cy Young is stored league-neutral, CY_YOUNG, league in its
own column), so the single highest-value award in the whole config had zero real test coverage
the entire time it was “passing.” Second, and I’d made this exact mistake once already earlier
in the same build: getPlayerTradeDeals truncated to limit: 500 before filtering to the
player being viewed, so a long-career player’s trade could get cut before the filter ever saw
it. Never truncate a set you’re about to filter. I know this now. I knew it a week ago too,
apparently not well enough.
What’s on the board today, for folks who want the short version: real WAR, real awards, and a control-year number that’s honest about being a guess instead of a synthetic grade dressed up to look more certain than the data underneath it actually is. I could go on about the six weights and how each one earned its coefficient, but I digress, that’s a post for whenever I’ve derived weight number seven.