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.

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

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.