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.

Ballpark Genius trade board, headlined by the Tarik Skubal / Adley Rutschman deals, showing WAR, awards, and control-year estimates for every player involved

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