Building Blockbuster Trades: Ranking a Decade of MLB Deals Without Making Up a Score
Ballpark Genius tracks MLB trades — who went where, and how big a deal actually was. “How big” is the interesting part: a two-month rental reliever and a controllable six-year ace are not the same asset, and a naive trade feed treats them identically. This is the story of building that ranking end to end — from a primary-key bug that was silently collapsing multi-player trades, to a scoring function whose weights came from grading 201 real trades by hand rather than picking numbers that felt right.

The trade board today — the Skubal and Rutschman deals are the two real trades this whole build kept getting checked against.
First, the data had to stop lying about who was in a trade
Before any ranking could matter, the transactions table had a structural bug: Transaction.id
was declared as the primary key, but MLB’s Stats API id field is a deal identifier shared by
every player in that trade, not a per-person id. A four-player trade upserts four rows that all
share one id — so each row overwrote the last, and only one player of every multi-player deal
ever survived in the database. The natural key needed to be (dealId, personId), not id alone.
Re-keying that was the precondition for everything that followed: you can’t rank a trade’s
players if the database only remembers one of them.
With the key fixed, I ran a one-off backfill pulling MLB’s transaction history in monthly windows
from January 2016 through the present. The first full run silently dropped 56 of 128 windows.
The cause: the API client wrapped its schema validation in a try/catch that swallowed any Zod
error and returned an empty array — so one malformed record failed validation for the entire
month, and the caller had no way to distinguish “zero trades this month” from “parsing blew up
and we got nothing.” The captured Zod errors pointed at the real cause: some historical records
(pre-2020 releases, minor-league moves) omit a name field on the traded-to or traded-from team
that the schema had marked required. Making that field optional, and adding a strict variant of
the fetch that rethrows instead of swallowing, turned “quietly wrong” into “loudly correct”: the
re-run processed all 132 windows with zero failures, landing 513,690 transactions including 5,883
trade records going back a decade.
Estimating control years without contract data
A trade’s weight depends heavily on how many years of team control each player still has left —
but this project has no contract or service-time database yet. So I built an estimator that
works from what’s actually available: MLB debut date, bucketed into EXPIRING / SHORT /
MEDIUM / LONG control windows based on years of service.
Before shipping it, I ran the validation the spec called for: hand-check the 25 active players with the highest career WAR — the worst case for a debut-date-only heuristic, since long-tenured stars are exactly who tends to sign extensions the heuristic can’t see — against their real contract status via web search. 15 of 25 (60%) were false positives. The heuristic was calling players “expiring” who were actually locked up for years.
60% is well above the 25% threshold the spec had set as an acceptable error rate for a rough
estimate, so the fix wasn’t a tuning pass — it was changing what the UI claims. The bucket’s
display label went from the confident-sounding RENTAL to the honest CONTRACT UNKNOWN, and
every control estimate in the product now carries a persistent confidence: 'estimate' flag.
The estimator wasn’t good enough to assert a fact, so the product stopped asserting one.
Scoring “blockbuster” against real trades, not intuition
The ranking itself — scoreBlockbuster() — blends six weighted signals: star power, awards,
headcount, estimated control years, deadline proximity, and whether the trade crosses leagues.
The weights aren’t guesses. They’re derived by scoring all 201 real MLB trades from the 2026
season against three different candidate models — star-dominant, package-weighted, and a
WAR-rate model — and picking the one whose output actually matched a human sense of “how big was
this trade,” which meant falsifying and discarding the first two before landing on the current
one.
Two bugs surfaced in the process worth calling out because both hid behind a passing test suite:
Award matching was silently wrong. The scorer needs to recognize award types like Cy Young
and MVP, but the real award_type values in the database are granular — WS_MVP, ALCS_MVP,
NLCS_MVP, ALL_STAR_MVP all contain the substring MVP. A naive substring match would score
every one of those as a full regular-season MVP. Worse, the test fixture for the headline Skubal
trade used awardType: 'AL_CY_YOUNG' — a value that doesn’t exist anywhere in the real data (Cy
Young is stored league-neutral as CY_YOUNG, with league in a separate column). That typo meant
the fixture silently fell through to the generic unknown-award fallback weight instead of
exercising the actual Cy Young weight — so the single highest-value award in the whole config had
zero real test coverage, and the test suite was green the entire time. The fix was exact-key
matching plus a corrected fixture pulling Skubal’s real award rows straight from the database.
Truncation before filtering. A related bug in the trade-detail page: getPlayerTradeDeals
scanned a date range and capped it with limit: 500 before filtering to the player being
viewed. Across a full career that window holds thousands of deals, so for a player with enough
transaction history, their trade could get cut by the limit before the filter ever ran. The fix
was to select by dealId directly instead of truncating a superset and hoping the target
survived. This is the same shape of bug as the ranking’s earlier limit-then-filter mistake in a
different query — worth naming as a pattern: never truncate a set you’re about to filter.
What actually shows up in the product
The result is what’s on the trade board now: every deal shows real WAR, real awards, and a control-year estimate that’s honest about being an estimate — not a synthetic “trade grade” dressed up to look more certain than the underlying data supports. The Skubal/Dodgers-Tigers deal that ran through every stage of this build as the reference case — four players, real awards, a Cy Young winner headlining one side — is the top-ranked trade of the season, which is also just true.
— James