Trades, looked at forward: one currency, both sides, and an honest calibration
If you came from the front page. The figure quoted there, the evaluator picked the better side of 28 of 55 past trades, is the first row of the table in section 0 below. It was 25 of 55 on every revision until R6 (2026-10-16), which counts each man's games from Box Out's blend and settles a roster place a deal leaves open the same way at both ends. 28 is still what a coin does, and it is published because it is what was measured. This page is the working record: what a trade is judged in, why both sides are shown, and how the record was checked for look-ahead.
League: one real league, nine-category H2H, daily lineups, 16 teams in 2027
Written: 2026-09-21, revised the same day after the run declared in §7a, again on 2026-09-22 after the run declared in §7b, and on 2026-10-16 after the run declared in §7c
Status: the engine (app/trades/), the CLI (scripts/trade.py), the calibration (scripts/trade_calibration.py) and the published record (app/trades/calibration.py) are built, and since 2026-09-21 so are the two routes and the page (app/api/trades.py, app/api/static/trades.html, §10). §6 is the payload they sit on.
Beside it (2026-11-01): on a one-for-one, Box Out's risk model reads the same deal on its own two dimensions, this week's matchup in points of the chance and the two men's four-week values as an order (trades/risk, app/risk/trade.py, risk_engine.md "The trade read"). It is drawn in its own section under the number and carried by the connector as risk_model; nothing in this page's engine reads it, its figures are never added to the judgement's, and the judgement's "clears the bar" is not its to give.
Companions: pickups.md (the currency and the recommender it came from), scoring/ and app/scoring/trade_grades.py (the same trades, graded in hindsight), inseason_rehearsal.md (the look-ahead lesson §5 is built on)
0. The answer, up front
Revision R7, 2026-10-17: the printed figure shrunk to what happens (declared; behind a switch, not adopted)
What is proposed, declared in §17 before it was built or measured: the
judge's sizes run about four times what the graded trades went on to do, so
each side's week term and season term are multiplied by one factor k for the
league (0.10 <= k <= 1), measured on the league's own graded trades as the
slope of what a side got on the figure the judge printed, through the
origin; the net, the figure a week and the bar's mark all follow it. No
direction changes, so the record above cannot move. Behind a switch, off;
§17 has the rule, the estimator, what is measured and the reading, and
research/2026-10-17-trade-shrink/ the run.
The run (2026-10-17): k = 0.28 over 75 graded trades regenerated on a local copy (interval 0.00 to 0.64). The reading is met, narrowly: out of sample the shrunk figure's slope against what was got is +0.57 (interval -0.19 to +2.13, which holds 1) where today's is +0.28 (-0.00 to +0.64, which does not); its typical miss falls from 0.265 to 0.237 a week; no direction turns and every record cell is the same. On, "Worth a look" would mark 2 of the 154 judged sides where it marks 21 today. Not adopted; the switch is off.
Revision R7a, declared 2026-10-17 before any page changed: the lean
How R7's shrunk figure is printed, declared in §17a before the trade page, the news line or the connector was changed, on the concept the owner approved. With the switch on, a side is leaned to when its sized net a week leads the other side's by 0.05 or more; nearer than that the judge says "Too close to lean either way". "Worth a look" is no longer applied to trades; pickups keep it and their 0.20 bar. Off, every page reads as before.
Revision W2, declared 2026-10-17 before any figure: W1 with two fixes
W1's widening (below) with two to four weeks ahead fixed at 2.0 and a floor on
this week's factor late in the week, declared in
spread_revision.md and
research/2026-10-17-week-widening-w2/PLAN.md, behind the same switch. Like
W1 it is expected not to move this record; it is re-run once as §7 has it,
and PUBLISHED is not changed by that run.
Revision W1, declared 2026-10-17 before any figure: the week's widening by weeks ahead
A widening of the weekly spread that depends on how many weeks ahead a week is
is declared in spread_revision.md and
research/2026-10-17-week-widening/PLAN.md, behind a switch that defaults to
the shipped 2.0. It moves a trade's week term (delta_week); this record
scores the season terms, which do not read the head to head, so it is expected
not to move. It is re-run once with the switch off and on as §7 has it, and
PUBLISHED is not changed by that run.
Declared 2026-10-17, a study and not a revision: the league's spreads read net of the week
What a man is worth a week (the league standard behind the season term)
divides each category by the league's spread of weekly team totals. A study
declared today (research/2026-10-17-worth-net-of-week/PLAN.md, committed
before any figure) recomputes worth with each spread read net of the week
both sides of a matchup share (the within-week spread), and measures the
calibration, the ranking and who moves. Nothing in the engine changes from
it; the owner decides after it has run.
Revision R6, 2026-10-16: each man's games from Box Out's blend, and an open place one figure at both ends
What changed, declared in §7c before it was built: each man the judge
prices over the season is counted for the blend's share of his team's games
(ours and ESPN's, app.trades.games) instead of a flat 0.88; no one-season
rule; and a roster place a deal leaves open is a streamed spot, the league's
OPENED_PLACE (0.38 a week here), added to the roster's week as a scalar,
with the hindsight grade crediting the place the very same figure. The
per-game line is unchanged. Every report also says what would change the
answer (turns_on, §6).
Before and after, one run (docs/runs/2026-10-16-trade-calibration.md),
on a copy of the database the study of 2026-10-16 ran on, against the
study's baseline on the same copy and against its arm V1, which was the
expectation:
| primary cell, the thirty days after the deal | before (the engine at 1935763) |
R6 | the study's V1 |
|---|---|---|---|
| picked the better side, 55 deals (a coin: 20-35) | 25 of 55 | 28 of 55 | 27 of 55 |
| sides, mean absolute error, categories a week | 0.304 | 0.267 | 0.277 |
| sides, rank correlation (110) | +0.00 | +0.07 | +0.05 |
| sides, mean error | +0.011 | +0.007 | +0.016 |
| men, rank correlation against what they did (174) | +0.39 | +0.47 | +0.473 |
| men, mean absolute error | 0.212 | 0.198 | 0.197 |
| uneven sides (25), mean error | +0.099 | +0.099 | +0.143 |
| even sides (85): agreement, rank, mean error, MAE | 56%, +0.12, −0.014, 0.287 | 54%, +0.15, −0.020, 0.261 | 53%, +0.15, −0.021, 0.262 |
Read it this way. The deal level is still a coin: 28 of 55 sits well inside 20-35, and 55 deals cannot tell 25 from 28. The men are ranked better, +0.39 to +0.47, which is the study's own finding reproduced in the engine (the evidence it named, and the one a reader of the trade page sees in each man's figure). The fall in the sides' error is mostly a shrink of the predictions -- their spread went from 0.298 to 0.261 -- which the study showed a flat cut buys as well (its section 4), so it is not claimed. The uneven sides came back at +0.099, where V1 left them at +0.143: with the men's games counted lower and the lane not, the old split settlement would have made them worse, and settling the place at one figure at both ends holds them where they were. What is left of their error is the men and the drop the judge forces, not the place.
How it differs from V1, as declared. V1 also mixed the blend's per-game rate into the prior; the owner ruled the rate unchanged, and the study's V1r put the rate alone inside the noise (men +0.008). The even sides, which no settlement reaches, are V1's to within a point (54% against 53%, MAE 0.261 against 0.262), which is that difference. And V1 left the opened place settled as before (the best free agent's whole week in the place, the grade a scalar); R6 settles it as a streamed spot at both ends, which is where the uneven sides part from V1.
PUBLISHED, UNEVEN_ERROR, PLAYER_LEVEL_SPEARMAN, PLAYER_LEVEL_ERROR and
CALIBRATION_NOTE follow the run (app/trades/calibration.py); the note now
says 28 of them. The page prints the league's own stored record
(league_calibrations), which migration 0027 had seeded from September's
run, so migration 0075 writes this run over it. The 2027 deal the study
began from is in §7c.
Revision R5, 2026-09-24: a replayed morning reads the NBA's own injury report, and this record moved
What changed. Until this morning the engine read an injury status from
player_status_snapshots, which the listener only ever writes for the season
in progress, so every man in every replayed season was counted fit — R4
below says so in as many words, and that is why it came back identical. The
engine now applies a declared source order (docs/replay_status.md): ESPN's
snapshot on a morning the listener had already run for, and otherwise the
NBA's own official report as of ten o'clock Eastern that morning, read
point-in-time through app/injuries.py. Nothing else moved — not
ESPN_AVAILABILITY, not the hurdles, not RULED_OUT_STATUSES, which still
means Out alone of the league's five words.
Before and after. This is the first of the three records to have moved at all since the spread was widened, and it moved in the right direction.
| before | after | |
|---|---|---|
| picked the better side, 55 deals, 30-day window | 25 of 55 (45%) | 25 of 55 (45%) |
| rank correlation over 110 sides | −0.02 | +0.02 |
| sides the sign agreed on | 51% | 53% |
| mean absolute error, categories a week | 0.299 | 0.293 |
| what a man is worth a week, against what he did | Spearman +0.39, MAE 0.213 over 174 men | +0.39, 0.211 |
| 2026's own 34 sides | 32%, rank −0.31 | 38%, rank −0.16 |
Read the last row and not the first. Six seasons are scored here and the reports are loaded for one of them, so 2026 is the only slice that could move much, and it is where nearly all of the movement is: the sign agrees on 38% of its sides against 32%, and its rank correlation halves its distance to zero. The deal-level headline did not move at all, which is the right shape — ordering two sides of one deal is a coarser judgement than valuing a man.
The worked example is the run's own. Double Bonus gave up Nikola Jokic on day 72 and the evaluator called the deal −0.339 a week; with the morning's statuses visible it calls it −0.097, against a delivered +0.743. Still wrong, and a third as wrong.
What would move it further. Five more seasons of reports. The VPS holds
2022–2026 and this machine holds 2026 only; docs/replay_status.md §7 has the
one command.
PUBLISHED, UNEVEN_ERROR and CALIBRATION_NOTE were updated to the new
measured values, which is the rule of §7: the run is published whichever way
it falls and the constants follow the run.
Revision R4, 2026-09-24: an OUT man is counted for his expected games, and this record did not move
What changed. A man ESPN has ruled out was worth nothing over the rest of
the season, because the games count waited on an expected_return_date ESPN's
basketball API has no field for. He is now counted for the games he is
expected to play — the return prior of stashes.md §2a times the
ramp of its §3, declared in stash_mode.md before anything
was re-run. The evaluator reads it through weekly_lines like every other
caller, and each side of a judged deal now carries a stashed block for the
men it is taking on who are out: the odds, the dead weeks and what they cost.
Why it had to be re-run, and what it said. Every man's season value comes
from that games count, so a deal involving anybody out would be a different
number. It came back identical — 25 of 55 deals, rank correlation −0.02,
+0.39 over 174 men, every published digit — because the six seasons this
record is measured on have no stored injury status at all (snapshots cover the
season in progress only). It is a guard that nothing else moved, not a test of
the rule, and §7 of stashes.md re-scored in stash_mode.md is the test.
Revision R3, 2026-09-23: the weekly spread was widened, and this record did not move
What changed. app.pickups.stream.SPREAD_SCALE is 2.0 since 2026-09-23,
so every "chance of winning a category" in the product is nearer the middle
(docs/projected_record.md §0, docs/spread_revision.md). The factor was
chosen by the owner on the evidence of the projected-standings run of
2026-09-22 and declared before this re-run started; nothing was tuned
afterwards, which is §7's rule.
Why this calibration had to be re-run. head_to_head is where every
chance in the codebase comes from, and Judgement.delta_week — the matchup
term of a trade's net — reads it through app.pickups.stream.week_deltas. A
trade's headline number on the page is therefore not the number it was
yesterday.
Before and after: nothing. The whole run of 2026-09-23 reproduces the run
of 2026-09-22 to every published digit: 25 of 55 on the primary cell, 31
of 55 over the rest of the season, Spearman −0.02, mean error +0.008, the
two-for-one gap +0.404 against the old yardstick and +0.103 against the
streamed lane, the player level +0.39 over 174 men. PUBLISHED,
UNEVEN_ERROR and CALIBRATION_NOTE are unchanged, character for character.
Why it did not move, which is the finding. What this calibration scores is
Judgement.delta_season_per_week and SideReport.season_independent — both
per-week season terms, built from app.scoring.value.marginal against the
league's spreads and from places_cost. Neither reads head_to_head. The
week term the widening does move is not in the headline, because a trade is
judged on what it is worth to an ordinary week from here on rather than on the
matchup it happens to land in (§1, §2). So this is a real out-of-sample check
that came back clean, and at the same time it is a check of a narrower thing
than it looks: it says the widening broke nothing in the trade evaluator, not
that the widening was measured out of sample on it. The out-of-sample evidence
about the chances themselves is docs/pickups_backtest.md.
The run took 73s against 61s, on the same 55 deals and the same T30 window;
the full output is in docs/runs/2026-09-23-trade-calibration.md.
The evaluator is no better than a coin flip at picking which side of a trade did better, and none of the three revisions changed that. On the 55 deals this database can both evaluate forward and grade in hindsight, the headline picked the side that came out ahead 25 times on the window declared as primary — 45%, where a fair coin gives between 20 and 35 of 55 nineteen times in twenty. That is the same 25 of 55 revision R1 gave yesterday, and the per-man number before it gave 27. Every cell of every table we said we would print lands inside that coin's interval, so none of the differences between them is a finding either.
What did change is the one thing in the calibration that was ever a model
defect rather than a forecast miss. Deals that send two men for one were
over-rated by +0.389 categories a week, and §7b asked one question about it:
how much of that was the two engines pricing an emptied roster place
differently? The answer is nearly all of it. Re-pricing the forecast alone
leaves the gap at +0.404 — very slightly worse. Re-pricing the hindsight
yardstick beside it brings the gap to +0.103. The number was mostly an
argument between two pieces of our own arithmetic about what an empty roster
place is worth, and docs/streaming_lane.md settled the argument: 0.38
categories a week, not 0.06.
The sides of a deal where nobody's roster gains or loses a place are identical to yesterday's, to three decimals — 85 sides, 54% sign agreement, mean error −0.020 — which is the check that nothing else moved.
The other thing the previous run found still stands. The evaluator is decent at players and hopeless at deals. What it says a man is worth a week ranks at +0.39 against what his real box scores were worth over the next month, across 174 men in these deals — unchanged by R2, which is the regression check we asked for — and subtracting one side of a trade from the other leaves −0.02. The forecasts are not noise. The difference of two of them, on two rosters, is.
What is built is still worth having, and the calibration is the reason to say so carefully rather than the reason to bin it:
- A trade is judged in exactly the currency a pickup is judged in —
expected category wins, this week plus the change per week over the weeks
left, with the projected end-of-season record either way (
Judgement). So "trade for him" and "pick him up off the wire" are two numbers on one scale. - It shows the nine categories one by one, in counts and in the chance of winning each in a typical week. That is the part a nine-cat manager cannot get anywhere else, and it is the part the calibration says nothing against: whether the FT% you are giving away costs you anything is a fact about your roster today, not a forecast.
- It judges the other side too, with the same machinery, and says plainly that this is our estimate of his roster's needs rather than his opinion.
- It shows its working: games remaining, the games behind every projection, who is hurt, who is thin.
- It now carries its own record.
app/trades/calibration.pyholds the published run as data and aCALIBRATION_NOTEthe page prints verbatim under the number, in the manager's own words: how often this number has pointed at the side that did better, over what window, on how many deals, and what a coin does on the same sample.
Read it as a calculator for a conversation, not as an oracle. That is what
docs/pickups.md calls a tool and not gospel, and a 45% hit rate on the
outcome is the strongest possible argument for the language being careful.
The summary sentence under a deal now says the fit first and the number second, for the same reason: the categories it wins and gives up are the same week laid out one line at a time, and the headline is a forecast with the record above.
1. What it judges, and in what
Module app/trades/, three files: evaluate.py (the engine), summary.py
(the sentence under it) and calibration.py (the published record, as data
and as a note the page prints). Everything is a function of loaded state, and
the whole of app/pickups/judge.py is reused rather than re-derived.
evaluate_trade(session, league_season, today, side_a, side_b, *, drops=...) -> TradeReport
A TeamOffer is a team (ESPN id) and the player ids it gives away. For each
side the report carries a SideReport with:
| what | |
|---|---|
judgement |
app.pickups.judge.Judgement: this week's delta, the rest-of-season delta per week, the weeks left, the net, and the projected end-of-season record with and without |
season_independent |
the season term the first cut used, each man valued on his own inside a league-average team, kept beside the headline so both can be measured (§7a, §7) |
categories |
nine CategoryViews: the counts before and after in an ordinary week, and the chance of winning each before and after |
playoffs |
PlayoffLens: the same arithmetic over the playoff matchup periods alone, or a sentence saying why it cannot be counted |
receives / gives / drops |
PlayerCards: what each man is worth a week, his games left, his playoff games, his status, and what his projection rests on |
places_opened / places_used / replacement / opened_value |
how a 2-for-1 was settled, and at what: since R6 (§7c) a place it empties is a streamed spot, never a man from the wire; replacement is the wire level under each man in the per-man number |
summary |
the plain-English line, generated from the numbers |
notes |
the honest caveats for this side |
finish |
where the deal leaves this side in the projected standings: the projected record, the place and the playoff odds before and after (§1a) |
The currency is the recommender's, unchanged:
net = delta_week + delta_season_per_week * weeks_remaining
delta_week is app.pickups.stream.week_deltas — the same daily seating
against the same lineup, the same normal head-to-head against the actual
opponent, the same knowable lines. Not an approximation of the streaming
report's number: the function itself.
delta_season_per_week is the expected category wins of this team's
active roster in an ordinary week with the deal in it, less the same week
without it, through the league standard (Standard.week_wins over the summed
weekly_lines). It is the same pair of lines the nine-category table is drawn
from, so the headline number and the table are one arithmetic read two ways.
That is revision R1, declared in §7a before it was run; §3 is the
per-place rule it replaced, which is still what a pickup is charged by, still
what the playoff lens reads, and still on the payload as
season_independent.
weeks_remaining is weeks_after_this_period(today): the matchup weeks
after the one being played, so delta_week and the season term never share a
day.
The bar is TRADE_HURDLE, which is SEASON_HURDLE_PAID (0.20 categories a
week, set by the pickup backtest on 2026-09-21). A trade is a rest-of-season
decision that costs players, so it is labelled on the same scale as a claim
that costs FAAB. The bar labels and never hides: a deal under it is printed in
full with its number, and the most the report ever says is "clears the 0.20
bar" or "does not".
1a. The finish, added 2026-09-23
Everything above answers "what is this deal worth", in categories, against a
league-average opponent. It does not answer the question the owner actually
asked, which is where does it leave me. Since 2026-09-23 each side also
carries a finish: the projected final category record, the place and the
playoff odds, before the deal and after it, from the projected-standings
engine (docs/projected_record.md) run twice on the same seed with both
rosters changed in the second run -- because the deal happens to both sides at
once. The rosters are the evaluator's own, man for man: each side's gives
and drops leave, its receives and fills arrive. docs/what_if.md is the
write-up, and it shares its engine with the /what-if route for a named
pickup.
The place and the odds are the projected table's, which since 2026-09-25 is
ranked the way the league is -- category win share, not matchups won
(docs/projected_record.md revision R6) -- and the finish says so
(ranked_by).
It is a second lens and not a second bar. Nothing on this page is
labelled, ranked or refused on the finish; the bar is still TRADE_HURDLE on
the net. The finish comes with the simulation's own ninety-five percent
sampling band beside it (odds_band, about a point at ten thousand seasons)
and the projected record's published calibration sentence underneath, because
that forecast has a record of its own and it is not the trade number's.
On the page it sits under the nine categories, one compact line per side:
Projected 94.6-76.4 · 3rd · playoffs 92.7% → 94.4-76.6 · 3rd · 92.2%
(±0.5 points on each)
The deal's own cost: one more reading of the league's measured weekly spreads
would have been 2.9 s, so the route now reads them once and hands them to the
evaluator and to both projections; the finish layer itself adds about 1.5 s
(docs/what_if.md §4).
2. When the trade lands
A trade cannot be in a lineup the moment you think of it. The other manager has to accept, and ESPN holds it for review.
The review window is the league's own setting. ESPN's
settings.tradeSettings.revisionHours (the mSettings view) is stored by the
settings ingest as league_seasons.trade_review_hours (migration 0060),
beside trade_veto_votes_required (vetoVotesRequired) and
trade_rules_read_at, with ESPN's block kept verbatim as
raw_settings.trade. app.trade_rules.review_days turns the hours into whole
scoring periods, rounded up: 24 hours is one day, 48 two, 0 none. The deadline
in the same block (deadlineDate) has always been
league_seasons.trade_deadline, read through espn_api, and is not repeated.
A season whose trade settings have not been read yet (trade_rules_read_at
NULL) is assumed to review for 24 hours, and its report says it was assumed,
never that it came from the league. The settings ingest asks ESPN for
mSettings once (app.espn.fetch_settings_view) and hands that one answer to
the roster, draft, acquisition and trade readers.
The owner's league, read on 2026-10-04 for 2027: revisionHours 24,
vetoVotesRequired 3, deadlineDate 2027-02-19 12:00 UTC, max -1 (no limit
on trades). One day, and the 2026 ledger agrees. There are four executed
TRADE_ACCEPT rows that season, on scoring periods 22, 53, 85 and 108. In
every one of them the players named were in the old team's
daily_lineup_slots through the day before the transaction and in the new
team's on the day of it. The TRADE_UPHOLD confirmations land on the same
day as the accept. So a deal agreed overnight is in a lineup the next morning,
and a deal you are looking at this morning is in a lineup tomorrow at the
earliest.
That is a floor, not a promise: a manager who has already shaken hands passes
review_days=0, and the report prints which number it used and where it came
from (review_source: the league's settings on ESPN, assumed, or given for
this judgement in place of the league's own). The trade calibration and the
intake's measure_trades still pass one day explicitly.
What the effective day does to the week. week_deltas takes an
effective_day, and the days of this period before it are projected with the
roster as it stands — on both sides of the comparison, so they cancel in the
delta while still counting toward the totals the nine probabilities are read
off. Seating is per day and independent, so splitting the window in two is
exact rather than an approximation, and with no effective day the function
returns the number it always did.
If the review pushes the deal past the end of this matchup period,
delta_week is exactly zero — no day of this week is the new roster's — and
the period it does land in is one of the weeks weeks_remaining covers, so
nothing is counted twice and nothing is dropped.
3. Uneven trades, and the one generalisation
A pickup touches one roster place. A trade touches several, and unevenly. The
rule judge.py already had —
season_cost = max(value(dropped), wire) - max(value(added), wire)
— is a statement about one place, so it was restated per place and summed,
in judge.py, as places_cost:
before = sum over places vacated of max(value(man leaving), wire)
after = sum over places filled of max(value(man arriving), wire)
+ what the places vacated and not refilled are worth
A place a move fills that was empty was worth nothing, so the arrival is
credited in full. A place a move leaves open is worth what it returns while it
stays open — and that is not the same number as what one waiver pickup
returns, which is what this code charged until 2026-09-22.
docs/streaming_lane.md measured a place that is left open and streamed at
0.38 categories a week against the held 13th man's 0.00, because a
streamed place has a live body in it every day. So an opened place was worth
the better of the man the wire offers and that lane (OPENED_PLACE, revision
R2 in §7b), and a second opened place the better of that man and a single
ordinary pickup, because the seven-add budget and not a decay curve is what
limits a team to one lane at a time. Since revision R6 (§7c) the man is gone
from it: an opened place is a streamed spot, a second one an ordinary
pickup, whoever is on the wire (app.scoring.replacement.streamed_places),
and the hindsight grade credits the place with the same figure. The page
says it in one line: 1 open place, counted as a streamed spot: +0.38 a
week. All of it falls out of the two sums
with no special case, which is why a free add, an injured-reserve move, a
one-for-one stream and a three-for-two trade are still one line of arithmetic.
Only a move that empties a place is charged differently. A one-for-one swap opens nothing, so every number the pickup recommender has ever printed is untouched; the suites prove it by passing unchanged, exactly as they did through R1.
The pickup path's numbers did not move. season_cost is now written in
terms of places_cost rather than beside it, judge() calls places_cost,
and the existing suites — test_pickups_judge.py, test_pickups_stream.py,
test_pickups_season.py — pass unchanged. That is not luck: the recommender
only ever passed one man on each side (the rest-of-season report always
overrides the season term with the optimizer's own), and for one man a side
the two formulas are identical by construction. Revision R1 did not move them
either, for the same reason and one more: a pickup never goes through the
trade evaluator at all. The three suites passed untouched after it too.
This is no longer the trade's headline. Since R1 the headline season term
is the roster with-and-without described in §1, and places_cost is what
SideReport.season_independent carries beside it, what the playoff lens
still reads, and what every pickup is still charged by. §7 is why it was
demoted and what demoting it did — which, on the shape it was demoted for,
was to make the error larger.
Who gets dropped. A side that receives more men than it gives needs a
place for each extra one. Open places take them first (week.open_slots);
after that somebody goes. The caller may name him (drops=), and by default
it is the cheapest man on the active roster by what his place is worth —
which is app.pickups.season's drop candidate, read one at a time. The report
names him, says whether he was named or chosen, and prints what he cost. Men
on injured reserve are never chosen, because dropping one frees no active
place; that is the same reading of open_slots / ir_slot_free that
app/pickups/state.py does.
The man in the open place has to be able to play (2026-10-13)
Superseded for the judgement by revision R6 (§7c, 2026-10-16): the open
place is a streamed spot and no man is counted in it, so nothing below moves
a figure any more. The can-play rule still chooses the body the finish seats
in the place (SideReport.stand_in): of the free agents who can play, the
one whose season worth is nearest the streamed spot's, never named. The
record of what went wrong is kept as it was written.
What went wrong. Patrick judged a two-for-one on the Trades page: Alex
Sarr and Gui Santos out, Naz Reid in. The page read +0.32 categories a
week, worth a look, and beside it projected finish 9th from 5th, playoffs
56%. Man for man the deal was a wash (season_independent +0.03). The
whole of the +0.32 was the place it opened, and the judge had filled that
place with Jimmy Butler III: ruled out, no return date, no game left to
play, and the wire's best man by season worth all the same, at 0.66 a week.
The finish seated the same man in the same place and, because a projection
seats a man only on days he is not ruled out of, seated him on none: Patrick
was projected two men down for one. One phantom bent both numbers, in
opposite directions.
Why he was worth 0.66. The season lens counts a ruled-out man's games by
the return prior (RosteredPlayer.season_games, R4 above): 50.6 of his
team's 57 for a man with no date and no game played this season, because
with nothing to count his absence from, the prior reads him as one day out.
That is the declared rule for what a held man is worth while he waits. It
was never a statement that he can fill a place today, and _best_wire took
it for one.
The rule. The wire an opened place is settled over is the free agents
who can play: RosteredPlayer.can_play, a game day in the stretch he is
not ruled out of, which is game_days and so exactly what the projection
seats a man on. The man (replacement_player), the value (replacement,
opened_value), the line in the nine-category table, the playoff lens's
filler and the finish all read that one wire, in evaluate_trade and in
fill_pool both. The rest of §3 is as it was: the place is still worth the
better of that man and a streamed lane.
- Ruled out with no return date (OUT or SUSPENSION): cannot play. Never the man, and his worth is never the place's.
- Ruled out with a return date inside the stretch: can play, from that date. The projection seats him on every game day from his return and the judge counts the same days (at the ramp, 0.91 then 0.98 for his first two weeks back), so the two agree and he may be the man. A date past the last day of the stretch is no game day in it, and he cannot.
- Day-to-day, questionable, doubtful, probable: not ruled out. They play and are chosen from as anybody is, labelled as they are.
What did not change. The pool a manager chooses from (trades/pool,
§11) still lists every free agent, hurt or not, with his status on him, and
a man named for the place (fills) is still priced by his own line whoever
he is. Only the choice nobody made is held to this. A manager who names a
man who is out with no date therefore still gets the old disagreement, by
his own hand: the judgement counts the man's expected games and the finish
seats him on none.
The payload says what the place was counted as. open_places is one
entry per place left open — counted_as (wire, with the man in player;
streamed; or pickup, one ordinary add, which is what a second place
falls to), and value, adding up to opened_value — and
open_places_note is the same in words, which is the clause the summary
now prints. It used to say "worth 0.66 a week streamed" whichever side of
"the man or the lane" the figure came from. The page prints one line under
"As one number": This counts the roster place it opens as Brook Lopez from
the wire, 0.58 a week.
What it does to the deal above, replayed on the local copy of the league with the wire as production had it (Brook Lopez the best free agent who can play, 0.58 a week): the number falls from +0.32 to +0.19 a week, which is under the 0.20 bar, and the finish goes from 9th-from-5th to 1st-from-5th, playoffs 71% to 82%, because the place now has a man in it who plays. Both lenses point the same way. Four places is about 2.5 category wins in that table, where 1st and 9th are five wins apart; and the whole of the deal's edge is still the open place, a free agent worth more than the second man given up.
Not re-run. The calibration in §7 replays uneven deals through this judge. A replayed morning can see a ruled-out man since R5, so a few of its opened places may now go to a different man; the published record was measured under the old rule and has not been run again.
4. Both sides
A trade only happens if the other manager says yes, so the report is built from his roster too — his week, his opponent, his league-standard values, his playoff games — with the same functions. Every surface says what that is: our estimate of their side, not what they think. It is built on our projections and our lens, and his roster's real needs are known only to him.
It is still the most useful number on the page. A deal that reads +0.5 for us and −0.6 for him is a deal that will not be accepted, and the whole reason trades are where category leagues are won is that two rosters built differently can both gain from one deal: the report is what shows you whether this is one of those.
One case the first draft got wrong and now handles. When the counterparty
is also this week's opponent, the deal changes both rosters inside one
matchup. week_deltas therefore takes an opponent_move and applies it to
the opponent's roster from the same effective day; without it, a trade with
the man you are playing would be judged against a roster he no longer has.
5. No look-ahead, and how it is proved
Yesterday's rehearsal (docs/inseason_rehearsal.md) found four functions that
were right live and wrong on any replayed day. The calibration in §7 is only
meaningful if this module does not add a fifth, and a docstring is not
evidence.
Every query takes today and reads nothing on or after it:
- projections filter
scoring_period < today(app.scoring.knowable), and so does the minutes tilt; - the roster is the latest lineup day at or before
today; - the posted totals are the days of this period before
today(app.pickups.state._posted, fixed 2026-09-21); - the banked record counts only matchup periods already finished;
- games remaining come from
pro_team_games, which is a fact about the future that is on record today, and is supposed to be read; review_daysmoves only the day the deal is seated from, never the day the data is read as of.
The test that makes it credible
(tests/test_trades.py::test_the_report_reads_nothing_after_the_day_it_is_judged_on):
the fixture gives every man in a deal a monstrous set of box scores on every
day after the judgement day — lines nothing could have known that morning. The
report is built. Every player_game_stats and daily_lineup_slots row after
the judgement day is then deleted, and the report is built again. The two
TradeReports must be equal, field for field. If any query reached past
today, deleting the rows it read would move a number. The NBA schedule is
deliberately not deleted, because that is the one thing about the future the
report is entitled to see.
6. The payload, and the page it is drawn on
The dataclasses were designed as the thing a page draws, so a schema is a
transcription rather than a redesign. One TradeReport, two SideReports,
symmetrical:
TradeReport
season, today, effective_day, review_days, review_source
first_scoring_period, last_scoring_period, weeks_remaining
hurdle, pool_size, historical_wire, notes[]
games_note how each man's games were counted (R6)
turns_on: TurnsOn, what would change the answer (R6)
turns[<=2]: player, kind ("line" | "games_out" | "games_record"), factor,
counted, varied, team_games, record[], sides[2] (net, per_week, clears)
considered, line
sides[2]:
team_id, team_name
receives[] / gives[] / drops[] / fills[]: PlayerCard
player_id, name, value, games_left, playoff_games,
injury_status, expected_return_date,
games_so_far, had_projection, projection_source, rests_on, thin, hurt,
games_counted, games_rule ("blend" | "flat") (R6)
drop_source, places_opened, places_filled, places_left_open, places_used
replacement, opened_value
open_places[]: OpenPlace, one per place left open
counted_as ("streamed" | "pickup"), value (R6: never a man)
open_places_note the same in words
stand_in the finish's body for the place; never named
judgement: Judgement (as the pickup routes already send it)
delta_week, delta_season_per_week, weeks_remaining, replacement,
banked, record_without, record_with, measured,
delta_total, weeks_covered, per_week
season_independent the old per-man season term, labelled
categories[9]: CategoryView
abbreviation, before, after, delta, p_before, p_after, p_delta, moved
playoffs: PlayoffLens
first_scoring_period, last_scoring_period, weeks, games,
delta_per_week, delta_total, categories[], note, measurable
expected_per_week, hurdle, clears
summary, notes[]
Four things a page should draw and not hide:
- The nine, as a two-column diff with a probability bar. The counts are
the change; the probability is whether the change matters. A manager
punting FT% should be able to see at a glance that the FT% he is giving
away costs him nothing — that is
p_deltanear zero beside a largedelta, and the summary already says so in words. review_source,historical_wireand everynote. They are the difference between a number and a number you can argue with.thinandhurton everyPlayerCard. A trade evaluation that hides a 12-game sample is worse than none (THIN_GAMES = 12).app.trades.calibration.CALIBRATION_NOTE, verbatim, under the number. It is three sentences of plain English saying how often this number has pointed at the side that did better, on how many deals, over what window, and what a coin does on the same sample — and that the category table does not depend on it. A forecast printed without its record is the one thing §7 says we must not ship.fills, and the two counts besideplaces_opened. A place with a man in it and a place left open are settled by different arithmetic and the payload says which is which:places_filledis how many the caller named somebody for,places_left_openhow many are still valued as a lane, andopened_valueis about the second kind alone. §11 is the rule.turns_on.line, under the number (R6, §7c (d)). The one or two inputs the deal turns on, chosen by rule (app.trades.turns): a man's games where he is ruled out (at half) or his own record's lowest quarter is at least a game under what he is counted for; a thin man's per-game line at 0.9 and 1.1. Each is the judge's own season arithmetic with that one input changed, this week's half held as judged; the figures at a factor of one are the report's. "Worth a look" only for a side the change puts at the bar it was under, and the line ends on the number's record here.
The report is bounded, so the route returns an object rather than a Page,
like both pickup routes. It makes no ESPN request. A season with no schedule
or roster was going to be a 409 for the same reason /pickups/stream is, and
is not: §10 says why, and what it answers instead.
7. The calibration: it still does not predict the outcome, and the consolidation error was mostly the yardstick
Read §7a and §7b first. Two revisions, each declared before its run, each followed by exactly one re-run, published whichever way it fell. The numbers in this section are the re-run of 2026-09-22; both earlier runs are kept at the bottom as the "before".
Re-run again on 2026-09-23 after the weekly spread was widened by two (§0,
revision R3), because head_to_head is what Judgement.delta_week is built
from. Every figure below came back identical, and §0 says why: what this
section scores is the per-week season term, which does not read the
head-to-head at all. Only the wall time changed, 61s to 73s, and the line
below still says 61s because it is the printed output of the run the section
was written from. The 2026-09-23 output is in
docs/runs/2026-09-23-trade-calibration.md, and it differs from the block
below in that one number.
The answer
Primary cell, as declared — the headline under revision R2, against the thirty days after the deal, graded by a yardstick that prices an emptied roster place the same way: the evaluator picked the side that did better in 25 of 55 deals, 45%. A fair coin gives between 20 and 35 of 55 nineteen times in twenty. R1 gave 25 of 55 on the same cell yesterday and the per-man number gave 27 of 55 the day before. The headline has now been revised twice and has not moved off the coin.
Three things the run does say:
- The consolidation error was mostly the yardstick, and the measurement
named in §7b is what settles it. The uneven sides — two men for one, the
shape both revisions were written for — had a mean error of +0.389
categories a week under R1. Under R2's forecast against the old yardstick
it is +0.404: re-pricing the forecast alone made it very slightly worse,
which is what §7b predicted it would do, because the evaluator already
credited the opened place generously. Under R2's forecast against R2's
yardstick it is +0.103. Between three quarters and all of the
over-rating was the hindsight grade charging 0.07 for a place the forecast
filled with a real man, and
docs/streaming_lane.mdsays the truth is 0.38. - Nothing else moved, and the run proves it rather than asserting it. The even-count sides — where no place is opened, so neither engine can have been re-priced — come back at 85 sides, 54% sign agreement, Spearman +0.10, mean error −0.020, mean absolute error 0.279, which is R1's row to the last decimal, under both yardsticks.
- The player-level result is unchanged. 174 men, Spearman +0.39 against what their real box scores were worth over the same thirty days. That was the regression check §7b asked for, and it passed: R2 re-prices places, not players.
And one thing it does not say. The headline's own accuracy barely moved and what movement there is cuts both ways. The mean error on the primary cell fell from +0.073 to +0.008 and the mean absolute error from 0.328 to 0.299, both of which look like the settlement being fixed; the rank correlation over the sides went from +0.01 to −0.02, and on the uneven sides from −0.36 to −0.41. All four are inside the noise of 110 sides and none of them is claimed.
The run, in full
The primary cell, named before the run: R2 (the roster) x next 30 days, deal level. The evaluator picked the side that did better in 25 of 55 deals (45%), where a coin gives 20-35 of 55 (36%-64%) nineteen times in twenty. Over the 110 sides the rank correlation is -0.02 and the mean error +0.008 categories a week. 110 sides across 6 seasons is the whole sample and every figure below rests on it. Generated by scripts/trade_calibration.py in 61s, review_days=1, short window 30 days.
For scale: the predictions have a spread of 0.287 categories a week and what was delivered a spread of 0.255, so the mean absolute error of 0.299 is about the size of the thing being predicted.
The 2x2 and the old yardstick beside it, every cell from one run
| headline | horizon | deals | picked the better side | sides | Spearman | mean error | mean abs error |
|---|---|---|---|---|---|---|---|
| R2 (the roster) | next 30 days | 55 | 25 of 55 (45%) | 110 | -0.02 | +0.008 | 0.299 |
| R2 (the roster) | rest of season | 55 | 31 of 55 (56%) | 110 | +0.09 | +0.013 | 0.262 |
| per man (the old headline) | next 30 days | 55 | 19 of 55 (35%) | 110 | -0.19 | -0.065 | 0.287 |
| per man (the old headline) | rest of season | 55 | 29 of 55 (53%) | 110 | +0.02 | -0.060 | 0.233 |
| R2 (the roster) | next 30 days, old yardstick | 55 | 25 of 55 (45%) | 110 | +0.00 | +0.077 | 0.325 |
| R2 (the roster) | rest of season, old yardstick | 55 | 30 of 55 (55%) | 110 | +0.09 | +0.082 | 0.283 |
The two sides of a deal are not two observations -- the prediction is very nearly antisymmetric between them -- so the deal-level column is the independent question and the side-level columns describe the same data twice. For 55 deals, a coin gives 20-35 of 55 (36%-64%) nineteen times in twenty.
Splits, on the primary cell (R2 (the roster) x next 30 days)
| sides | sign agreement | Spearman | mean error | mean abs error | |
|---|---|---|---|---|---|
| all sides | 110 | 51% | -0.02 | +0.008 | 0.299 |
| even counts | 85 | 54% | +0.10 | -0.020 | 0.279 |
| uneven counts | 25 | 40% | -0.41 | +0.103 | 0.366 |
| nobody broke down after | 85 | 55% | +0.12 | +0.028 | 0.271 |
The settlement of an opened place, isolated
| prediction | yardstick | horizon | uneven sides | mean error |
|---|---|---|---|---|
| R2 | R2 | next 30 days | 25 | +0.103 |
| R2 | R2 | rest of season | 25 | +0.068 |
| R2 | the old flat level | next 30 days | 25 | +0.404 |
| R2 | the old flat level | rest of season | 25 | +0.370 |
| R1, on 2026-09-21 | the old flat level | next 30 days | 25 | +0.389 |
| R1, on 2026-09-21 | the old flat level | rest of season | 25 | +0.355 |
The per-man headline gave +0.265 over 25 sides against the rest of the season on 2026-09-21, which is the number R1 was meant to fix and did not.
The check: the even-count sides must not have moved. No place opens on them, so neither the forecast nor the grade can have been re-priced, and the run of 2026-09-21 reported 85 sides, sign agreement 54%, Spearman +0.10, mean error -0.020, mean absolute error 0.279. This run:
| sides | sign agreement | Spearman | mean error | mean abs error | |
|---|---|---|---|---|---|
| even counts, R2 yardstick | 85 | 54% | +0.10 | -0.020 | 0.279 |
| even counts, old yardstick | 85 | 54% | +0.10 | -0.020 | 0.279 |
How sure it was, decided on the prediction
| deals ranked by |predicted edge| | n | picked the better side | a coin | |---|---|---|---| | top third (edge at or above 0.40 a week) | 18 | 8 of 18 (44%) | a coin gives 5-13 of 18 (28%-72%) nineteen times in twenty | | the other two thirds | 37 | 17 of 37 (46%) | a coin gives 13-24 of 37 (35%-65%) nineteen times in twenty |
The players themselves
For every man in a scored deal (174 of them, counted once per deal), what the evaluator said his roster place was worth on the morning against what his real box scores were worth per week over the same thirty days, through the same league-standard lens: Spearman +0.39, Pearson +0.35, mean error +0.028 categories a week, mean absolute error 0.213. Predicted spread 0.248, delivered spread 0.255.
Against the decision lens (MoveGrade.decision, the same day's data through the hindsight engine's own arithmetic): Spearman +0.55 for the roster headline and +0.48 for the per-man one.
By season, on the primary cell (R2 (the roster) x next 30 days)
| season | sides | sign agreement | Spearman | mean predicted | mean delivered |
|---|---|---|---|---|---|
| 2019 | 10 | 60% | +0.24 | +0.176 | +0.086 |
| 2021 | 6 | 17% | -0.37 | +0.118 | +0.023 |
| 2023 | 6 | 50% | +0.03 | +0.253 | +0.083 |
| 2024 | 26 | 65% | +0.31 | +0.082 | +0.038 |
| 2025 | 28 | 64% | +0.02 | +0.030 | +0.073 |
| 2026 | 34 | 32% | -0.31 | +0.038 | +0.084 |
The worst misses, and why
- 2021 day 84, Chalk Talk: in Myles Turner; out Kevin Durant -- predicted -0.994 a week, delivered +0.227 over 5 period(s). Kevin Durant left and then broke down, so the side that gave him up is credited by hindsight for a risk it did not take.
- 2021 day 84, The Glass Cleaners: in Kevin Durant; out Myles Turner -- predicted +0.963 a week, delivered -0.164 over 5 period(s). Kevin Durant played under half the games scheduled after the deal: an injury nobody had on the day, which is luck and not a bug.
- 2026 day 72, Double Bonus: in Deni Avdija, Onyeka Okongwu; out Nikola Jokic, Ryan Nembhard -- predicted -0.339 a week, delivered +0.743 over 5 period(s). the projections held up; the gap is what the men did afterwards -- the season term is this roster's week with the deal and without it, and the grade counts what the lineup really posted.
- 2026 day 72, The Top of the Key: in Nikola Jokic; out Deni Avdija, Onyeka Okongwu -- predicted +0.455 a week, delivered -0.328 over 5 period(s). an uneven deal: the evaluator fills the place it opens with the best man on the wire and the hindsight grade charges a flat replacement level, so part of this gap is the two settlements rather than the forecast.
- 2023 day 13, Harbor Street: in Joel Embiid; out Jaylen Brown, Deandre Ayton, Josh Giddey -- predicted +0.480 a week, delivered -0.297 over 6 period(s). the line for Joel Embiid, Jaylen Brown, Deandre Ayton, Josh Giddey rested on fewer than a dozen games of his own, which the report flags and this run does not discount.
- 2023 day 84, The Top of the Key: in Joel Embiid; out Kyle Kuzma, Damian Lillard -- predicted +0.411 a week, delivered -0.275 over 5 period(s). an uneven deal: the evaluator fills the place it opens with the best man on the wire and the hindsight grade charges a flat replacement level, so part of this gap is the two settlements rather than the forecast.
- 2024 day 77, Riverside Five: in Brandon Ingram; out Tyrese Haliburton -- predicted -0.471 a week, delivered +0.197 over 5 period(s). Tyrese Haliburton left and then broke down, so the side that gave him up is credited by hindsight for a risk it did not take.
- 2024 day 72, The Top of the Key: in Joel Embiid; out Bradley Beal, Ja Morant -- predicted +1.013 a week, delivered +0.347 over 5 period(s). the line for Bradley Beal, Ja Morant rested on fewer than a dozen games of his own, which the report flags and this run does not discount.
What could not be scored
- 39 sides where only one half of the deal left a roster trace.
- 3 sides of trades with more than one counterparty.
- 5 events where the two teams' reconstructions did not mirror.
- 0 sides with no gradeable stretch after the move.
- 0 sides with no matchup period inside the thirty days.
(The two uneven deals still in the "worst misses" list carry a sentence blaming the two settlements. It was written for the run before this one and it is now only half true: the settlements still differ — the forecast puts the named free agent's whole week into the after-roster, and the grade adds a scalar — but both now value the place at the same 0.38 rather than at 0.38 against 0.07. The sentence is generated from the rows and has been left as the run printed it.)
Reading it
1. The one measured model defect was mostly a disagreement between our own
two engines. That is the finding, and it is the first time this calibration
has produced one that is about the code rather than about the future. §7 of
the previous run said the forward side "credits that place a good deal more
generously than the hindsight grade's flat replacement level does" and wrote
the measurement down as not-done. Done, it accounts for roughly three quarters
of the +0.389: the forecast was not being generous, the grade was being mean,
and the number that says which is docs/streaming_lane.md's 0.38 a week
against the 0.06 the code had.
2. The forecast side of R2 is a rounding error, and we said in advance it might be. +0.389 to +0.404 on the same yardstick. The evaluator already put the best free agent's whole week into the place a deal opens, and on a reconstructed wire that man is usually worth more than a streamed lane, so the floor rarely binds. R2's effect on the forecast is therefore almost entirely about the pickup path and the drop, not about trades — which is the opposite of where it was expected to matter, and is why the declaration named the yardstick as a separate row pair before the run rather than after it.
3. The headline is still a coin, after two revisions. 27 of 55, 25 of 55, 25 of 55. Two declared revisions, two published runs, no movement. The honest reading is the one §0 has carried since yesterday: this is a fit tool, not a winner-picker, and the page leads with the nine categories for that reason.
4. Confidence still does not help. The split was defined before the run, on the prediction alone: the top third by predicted edge picked the better side in 8 of 18, the other two thirds in 17 of 37. Identical to yesterday, which is expected — R2 barely moves the predictions, so it barely moves their ranking.
5. The two forward lenses agree with the decision lens a little more than
they did. Against MoveGrade.decision, the roster headline now ranks at
+0.55 and the per-man number at +0.48, against +0.49 and +0.44 yesterday. Both
lenses moved together, which is what a shared settlement should do, and both
still fail to predict the result.
6. Injuries still do most of the work. Splitting on whether anybody in the deal played under half his scheduled games afterwards moves sign agreement from 51% to 55% and the rank correlation from −0.02 to +0.12 — still not a result that can be claimed, because the split is made on the outcome. Where the remaining honest work is has not changed: availability, not production.
The two runs before this one
2026-09-21, the per-man headline against the rest of the season, before any of this: 27 of 55 deals (49%), sign agreement 46% over the sides, Spearman +0.05, mean error +0.006, mean absolute error 0.253. Uneven counts 48% and −0.19 on 25 sides, mean error +0.265.
2026-09-21, revision R1, the roster with-and-without: 25 of 55 (45%) on the thirty days and 30 of 55 (55%) on the rest of the season; Spearman +0.01 and mean error +0.073 on the primary cell; even counts 85 sides, 54%, +0.10, −0.020, 0.279; uneven counts 25 sides, 44%, −0.36, mean error +0.389; player level 174 men at +0.39; against the decision lens +0.49 and +0.44.
Both of those were graded by the old yardstick. This run's old-yardstick rows — 25 of 55 and 30 of 55, mean error +0.077 and +0.082 — are the check that it is the same sample scored the same way, and they match R1's to within the re-pricing of the forecast.
Not done, and deliberately
Ideas that arrived after the numbers, written down here instead of being built, because the whole point of §7a and §7b is that one revision per run is all we allow:
- Settle the opened place identically in both engines. They are now the same price and still not the same arithmetic: the forecast puts the named free agent's whole weekly line into the after-roster, where it runs through saturating probabilities, and the grade adds a scalar. The remaining +0.103 is the obvious place to look for what that is worth, and it is the next measurement rather than this one.
- Gate the opened place on the add budget.
docs/streaming_lane.md§6 is emphatic that seven adds a period is the binding constraint and that 16.2% of team-periods spend the lot;adds_leftalready exists inapp.pickups.state. A team with no adds left cannot stream an open place at all, and the code currently prices it as though it could. - Report the two sides as a ranking rather than a number, since the rank is the only part anybody acts on and the magnitude is demonstrably noise.
- Show the player-level number on the page, which is the part that measured well, rather than only the deal-level difference, which did not.
- ~~Model availability. Every run of this calibration has ended in the same
sentence.~~ Done on 2026-09-24, and the run that scored it is R5 in §0:
a replayed morning reads the NBA's own report (
replay_status.md). What is left of it is the five seasons whose reports are not loaded on this machine, which is one command on the VPS.
What a played season costs the measurement
Everything in §7 is run without listener data, because the listener has never run for a played season:
- ~~No injury statuses.~~ Fixed 2026-09-24 (§0 R5,
replay_status.md). Every player used to be projected as fully available on the morning of the deal, on both sides, because the only status source was the listener's and the listener never runs for a played season. A replayed morning now reads the NBA's own official report as of ten o'clock Eastern that day. Two limits remain: the reports are loaded here for 2026 only, so the other five seasons are still status-blind, and the league names only players whose team plays that day, so a man whose team was idle still reads as fit. - A reconstructed wire.
app.pickups.state.historical_free_agents— whoever played that scoring period and was in nobody's lineup. It cannot see a free agent who did not play, and it knows nothing about waivers, so thewire_replacementthe opened places are charged at is an approximation. It is also why R2's floor rarely binds on the forward side: a reconstructed wire is stocked with men who played. - No minutes tilt. The tilt keys on
player_status_events, and played seasons hold none, so it is off everywhere — not measured as worthless. - About half the trades are unrecoverable. 39 sides had only one half of the deal leave a roster trace, three were multi-team, and five events had the two teams' reconstructions disagree about what changed hands. All are counted and named in the run's output.
This is the same handicap docs/pickups_backtest.md carries, and for the same
reason: the one season with listener data is the one that has not been played.
Re-running it
.venv/bin/python scripts/trade_calibration.py
It prints the markdown above; --out FILE writes it as well, --seasons
narrows it and --review-days changes the day the deal is seated from. The
section above is pasted from the run of 2026-09-22. Read-only, about a
minute, local database only.
7a. Declared before the re-run, 2026-09-21
One revision, declared here before it was run, then exactly one re-run of the calibration, published whatever it showed. With 55 deals, anything more is fooling ourselves. Everything below this line was written before the revision was built and before any number came back; §7 above is the "before" it will be read against.
Revision R1 — the headline is judged on the roster. The season term of a
side's judgement becomes: the expected category wins per week of that team's
actual active roster with the deal, minus without it, against the league
standard — i.e. Standard.week_wins (or whatever the nine-category table
already uses to produce its win probabilities) applied to the summed
rest-of-season weekly lines (weekly_lines) of the active roster before and
after. In an uneven deal the opened place is filled by the named best free
agent (as the table already shows) and the forced drop removes the named man,
inside the same before/after. The old per-man number stays in the payload as
season_independent, labelled, so both can be measured; the bar, the week
term, the playoff lens and the language are unchanged apart from reading the
new number. The pickup path's numbers must remain identical (it does not go
through the trade evaluator; prove it by the existing suites passing
untouched).
Target T30 — the next thirty days. Beside the existing rest-of-season
hindsight grade, grade each side over the 30 days after the deal lands — the
window scripts/pickups_backtest.py uses. Use grade_move's own arithmetic
restricted to that window; if it cannot be windowed without new machinery,
build the minimum and say exactly what you built.
What will be reported, all of it, from one run:
- The 2×2: {old headline, R1 headline} × {rest-of-season, T30}, each with deal-level "picked the side that did better" (n of N) and side-level Spearman and mean error. Primary cell, named now: R1 × T30, deal-level.
- Uneven sides' mean error under R1 (was +0.27 under the old headline).
- An ex-ante confidence split, defined now: deals ranked by |R1 predicted delta|, top third versus the rest, deal-level hit rate in each. (Defined on the prediction, not the outcome, so it is a legitimate claim whichever way it falls.)
- A player-level diagnostic: for every player in the scored deals, the evaluator's predicted weekly value on the day versus what he delivered per week over T30 (same lens), Spearman and n. This separates "the projections carry no signal" from "differencing two similar players destroys it".
- A coin's 95% interval for the deal-level n, printed beside every hit rate, so nobody reads 31 of 55 as a finding.
What will not be done after the run: no change to R1, the window, the
splits, the sample, or the bar. Seasons, exclusions and review_days=1 are
exactly yesterday's.
7b. Declared before the re-run, 2026-09-22
The same discipline as §7a, for the same reason: one revision, declared here before it was built, then exactly one re-run of the calibration, published whichever way it falls. Everything below this line was written before any code changed and before any number came back; §7 above is the "before" it will be read against.
Why there is a revision at all. Every roster place this code prices is
priced as if one man held it: TYPICAL_PICKUP = 0.06 categories a week, the
median return of a single executed add. docs/streaming_lane.md, merged today,
measured what an opened place actually returns when it is streamed: 0.38
categories a week per place (IQR 0.23–0.53), against the held 13th man's
0.00, over 1,536 team-periods of 2019–2026. It is not a volume effect — a
lane starts 4.67 games a week to a held man's 5.00, because a team starts at
most ten men on a day — it is that a team's 13th man mostly produces nothing
while a streamed place has a live body in it every day. §7 finding 3 named the
two engines' different settlements of an opened place as part of the
consolidation error, and the lane value is the same size as that error.
Revision R2 — an opened place is priced at what a streamed place returns. Two numbers where there was one:
TYPICAL_PICKUP(0.06) stays as the floor under a single ADD — what a man picked up and kept returns. Unchanged everywhere it already means that.- A new constant,
OPENED_PLACE = 0.38categories a week (docs/streaming_lane.md, pooled median per place, 2019–2026; the lower quartile 0.23 is recorded beside it as the conservative figure), is what a roster place returns when it is left open and streamed. It applies wherever the code values a place that a move EMPTIES:places_cost/season_costwhen a move opens a place (theempty=path and the N-for-M generalisation), the trade evaluator's 2-for-1 settlement (the side that receives fewer men than it gives), andgrade_move's replacement charge for an opened place inapp/scoring/moves.py, which today charges a flat per-season median ofpickup_values, about 0.07. - The add budget caps it. An opened place is only worth streaming while
adds remain, so the k-th opened place on a roster is priced at
OPENED_PLACEfor the first and at the measured decay for the second and after — read offdocs/streaming_lane.md§6/§5 (lane 2 against lane 1 under the tight definition). If the document says the decay could not be measured cleanly, the first-lane value is used for one place andTYPICAL_PICKUPfor every further place, and the write-up says so. A 2-for-1 opens exactly one place, so the calibration is not sensitive to this choice; the pickup path can be. - The pickup path's existing numbers must not move for any one-for-one swap
(no place is opened): proved by the existing pickup, judge, season and stream
suites passing untouched. Only moves that OPEN a place change, and
docs/pickups.mdgets one as-built paragraph saying so.
What will be measured, from one run of scripts/trade_calibration.py, all of
it reported:
- The same 2×2 as R1 (the R1 headline against the per-man number; T30 against the rest of the season), now with both the prediction and the hindsight yardstick priced under R2, plus a third row pair: the R2 prediction against the old yardstick, so that the two effects — fixing the prediction and fixing the yardstick — are separable.
- Primary cell, named now: R2 prediction × R2 yardstick, T30, deal-level "picked the side that did better" (n of 55), with the coin's 95% interval beside it.
- Uneven sides' mean error under R2/R2, under R2/old, and the R1 figure (+0.389 on T30) for reference. This is the number the job exists for.
- The even-count sides must be identical to R1's — no place opens on them — stated as a check and reported.
- The player-level diagnostic unchanged (Spearman about +0.39) as a regression check.
What will not be done after the run: no change to OPENED_PLACE, the decay
rule, the window, the sample or the bar. Seasons, exclusions and
review_days=1 exactly as before.
7c. Declared before the re-run, 2026-10-16: revision R6
The same discipline as §7a and §7b: one revision, declared here before any code changed and before any figure of it came back, then exactly one re-run of the calibration, published whichever way it falls.
Why there is a revision. The study of 2026-10-16
(research/2026-10-16-trade-judge-inputs/, declared in its PLAN.md before
any figure, report committed as 4d8ded0) replayed the 55 deals under the
engine at 1935763 and under declared variants of what the judge reads for a
man. Three of its findings are what this revision acts on:
- Games, not the rate. The judge counts every man for a flat 0.88 of his
team's games over the stretch (
ESPN_AVAILABILITY), whatever his record: Robert Williams III, who played 6, 20 and 59 games in the last three seasons, was counted for 51.9 of 59, as many as Mikal Bridges. Counting each man's games by Box Out's blend (its arm V1) ranked the 174 men better against what they did over the next 30 days: Spearman +0.391 to +0.473, paired interval of the change +0.037 to +0.116, the tightest of the three arms that cleared the study's bar. The blend's rate as the prior, alone (V1r), moved nothing (+0.008, inside the noise). - The fall in the sides' error is mostly a shrink. 60-70% of every arm's fall in side MAE is what any shrink of the predictions buys (a flat 0.81 for everybody bought most of it and ranked the men no better). It is not claimed here as evidence for anything.
- The opened place is settled two ways. The judge put the best free agent's whole weekly line into the roster the deal leaves; the hindsight grade credits the place a scalar, the streamed lane. Recomputed inside the engine with the place settled the grade's way, the uneven sides' mean error under the baseline falls from +0.099 to +0.037: 63% of it is the two settlements, not the forecast of any man. Every per-man games arm made the uneven sides worse because the men's lines came down and the lane did not.
The owner's rulings, 2026-10-16. (1) Use Box Out's own projected games in the trade judge, the rate unchanged; where the blend has no figure for a man, keep today's rule for him and say so. (2) No one-season rule for trades: the draft's conservatism rule (a man with fewer than two stored seasons at the league's 25th percentile, docs/conservatism.md) is not applied. (3) The opened place is always what a streamed place returns, never a named free agent: "just make it the cost of a streamer and be explicit about it but not too wordy so it messes up the page." The grade credits the same figure, so the forecast and the grade settle the place the same way. (The first form of this ruling, earlier the same day, was the higher of the best free agent and the lane; the owner changed it before this section was written and before any figure of R6 was computed. Nothing below was ever run under the first form.) (4) A "what would change the answer" line on every trade, as the study's report §10 designs it.
Revision R6, as it will be built:
- (a) Each man's games over the stretch are the blend's. Where the judge
counted
season_games x 0.88it countsseason_games x a, whereseason_gamesis unchanged (his team's game days over the stretch he is not ruled out of, or a ruled-out man's expected count by the return prior) andais the blend's games as a share of the season, clipped to 0..1, exactly the study's V1 games: - a man ours covers and ESPN's line covers that morning: (w_g x ours' games + (1 - w_g) x ESPN's games) / L, ESPN's games being those of the line the knowable prior is read from that morning (the latest saved projection on or before the day, else the stored preseason projection), w_g the fit's weight on ours for games;
- a man only ESPN's line covers (a rookie, a man this league never
touched): ESPN's games x the room's measured availability factor on the
seasons before (
measured_availability), / L, the blend pool's own rule; - everybody else, and every man in a season with no fitted blend: 0.88, unchanged, and each card says which rule counted him.
L is the season's announced length (82; 72 in 2021). The fits are the
walk-forward ones docs/preseason_projection.md printed ("The walk-forward
fit") for 2022, 2024, 2025 and 2026, and own.FITS[2027]; there is none for
2019, 2021 or 2023 (2023 has no usable ESPN line), so those seasons' deals
are judged as before on games. The per-game rate is not touched. The rule
is read wherever the judge prices a man over the season: the season term,
season_independent, every card's value, the drop it chooses, the fill
pool, the playoff lens, and Find a trade (which scores the same season
term). It is not read by the week term (this period's game days, as
before), the finish (which seats men on game days and reads no share) or
the stash block (app.pickups.stash, shared with the week report and the
what-if). Every other engine that reads the flat share is left alone.
- (b) No one-season rule. A man with fewer than two stored seasons is
counted by the blend like anybody else.
- (c) An opened place is a streamed spot. A place a deal leaves open and
nobody is named for is counted at the league's OPENED_PLACE (0.38 here),
each further one at one ordinary pickup (the add-budget rule of §7b), and
never as a man from the wire. The season term is the roster's ordinary
week with the deal and the place empty, less the roster as it stands, plus
that figure; the per-man number and the playoff lens settle the place the
same way, and the category table shows the roster with the place empty.
The hindsight grade is handed the same figure, from one function and the
two numbers the judge read, wherever a trade is graded: trade_grades, the
Insights trades column and this calibration's yardstick. The finish,
which can only seat a player, seats a free agent who can play of about the
lane's worth (the one whose season worth is nearest it), never named, so
the projection counts the place at about what the judgement does and is
not a man down.
- (d) What would change the answer. Not scored. Every report carries a
turns_on block: at most two inputs, chosen by rule from the men in the
deal (a man's games, where he is ruled out or his own record's lowest
quarter is under what he is counted for; a thin man's per-game line at 0.9
and 1.1), each with both sides' figures, and one line on the page under the
number.
What will be measured, from one run of scripts/trade_calibration.py, on
a private copy of the study's database (the owner's league, seasons 2019-2026,
review_days=1, the same 55 deals):
- Primary cell, unchanged since §7b: the roster headline against the thirty days after the deal, deal level, n of 55 with the coin's 95% band.
- Sides: mean error, mean absolute error and Spearman over the 110.
- Men: Spearman, mean error and mean absolute error over the 174.
- Uneven sides: mean error and n (25), and the even sides beside them, which the settlement cannot reach, so their change is the games rule alone.
- Read against the study's baseline on the same copy (25 of 55, side MAE 0.304, side Spearman +0.005, men +0.391, uneven +0.099) and the published R5 (25, 0.293, +0.02, +0.39, +0.096). The expectation is the study's V1 (27 of 55, side MAE 0.277, men +0.473, uneven +0.143), and the build is not V1 in two declared ways: V1 also mixed the blend's rate into the prior, and V1 left the opened place settled as it was. Both differences are reported.
- The 2027 deal the study began from (Harbor Street give Mikal Bridges
and LeBron James to Midrange Society for Cooper Flagg, day 1), judged on
the new engine: both sides, the open place and the
turns_online.
What will not be done after the run: no change to the games rule, the
fits, the settlement, the window, the sample or the bar. PUBLISHED,
UNEVEN_ERROR and CALIBRATION_NOTE follow the run.
The run, 2026-10-16 (written after it)
Built as declared (commit 09653fd), then run once:
scripts/trade_calibration.py --league (the owner's league) on a private copy of the
study's database, 42 seconds, the same 55 deals and 110 sides, the same
exclusions (39 part missing, 3 multi-team, 5 not mirrored). The write-up as
the script printed it is docs/runs/2026-10-16-trade-calibration.md; §0
carries the table against the baseline and V1.
- Deals: 28 of 55 (51%), a coin's 95% band 20-35. Rest of the season 30 of 55. The per-man number: 23 of 55 on the thirty days, 29 over the rest.
- Sides: mean absolute error 0.267, rank correlation +0.07, mean error +0.007 (110). The predictions' spread 0.261 against the delivered 0.255.
- Men: rank correlation +0.47, Pearson +0.41, mean error −0.016, mean absolute error 0.198 (174).
- Uneven sides: mean error +0.099 (25); against the old flat yardstick +0.399. Even sides: 54%, +0.15, −0.020, 0.261 (85), the same under both yardsticks, as they must be.
The 2027 deal (day 1, seated day 2: Harbor Street give Mikal Bridges and LeBron James to Midrange Society for Cooper Flagg), on the local copy and the new engine:
| Harbor Street | Midrange Society | |
|---|---|---|
| net over the season (a week) | −3.30 (−0.174) | +5.74 (+0.302), worth a look |
| this week / the season term a week | −0.19 / −0.173 | +0.08 / +0.314 |
| the place it opens | 1, a streamed spot, +0.38 a week | none (Nikola Jovic dropped, the cheapest place) |
| projected finish | 2nd to 5th, playoffs 80.5% to 71.1% | 14th to 13th, 11.5% to 24.4% |
Each man's games are the blend's: Bridges 52.2 of his team's 59, LeBron 44.7 of 59, Flagg 46.8 of 58 (it was 51.9, 51.9 and 51.0 at the flat share). The study's baseline read Foxes +1.16 and Ben's +5.27; V1 −2.05 and +6.59. R6 reads Foxes lower than V1 because its open place is a streamed spot at 0.38 where V1 still counted Brook Lopez's 0.53 in it. What would change the answer, as the page prints it: It turns on Cooper Flagg's line and Mikal Bridges's line. With Cooper Flagg 10% above ESPN's projection for him, this reads −2.02 for Harbor Street and +4.51 for Midrange Society over the season; with Mikal Bridges 10% below ESPN's projection for him, −2.13 and +4.60. Then the number's record. LeBron's games are not flagged: the lowest quarter of his own record (71, 70 and 60 games in the three seasons before) is 46.8 of 59, above the 44.7 the blend counts. Flagg has one stored season (70 games), so his record says nothing either way: no one-season rule.
8. Decisions a reader could reasonably have made differently
- One day of review, not two or zero. The ledger says the deal seats the
day after the players' last day on the old roster, and the accept and the
uphold are stamped together. A league that actually votes would want two,
and a manager with a handshake wants zero; both are a flag
(
--review-days), and the report prints which was used. - The bar is the rest-of-season paid bar, 0.20. A trade costs no FAAB, so an argument could be made for the free bar of 0.10. It costs players, which is scarcer than money in season, so it is labelled against the paid one. Whichever it is, it must be a bar the other reports already use, or a trade and a claim cannot be compared on a page.
- The season term is judged on the roster, not on the league's average
team. The first cut took the other one, and the argument for it is still
good:
judge.py's lens is comparable across teams, fit is priced indelta_week, and a trade and a pickup are then charged by the same rule. Revision R1 (§7a) took the with-and-without instead, because expected wins saturates and a per-man sum cannot see it. Both numbers are on the payload; §7 is what happened when they were measured against each other, which is that neither of them predicts the outcome and the new one is worse on the very shape it was written for. A reader who wanted the old one back would not be arguing with the evidence. - A place a 2-for-1 opens is a streamed spot, not a man (since R6, §7c). The owner's ruling of 2026-10-16. Until then it was filled by the best man on the wire, by name, in the category table and in the headline, rather than left empty, and what follows is the case for that, kept as it was written. Leaving it empty would show a consolidating deal losing in every category while the net said it was fine. Filling it puts a man in the table who is not in the deal, and it used to credit that place a good deal more generously than the hindsight grade's flat replacement level did. Revision R2 (§7b) closed that gap from the other end: both engines now price the place at what a streamed place returns, floored rather than flattened, so the better of the man and the lane is what either of them charges. They are the same price and still not the same arithmetic — the forecast uses the man's whole weekly line and the grade a scalar — and §7 measures what is left.
- An opened place is priced at the median of the lane distribution, 0.38,
not the lower quartile.
docs/streaming_lane.md's own recommendation was the conservative 0.23, because the lane's upside depends on manager attention no code models. The median was taken because the number is being used as a floor under the best free agent rather than as a forecast of what this manager will do, and because the same document shows an ordinary held place returning 0.43 — so 0.38 keeps the ordering that a held man is worth more than a streamed place, and 0.23 would have re-created a smaller version of the same under-pricing. The quartile is on record asOPENED_PLACE_LOWERand a reader who wanted it would not be arguing with the evidence. - The default drop is the cheapest place, not the best fit. A fit-aware
drop would need the optimizer; the cheapest place is the same ordering
app.pickups.seasonalready ranks drop candidates by, and the report says a manager may prefer another. - The active roster, not the whole roster, is the "ordinary week". Men on
injured reserve post nothing, so they are out of the category table, out of
expected_per_weekand out of the drop candidates.app.pickups.streampasses the whole roster toload_spots; this differs from it deliberately, and it is the one place the two modules do not agree. - The calibration compares the season term, not the net. The net includes this week's head-to-head, which the hindsight grade has no counterpart for. Comparing nets would mix two quantities.
- The short horizon keeps whole matchup periods. Thirty days is four or
five of them, and a period is graded against the opponent's own totals, so
half a matchup has nothing to be graded against.
grade_move(within=...)therefore keeps every period that begins inside the window, which means the last one can end a few days outside it. Cutting at the day instead would need a new comparison rather than a new window. - The page leads with the fit and prints the record under the number. §0 decided it and §10 is how it was drawn. A reader who wanted the headline at the top would be arguing with §7 rather than with the layout.
- The trade routes answer a season with nothing to judge from rather than refusing it, which is the one place they differ from the two pickup routes' 409 (§10).
9. What is not built
(Proposals between managers are built, 2026-09-27: a judged deal can be sent from the trade page to the other team's manager, who opens it on his own trade page judged as his team, beside the sender's frozen headline, and can decline it or agree to make it on ESPN. It records an agreement and acts on nothing. docs/league_social.md, "Proposals", and §10 below.)
- No suggestion engine. The evaluator prices a deal you name; it does not search the league for deals worth proposing. That is the obvious next thing and it is also the thing §7 says to be most careful about: a search over every pair of rosters, ranked by a number that picks the right side of a deal 45% of the time, would generate confident nonsense at volume. §7's player-level result is the one encouraging thing here — a search over players is on firmer ground than a search over deals.
- No FAAB leg. ESPN's ledger carries
ACQUISITION_BUDGET_TRADEitems (the 2026 day-85 trade has one), and the evaluator ignores money changing hands. - No keeper value. 2027 has keepers; a man traded in March is worth something next October, and nothing here prices that.
- Nothing is measured against 2027, because 2027 has not been drafted. The
evaluator answers for a season with no games — the league standard falls back
to the newest earlier season that posted anything, and the report carries a
note saying so — but the CLI needs a stored NBA schedule, and
pro_team_gameshas no 2027 rows yet.
10. The page
/l/{league_id}/{season}/team/{team_id}/trades, under the site's shell and
in the light house style, after Moves in the My team menu (docs/site.md). The
same gate as the other team pages: this team's verified manager, and entitled
(require_team_plan_page). Plain HTML, CSS and JavaScript, no build step,
read per request, like every other page.
It is a fit tool, not a winner-picker, which is §0's decision drawn. The page leads with what the deal does to each roster's nine categories and to each side's needs, shows the headline second and smaller, and prints the record in plain words at the bottom.
Top to bottom:
- The masthead, in the house pattern: the team, the season, the day and its date, and that the rosters are that morning's and nothing after it is read.
- The builder. A team to trade with; the two rosters as lists, where a
tap moves a man into We give or We get and a second tap takes him out;
a "who is dropped" choice — one per place a side is short of — for whichever
side has no room, left alone meaning the cheapest place (chosen for you),
which is what §3 already does and says it did. One button: Judge this
trade. The whole deal is mirrored into the query string (
with,give,get,drop,theirdrop, andtodaylike every other page), so a judged trade is bookmarkable and a refresh lands back on it, judged. Left out,todayis the calendar's day, clamped to the season (a finished season is judged on its final's last day); the header line's day control picks another and keeps the deal, so the same trade is judged again on that day (docs/site.md, "Changing the day"). - The result, fit first. Each side's nine in the fixed order, ours first,
side by side on a wide screen and stacked on a phone: the ordinary week
before and after, the change, and the change in the chance of winning that
category, with the shift strip over the table. A gain and a loss are told
apart by a sign and an arrow before they are told apart by colour, and the
count's change is drawn in plain ink rather than green or red, because
more turnovers is a bigger number and a worse week and only the probability
beside it knows which way that cuts. Under each table, a compact Finish
line —
Projected 94.6-76.4 · 3rd · playoffs 92.7% → 94.4-76.6 · 3rd · 92.2%, the sampling band in brackets, "so this is inside the noise" when the odds moved by less than it, and the projected record's own calibration sentence in faint type underneath (§1a). It sits here and not beside the number because it is a second lens and not a second bar. Then the engine's own sentence, which already leads with the fit. - The number, second and smaller. Per side: this week plus the change per week over the weeks left and the net; the projected record with and without; "clears the 0.20 bar" or "below" as a label and never as advice; the playoff lens; and how an uneven deal was settled — the named free agent whose week fills an opened place, or the named drop and what it cost. The other side is labelled our estimate of their side, not what they think wherever it appears.
- What it rests on, per man: what he is worth a week, the games of his
own behind the projection and its source, his games left and his playoff
games, and a mark on anything thin or hurt. The source is said in a
manager's words (
rests_on, 2026-09-29): "his own games and ESPN's preseason projection", "his own games and ESPN's latest projection" where a saved daily one stood in, "his own games alone" where ESPN projected nothing for him; never the keyprojection_source("blend", "snapshot"), which read as the draft plan's Box Out's blend. - How much to trust the number:
CALIBRATION_NOTE, verbatim, and no link off the page.
Three routes, beside the pickup routes and named like them
(app/api/trades.py):
GET .../teams/{team_id}/trades/rosters ?with_team= &today=
GET .../teams/{team_id}/trades/pool ?with_team= &give= &get= &drop= &their_drop=
&side=ours|theirs &limit= &today=
GET .../teams/{team_id}/trades/report ?with_team= &give= &get= &drop= &their_drop=
&fill= &their_fill= &today=
All three are the paid team layer (require_team_manager and
require_entitlement, the one check the week, season and moves routes
declare). Player ids go in and out as ESPN's, as everywhere else. The report
route answers evaluate_trade's own payload (§6) and computes nothing of its
own; all three carry calibration_note, so the page never keeps a copy of
a record that a re-run would make stale. The pool is §11 and the card §12.
The rosters route is what the pickers are drawn from, so the page hard-codes
no roster and guesses none. It reads the roster stored on or before the day
asked for: a builder opened on a replayed day 52 offers day 52's men, and
tests/test_api_trades.py proves it on a fixture where a later day holds one
more.
A season with nothing to judge from is not an error. 2027 before its
draft has no rosters, whatever ESPN's pre-draft feed stored; every route
answers 200 with readiness — the things app.api.pickups.readiness looks
for, the draft first (app.inseason.drafted), then a schedule and a roster —
and the page says so in a sentence and draws no pickers. Before the draft
note is the draft's own dated sentence ("The auction is Sat, Oct 10 at
2:00 PM ET; there are no rosters to project until then."). This used to be a
difference from the pickup routes, which answered 409; since 2026-09-25 they
answer the same way (docs/site.md, "Readiness").
Bad input is a 422 with a sentence a manager can act on. Every one of
them, in app/api/trades.py:
- A team cannot trade with itself: pick a different team to trade with.
- Name the team on the other side of this deal.
- There is no team {id} in this league's {season} season.
- Nothing is being traded: name at least one player given or got.
- {name} is on both sides of this deal: name him once, as given or as got.
- {team} does not have {names} on its roster on day {day}.
- {name} is on {team}'s injured reserve on day {day}: he holds no active place, so he cannot be traded or dropped to make room in this report.
- {team} cannot drop {names}: not on its active roster on day {day}.
- {team} cannot both trade away and drop {names}.
- {team} has no room for the {n} player(s) arriving and nobody left to drop: every other man on its roster is already in this deal. Give it one fewer player, or take one back.
- {team} needs {n} more roster place(s) for this deal and can free {m}: it could still drop {names}. Give it one fewer player, or take one back.
- {names} is not a free agent on day {day}: only a man on the wire that morning can fill the place this deal opens.
- {team} opens no roster place in this deal, so there is nowhere for {names} to go. Take a player back from it, or leave the wire alone.
- {name} cannot fill a place on both sides of this deal: there is one wire, and one of him. Name him for one side or the other.
- {team} opens {n} roster place(s) and {m} men are named off the wire for it: name one man for each place, or fewer.
The last four are the fill's, and the first of them is the one a manager will actually meet: a reconstructed wire cannot see a free agent who did not play that day (§7, "What a played season costs the measurement"), so a man who is on the real wire and hurt is refused by name on a replayed season.
The two before them are guards rather than a common path. With the equal roster sizes ESPN gives every team there is always somebody to drop, and the report drops him rather than refusing; only a roster holding more men than the other side has places can produce them, which is what the fixture builds.
Waiting. An evaluation is about 2.4 seconds cold in a fresh process and 1.9 warm, measured on the stored 2026 season, day 52, Turner for Queta. That is nothing like the pickup reports' 45 seconds cold, because a trade prices a deal that was named rather than searching the whole wire and re-running the week for every candidate. The page still never looks dead: the button disables itself and says what it is doing in words, the builder stays usable, and the result replaces itself when it arrives. The morning precompute is not worth extending to it — there is no deal to precompute until a manager names one, and two seconds is not a wait worth caching for.
The pool is the same shape and the same price: 2.5 seconds cold in a fresh
process and 2.3 warm, on the stored 2026 season, day 21, the Morant and
Markkanen for Cunningham deal, 51 free agents priced. It reads the same
rosters, the same wire and the same weekly lines the report does (_context,
shared by the two so they cannot answer from two readings of one day), and
skips only the playoff lines, which the pool has no use for. Nearly all of
both numbers is one query: the league's measured category spreads
(category_distributions) take about two seconds and are not cached. That is
also why the card in §12 does not carry what a man is worth a week.
Proposals and the block on this page (2026-09-27)
Four additions, none of them a number the page did not already draw (docs/league_social.md has the whole):
- The block in the pickers. A man on another team's block carries a grey
blocktag in the other side's picker, and his card in the drawer carries his manager's note. Each of our own men has card · block, which opens his card with the note field and Put on the block. - SEND IT, after a judgement, when the other team has a manager on the site: "Send this to {name}" with a note. The server judges the deal once more through this page's own route and keeps the headline for both sides with it; the page says nothing happens on ESPN.
- THE PROPOSAL, at
?proposal={id}: the deal loaded from the receiver's side and judged as his team, and the sender's estimate of his side beside his own numbers; Decline, Agree here, then make it on ESPN, or for the sender Withdraw it. - PROPOSALS: the team's, both ways, with their state and a link.
11. Filling the opened place
The settlement in §3 answers a manager who has not decided what to do with the place his deal empties. Most of the time he has decided. Patrick's real deal of 11 November 2025 is the case: Ja Morant and Lauri Markkanen to Ben's Need Some VC for Cade Cunningham, and Brandon Miller — dropped by another team three days before, hurt, and claimed by Through The Wire the following morning — going straight into the place it opened. The deal he was weighing was two men for one and Miller, and until now the page could not be asked that question.
The rule. evaluate_trade(..., fills={team_id: (player_id, ...)}) names
one free agent per place a side opens, and that man is then treated as
exactly what he is: a player arriving. His weekly line goes into the
after-roster the nine categories are drawn from, into the season term's
with-and-without, into places_cost, and into delta_week — which an opened
place never was, because nobody knows who would be in it day by day and a man
the manager has named is on the roster from the day the deal lands. Only the
places nobody is named for keep the §3 settlement: since R6 (§7c) a streamed
spot at OPENED_PLACE, whoever is on the wire.
Naming a man is therefore always a different deal from leaving the place
open: his week goes into the table and the season term in place of the
streamed spot's figure, and tests/test_trades.py holds the difference to
exactly that. (Until R6, naming the man the report would have stood there
anyway changed nothing in the season term.)
The pool is the other half. fill_pool answers with the day's wire, each
man priced by what he would be worth to this roster after this deal: the
expected category wins a week of the side's post-trade roster with him in the
opened place, less the same roster with the place left open. That is the
nine-category table's own arithmetic, so it ranks the wire differently from
the league standard, and the difference is the whole reason the choice
belongs to the manager: a roster that has given up on free-throw percentage
should take the big who cannot shoot them over the guard who can, and the
league lens — a man dropped into an average team — says the opposite. Both
numbers are on every candidate, worth and value, because a ranking a
reader cannot argue with is worse than one he can.
The deal is a parameter of the pool for the same reason: who is leaving
decides what the roster is short of. The wire is read exactly as the report
reads it, historical_wire and all, so a man the pool offered cannot be a man
the report refuses.
What it does not do. It does not pick. "Leave it open" is the first row and the default, it says what the report values it at, and no row says take him. And on a season the listener never ran for, the pool is the reconstructed wire — the men who played that day and were in nobody's lineup — so the man a manager actually claimed, if he was hurt and did not play, is not in it. The refusal names him (§10) and the page says which wire it is looking at. That is the same handicap §7 measures the calibration under, and the one thing a listened season would fix on its own.
12. The card
Every name on the trade page opens a card: hover on a desktop, tap on a phone, where it comes up as a sheet along the bottom. It carries his line in the nine per game, the games he has left over the stretch the report plans for and the games he has in the playoff weeks, his injury status and expected return when the listener has them, how many games of his own stand behind the projection and what stood behind the rest of it, and his position and NBA team.
It is app/inseason/card.py and GET .../players/{player_id}/card?today=,
and it is deliberately not the trade page's. The week, the season and the
moves pages all print names and all want the same card, so it is built once,
drawn once (pages.css, shell.js: cardName, wireCards), and hangs off
the league and the season rather than off a team — the games are counted over
one season's calendar, and it is a league member's to read, like the pages it
is opened from. Wiring the other three pages to it is a follow-up; nothing
here makes that harder.
It says what the page it was opened from says. per_game is
per_game_line, the rate every weekly line is scaled from, and the games and
the provenance are the report's own PlayerCard fields. A card that
disagreed with the table it was opened from would be worse than no card, and
the test holds the two to each other.
What it does not carry, and why. What a man is worth a week — the league-standard number the reports lead with — is not on it. That needs the league's measured category spreads, about two seconds of query, and a card is a hover: with them it answered in 2.2 seconds and without them in about fifty milliseconds. Every page that shows that number shows it in its own table, beside the name the card hangs off, so nothing is hidden by leaving it off.
Two triggers, because a roster row is already a button. A row in the
builder puts a man into the deal when it is tapped, so it cannot also open
his card: it carries data-card-hover (hover and focus, which a phone never
fires) and a small "card" control of its own beside it carries data-card.
Every other name is a data-card button: reachable from the keyboard,
dismissed by Escape with the focus put back where it was, by a tap outside,
or by scrolling away.
As built, 2026-09-22: the record is per league, and it writes itself
PUBLISHED and CALIBRATION_NOTE were this league's run, typed into the
module, and the trade page printed the note verbatim for whatever league was
being looked at. They are now this league's trade_record row
(docs/intake.md) and the fallback for a league with no trades of its own to
replay.
The sentence is written by app.trades.calibration.trade_note, a function of
a run's own figures: how many deals could be replayed, how many the evaluator
called right, the exact binomial interval a fair coin gives over that many
tosses, and what a consolidating deal is mis-priced by. Applied to the
published run it returns CALIBRATION_NOTE character for character, which is
what tests/test_trades.py holds it to, so this league's page cannot move.
Two things it does that the constant could not. The verdict against the coin is read off the interval rather than asserted, so a league where the evaluator lands outside the range is told so in either direction. And the "one thing did get better" clause only appears where there is an earlier revision to compare against -- ours has one (R1's 0.389 against R2's 0.103), and a league measured once has no before, so its note says only where it stands.
rows_for_season takes a league_id, for the same reason
streaming_lane.py does. intake_trades runs the whole thing in about 58
seconds over eight seasons here.
13. A most-categories league: the record in matchups
Declared 2026-09-30 (Job AS), before the code and before any run; the whole declaration is docs/most_categories.md, "The bars, measured in matchups". In a most-categories league the trade number is in matchups a week (Job AL), so a record graded in categories is no record of it, and until now the page showed the number without one.
- The forecast:
Judgement.delta_season_per_weekwith the league's distributions marked for the matchup, the floor and the opened place in matchups: the roster with the deal and without it, matchups a week. - The grade, the primary cell: the thirty days after the deal (the periods that begin inside it, as in section 7), each side's realized matchup result each period with the deal as played against the same team with the incoming men's started lines out and the outgoing men's counterfactual lines in, both against the opponent's real totals; averaged over the periods, plus the uneven settlement in matchups. Matchups a week.
- The record: hits among decided deals, a deal being decided when either side's matchups changed in the window. An undecided deal is left out and counted beside it. A coin's 95% interval over the decided deals.
- The minimum: 25 decided deals (a hit rate's standard error at a coin of 0.10), in deals replayed over the share decided on the fitting seasons.
- The split: fit 2020-2023 (only the minimum is read there), held out 2024-2026, the record over 2020-2026 the default for a most-categories league with too few trades of its own.
The measured record is in "Measured in matchups", below, whatever it says.
Measured in matchups, 2026-09-30
On the owner's league read as a matchup league (docs/runs/2026-09-30-matchup-bars.txt):
| seasons | replayed | decided | picked the better side | a coin, 19 in 20 |
|---|---|---|---|---|
| 2020-2023 | 6 | 5 | 1 | 0-5 |
| 2024-2026 | 44 | 35 | 12 | 12-23 |
| 2020-2026 (the default) | 50 | 40 | 13 | 14-26 |
The record in matchups is below a coin's range over all seven seasons, and the default note a most-categories league reads says so. Over the rest of the season, beside it: 18 of 41 decided. The rank correlation over 100 sides, thirty days: -0.21. The minimum, 30 deals replayed, rests on the fitting seasons' 5 decided of 6, which is all 2020-2023 holds. The run took 55 seconds over the seven seasons.
14 Find a trade
Built 2026-10-12, on the owner's word: "sometimes i know i want a player or 2 but don't know who i should trade for them."
Section 9 said a finder was not built, because a list of deals with no numbers under it is a guess. This one has the numbers: it is the judgement's own season term (section 7a), worked for every deal around the men named.
What is asked. One or two men of ours to give, one or two men on one other team that we want, or both. With only our own men named, every other team is searched, or the one team chosen.
What is listed. Every even deal that holds those men, one for one and two
for two (app.trades.finder). For each: the change in expected categories
won in an ordinary week for our roster and for theirs, with the deal against
without it; both_gain; and our categories whose weekly chance moves by
three points or more. The best fifteen of each size, the deals both rosters
gain from first, ordered by the smaller of the two gains. That order is the
categories' and no claim about a yes: of 50 trades this league's managers
accepted, 3 had both rosters gaining by these figures and half the sides
lost by them (research/2026-10-13-star-value), which is why the page calls
the label "Both gain categories" and prints what each half cost at the
auction (auction_price on every man of trades/find and
trades/rosters) beside the deal. No more than two rows bring the same men back, so
one good pair with a dozen throw-ins does not fill the list. Nothing is left
out for being lopsided; the row says what it costs the other side.
What a row says of a hurt man (2026-11-01). The owner, on the first row
for Jayson Tatum, which offered Chet Holmgren, ruled out a week with a knee
injury and ESPN's return read as 2 January, at a gain to both rosters:
"this trade suggestion is crazy. chet is worse and injured." The row had
said nothing of it. Every man of a row now carries his status and, when he
is hurt, the reason on the league's newest report line, his days out, ESPN's
expected return as last read by the row's day with the day it was read, the
date his figure is counted from when it is ESPN's, and what the figure
counted him for (hurt, injury_reason, days_out, espn_return,
expected_return_date, games_counted of games_of_team on every man of
trades/find; the judged deal's cards carry the same three new fields). The
page prints it under the deal, Chet Holmgren: out, knee, 7 days out · ESPN:
back Fri, Jan 2 · counted from it for 12.0 of 39 games, and the judged
deal's What it rests on says the same of him; where ESPN's date is not the
one counted it is said so, ESPN: back Fri, Nov 20 (not counted) · Box
Out's return record counts him for 30.1 of 39 games. ESPN's date is ESPN's
wherever it is printed (docs/return_dates.md). The figure itself moved the
same day by the owner's other ruling, that a real ESPN date a month or more
ahead is counted for a man our own record covers (docs/return_dates.md
section 9); the row's numbers are still the judgement's season term and
nothing else.
What it is not. It is the season term only. This week's re-seated
lineup, the playoff weeks, the finish, drops and the place an uneven deal
opens are the trade page's, which judges one deal whole: every row opens
there. So a two for one is made on the trade page from the nearest row. Our
side is read on our build and theirs balanced, as the report does: the
build is what the list is in order of and what "both gain" reads, and the
figure printed for our side is the same deal read balanced
(ours_balanced; docs/builds.md, the ruling of 2026-10-13).
What it costs. One reading of the league (every active roster, every man's ordinary week, the lens: about three seconds on sixteen teams), then arithmetic: 12,337 deals around one man in half a second.
Where it is. GET .../teams/{team_id}/trades/find?give=&want=&with_team=
(the paid team layer, as the report is); the trade page's "Find trades around
these men", which reads the men tapped in the builder; and the connector's
find_trade, which says the deals are scored and not advised.
15. The stored judgement: every executed trade, judged the morning it landed (2026-10-16)
The owner approved the concept on 2026-10-16: a trade in the league's news
carries each side's figure, and opens a page of its own with what the deal
means for every team in the league (docs/site.md, "League: a trade"). So
every executed trade is judged once, by the judge above, and kept; the news,
the page, the morning email and the connector read what was kept and work
nothing out (app/trades/stored.py, migration 0073, table
trade_judgements).
Which trades. Every executed trade of a season, as the league's news
names it (app.inseason.changes.season_trades): the ledger's own
TRADE_ACCEPT rows that are EXECUTED and carry items, then the trades
read off the rosters (app.scoring.trades.reconstruct_trades) that no
ledger trade a day either side of them, between any of the same teams,
already names. Each is kept under its key: the morning it is judged on,
then the teams in it by ESPN id, smallest first (trade_key: "72-19-22" is
day 72 of 2026, The Top of the Key and Double Bonus). The news, the page's
address, the job and the Board's line all name a trade by it.
Before the first day (2026-10-17). A trade made between the draft and
the season's first day was found by neither way: the rosters before the
first day are not stored (each read writes over it), and ESPN's reads give
the accepting row no items. 2027's three, all the Foxes', were missed. The
finder now reads the draft as each man's opening spell
(reconstruct_trades(..., from_draft=True), asked only by
season_trades): a man drafted by one team who opens the season on
another, with no executed waiver or free-agent move naming him, was traded
before the first day. Those legs are kept by the usual rule and folded into
deals over the whole league: each pair of teams with men going both ways is
a deal of its own, so one team's three deals that week are three trades; a
pair with men going one way joins every deal it touches, so a three-way, or
a man traded twice (only his ends show), is one trade between three teams,
named and not judged. Two deals between the same two teams that week read
as one. Its day, morning and landing are the first day (key
1-<team>-<team>); it is judged on the first day's rosters with it alone
undone. Every other reader of reconstruct_trades is unchanged, and the
judge's record (28 of 55) is a frozen run. Trades found per season on a
local copy, before and after: 2019 to 2023 unchanged (8, 1, 3, 0, 3), 2024
21 to 23, 2025 22 to 25, 2026 27 to 29 (one between three teams), 2027 0
to 2 (the copy's first day predates the Ben's deal); no team goes above its
own teams.trades.
When it went through (2026-10-17). Dated on the first day, such a trade
was in the future until then: 2027's three, upheld Oct 11, 12 and 16, read
Tue Oct 20 at noon, and the League page's last three days showed none.
ESPN gives a trade's TRADE_ACCEPT, TRADE_UPHOLD and TRADE_VETO no
processDate (every one stored, every season) but each its proposedDate
and relatedTransactionId, the proposal it answers, which an accept and its
uphold share. Both are kept now (transactions.proposed_at and
related_transaction_id, migration 0076; processed_at stays ESPN's
processDate, which every reader of the wire bounds its windows on), and
the next read of the day fills a row stored before. A trade found off the
draft is dated by its uphold (_date_opening_trades): an executed
uphold filed on or before the first day and not before the draft. The
uphold names the team of whoever upheld it, the league manager's (2025's
three, none the Foxes', were each upheld by the Foxes), so it is fitted to
a trade by the accepts that share its proposal: every team they name must
be in the trade. The upholds the accepts identify are matched first, then
every uphold, by elimination (a trade only one uphold left can be gets it;
an uphold only one trade left can be goes to it, oldest first). A trade
still not told is dated by the oldest uphold it could be, so never later
than its own; one no uphold fits, by the newest uphold there is, in the
past; with no uphold stored with a time it stays on the first day's noon.
Only the moment moves: its key, day, morning and landing are the first
day's as before, and a kept judgement's news_at is brought to it by the
next trade_judgement run without judging it again. 2027's: the Foxes and
Paint Touches Oct 11 11:15 UTC, the Foxes and Jalen's Oct 12 11:13 (the Foxes
accepted it, so it is the one left), the Foxes and Ben's Oct 16 18:04. A
trade's accept, uphold and veto are never a line of their own, nor is a
proposal or a decline: the Moves list, the email and the Board read the
trade once, and the connector's recent_moves lists no proposal, decline
or trade row that names no man.
Which morning. The rosters as they stood before the trade landed:
- A ledger trade's scoring period is the day its men are first on their new rosters (2026's four executed rows, section 2). Its morning is the day before; a trade read off the rosters has the last day the men appeared for the side giving them up as its day, which is that same morning.
- Before the season's first day there is no morning before it stored: the
ingest overwrites the first day's lineup on every read, so a trade read
after it landed is on the rosters it left (ESPN stamps a preseason trade
with the first day). Its morning is the first day, rebuilt by reversing
the trade on the stored rosters: the men each side received go back, the
men each side gave return, and a man the ledger names as dropped to make
room is held again. The rebuild is done inside a savepoint that is rolled
back once the judge has read it; its rows are replaced rather than edited,
so every kept answer keyed on the stored rows' newest id (
app.freshness) is never served for it, and the league's kept standing is let go after. The same reversal applies to any part of a trade a stored morning already shows landed (a ledger trade whose rosters ESPN wrote a day early), andreversedsays it happened; the page says "judged on the rosters that morning, rebuilt by undoing the trade". - Nothing after the morning is read (section 5): the judge is asked with
todaythe morning.
How it is judged. evaluate_trade on the morning, both sides, with the
league's own bar (season_hurdle_paid), its two measurements of the wire
and its weekly spreads, on no manager's build or week's focus: a trade in
the league's news is the league's. A side's drop is the one the ledger names
for it (a DROP item of the TRADE_ACCEPT row, for a ledger trade; for one
read off the rosters, a DROP item of any TRADE_ACCEPT row of the same
teams from its morning to the day after it landed, which is how 2026's
Jokic trade drops Tre Johnson and not the cheapest place; since 2026-10-17
also a drop with no add made to fit it, and for a trade found off the draft
the draft's, below), where the side held him that morning; else the
cheapest place, as the builder does. Its
finish is the projection's for every team (app.inseason.what_if.
trade_league, the same two tables trade_finishes reads its two sides
from: the builder's page is unchanged).
A drop with no add is the trade's drop (2026-10-17, declared before it was
run). The Foxes and Ben's deal (1-1-25) was judged with nothing dropped
on Ben's side. Ben's drafted thirteen and took two for one, so it had to
clear a place: ESPN's log shows Ben's dropped Klay Thompson on Oct 16 at
17:07 UTC, a drop with no add, a minute before it accepted the trade (the
accept 17:08, the uphold 18:04; the owner confirmed it in ESPN's app). ESPN
files such a drop as a ROSTER move, which no read stored, and the first
day's rosters already lack him, so with the trade undone Ben's held twelve
and an empty place the arrivals filled: Ben's read +8.93 categories over the
season with nothing paid for Klay's place. What changes:
- Kept. A
ROSTERmove carried out whose every item is a drop (app.espn.lone_drop) is stored as a transaction, with its items and its time (processed_at, ESPN'sprocessDate; ESPN'sproposedDatekept beside it inproposed_at, read where there is noprocessDate), by the night's ingest and the light read alike (store_transactions, the one writer). Every otherROSTERmove is the day's lineup changes and is never stored. It is one drop line wherever drops are read: the Moves list, the morning email, the connector'srecent_moves. - Named. A drop with no add by a team that receives more men than it
gives in a trade, made at most ten minutes before that team's accept of
it or its uphold, is that trade's drop (
DROP_BEFORE_TRADE). One drop serves one trade, the one whose accept or uphold follows it soonest. - Off the draft. Where the ledger holds no such row (a season read before this, or a drop no read has seen), for a trade found off the draft: a man the receiving team drafted who is on nobody's first-day roster and whom no stored move names is the trade's drop, as many as the team needed to fit the deal (its first-day roster and those men, over its roster places, and never more than the trade's extra men), the man the draft paid least for first, then the latest pick.
- Judged. The morning undone holds him again (the reversal already puts
back a named drop who is on no roster that morning), so the arrivals cost
the side his place:
drop_source"named", never "the cheapest place".
Every other trade, and every other reader of the ledger (the wire grades,
the chains, Insights, the Board's lines, which read only claims and adds),
is as it was. What will be done, once: each stored judgement the rule
touches is judged again (scripts/enqueue.py --trade-judgements <league> --season <season>
--trade <key> --rejudge-stale, one per key touched), and
its figures are written here whichever way they fall. The published record
(28 of 55, app.trades.calibration.PUBLISHED, migration 0075) is a frozen
run and is not re-run. What will be measured: which of league 1's stored
trades the rule names a drop for, and each touched side's net before and
after.
Built, and what it did before production (2026-10-17, written after). On
the tests' small league (tests/test_trade_judgement.py), the Ben's case
drawn to scale (Delta full at four places, two for one, DeltaD dropped a
minute before the accept): the drop is named, the undone morning holds four
men again, and Delta's net falls from +2.04 categories over the season (the
arrivals filling the empty place) to 0.00 (DeltaD's place paid for; every
man in the fixture's deal has the same line, so two in for two out nets to
nothing); with the drop absent from the ledger the draft names DeltaD the
same. On a local copy of the league (it predates the
Ben's deal, and no read has stored a drop with no add yet), the rule names
no new drop in any season from 2019 to 2027: no other trade found off the
draft is uneven, and the ledger has no drop with no add to read. So on the
server the one stored judgement it should touch is 1-1-25, through the
draft until the next read stores Klay's drop, and then through the ledger,
which names the same man. Its figures before and after go here once it is
judged again there.
What is kept (payload, version 1): the trade's sentence and teams;
each side's men with their values, games, statuses and what their lines
rest on, its drop or what the place it opens was counted as (never the free
agent's name: the owner's ruling of 2026-10-16), its net, per week, this
week's change and whether it is worth a look against the bar; the category
chances before and after; each side's next six weeks against their
opponents (Finish.weeks); every team's projected place, records and
playoff odds before and after, each with its sampling band; the category
races; the judge's record in this league (app.trades.calibration, its one
sentence and its count); turns_on, the judge's block of what a deal turns
on where the report carries one, with the sentences the page prints; and
_report, the judge's whole report as it stood, so a field the report
gains is kept without a change here. _report names the free agent an
open place was counted as, and is never served: the route drops every key
that starts with "_" and every database id.
The category races. Each team's rank in each category among the league's teams, before and after, on its ordinary week in the projection: its projected counts over the weeks not yet begun, per day, times seven, the percentages made over attempted. The two sides' after is that week moved by the judge's own change, each category scaled by the judge's after over its before. Fewest first where fewer wins. On the 2027 Foxes and Ben's deal this gives the concept's races figure for figure (Ben's FG% 10th to 4th; the Foxes' 3PM 6th to 12th).
A trade the judge cannot read is kept too, with judged false and why,
so it is not tried again: a trade between three teams (the judge reads a
deal between two), one whose other half left no trace, one in a season not
drafted, and one the judge refuses (a day in no matchup period, a man not
on the roster): the page names it and says why.
The grade, thirty days on. Each side's move graded by the move engine,
grade_move(..., within=morning + 30), the Insights trade panel's and the
trade calibration's horizon: what it was expected to get on what was
knowable that morning (decision), what it delivered over the matchup
periods beginning within the thirty days (result), and Move history's
words for where the two fell (app.scoring.verdicts.LABELS). It is written
once those periods are over: graded_on is the morning after the last of
them ends (for the 2027 deal, Mon, Nov 23: weeks 1 to 5 begin within
thirty days of its first day), and until then a reader is told that date.
Once written it is on the trade's line on any day the line is read, as the
concept draws it: it is the line's hindsight, dated, never part of the
judgement.
A number to know. The grade is thirty days because the brief and the
calibration say so; Move history grades a trade over the whole rest of the
regular season. On 2026's Jokic trade the two read differently: over thirty
days The Top of the Key expected +0.35 and got −0.33 ("Expected to help,
cost"), Double Bonus −0.13 and +0.74 ("Expected to cost, helped");
over the rest of the season Move history has +0.06 ("Expected to help, and
did") and +0.23. TRADE_DAYS is the one constant.
A man ruled out. The card's games left are the games over the stretch
a man is not ruled out of: none, for a man out with no date. The judge
counts him from his expected return (section 1, "What it cannot know"; the
side taking him on carries his stash block, app.pickups.stash), so his
line on the page says "out; counted from his expected return, 27 of 33
games" (the block's expected games of his team's games left) and never "0
games left" beside a full figure; the same man reads the same on both
sides. 2026's Jokic was such a man on day 72: two days out, 69% back within
a week, counted for 27 of 33.
Judged again, once. A row keeps what was said at the time, the engine it
was judged on beside it (engine_stamp, app.freshness). The owner's
ruling of 2026-10-16: the judge's revision, when it lands, has every row
judged on an older engine judged again, once, its grade written again
after (judge_season(rejudge_stale=True); the job's payload
rejudge_stale; scripts/enqueue.py --trade-judgements <league> --season
all --rejudge-stale). Off unless asked.
When it is made. The trade_judgement job (docs/jobs.md), queued after
every read of the league and by hand (scripts/enqueue.py
--trade-judgements <league> [--season all], the backfill: a season's
trades are few, two seconds each on the owner's league). Idempotent and
keyed on the trade, so the listener enqueues the same kind the moment ESPN
shows a trade accepted, as docs/jobs.md says.
Measured on the owner's league (a copy, 2026-10-16, the 2027 deal
inserted as ESPN shows it once it landed: its TRADE_ACCEPT with Flagg to
the Foxes, Bridges and LeBron to Ben's and Jovic dropped, the uphold, and
the moves on the first day's rosters): judged on the rebuilt morning it
reads Ben's +5.27 (+0.28 a week, worth a look), the Foxes +1.16 (+0.06 a
week, under the 0.20 bar), Ben's playoff odds 11.5% to 22.9% and Ben's FG%
race 10th to 4th, the concept's figures, which were worked out on the
rosters before the trade; the stored first day reads as it did after. These are the judge as it stands on this build; the revision running beside it (a man's games from Box Out's blended projection, an open place settled as a streamed spot, and a turns_on block) moves them, and the page draws turns_on when a judgement carries it.
16. Declared before the run, 2026-10-17: stints, and the team pages read the ledger
What was wrong. A trade the trade page had whole and judged showed on
the team's Move history as "gave Myles Turner, got nothing recorded", part
missing and ungraded. Two causes, both in the rebuild of trades from the
rosters (app/scoring/trades.py), which every team page reads.
- A spell was a pair's first and last day.
_roster_spanstook each (man, team) pair's first and last day held, so a man who left a team and came back to it later had one span over both stints. Neemias Queta was on the Foxes' days 6 to 52, traded to Through The Wire for days 53 to 77, and the Foxes claimed him again for day 71: the Foxes' span ran 6 to 71, overlapping the stint between, and the move that took him away (which needs one spell to end before the next begins) was never seen. Only Turner's side was left. This is the first of the two under-count shapes the module's own write-up names. - The team pages never read the ledger. The trade page
(
app.inseason.changes.season_trades) takes a trade ESPN's ledger names with its men and teams whole from it, and reads the rosters only for the trades the ledger does not name. Move history, Insights' trades board, trade of the week, team analytics' "how he came", the move chains, the newsroom's facts and the season report read the roster rebuild alone, so a trade could be whole on its page and half on the team's.
The change.
- Stints, not spans. A spell is a continuous run of days a team held the man. It ends where the next day he is held is another team's, or where his team's own lineup was stored on a day between without him (a drop, or a day he was off the lineup read). A day his team has no lineup stored at all -- a day not read for the whole league, the empty side of a bye -- says nothing about him and does not end the stint. A man's two stints on one team with nobody else's between them are never a move (same-team pairs were already skipped), so splitting them changes nothing; the split matters only where another team held him between.
- One source for a team's trades.
reconstruct_tradesreads the ledger first (ledger=True, the default): a trade an executedTRADE_ACCEPTnames with its men and teams is taken whole from it, dated the day before the one its row lands on (the trade page's morning), and a deal read off the rosters a day either side of one, between any of the same teams, is that deal and is dropped -- the trade page's own rule, so the two name the same trades. The trade page's finder merges the ledger itself and asks withledger=False. Every team page above reads it without a change of its own. - Move history links each trade to its page where the page has it (a stored judgement, or a trade the page's finder names).
Untouched. The trade calibration (section 7, the published 28 of 55,
migration 0075) is a frozen run and is not re-run; scripts/trade_calibration.py
would read the new rebuild if it were.
What will be measured, once, on a copy of the local database (which may be older than production's), every season of the owner's league, before and after:
- trades found per team (sides) and in total, and how many are taken from the ledger;
- the trade page's own list (
season_trades): keys added, gone or changed; - Move history's trade grades: rows whose figures change, rows that become gradeable, rows that appear or go;
- Insights (
app.scoring.insights.build) for the seasons whose trades change: which boards' rows move and how many rows; trade of the week (weekly_awards) for each regular week of those seasons: which weeks' awards move.
The result is written below whichever way it falls.
The run, 2026-10-17 (written after it)
Run once on a copy of the laptop's database (migrated to head; its data is older than production's, and its 2027 season holds the first day only), every season of the owner's league, before and after, with the module as it stood (main at 2eaa9ad) and as changed. Every team had a lineup stored on every day of every season in this copy, so the rule that a day with no lineup stored does not end a stint changed nothing here; it is there for a day a read missed.
Trades found (sides, one per team per deal; ESPN's own count beside):
season before after ESPN from the ledger gradeable
2019 13 14 20 0 13 -> 14
2020 2 2 2 0 0 -> 2
2021 6 8 8 0 6 -> 8
2022 0 0 0 0 0 -> 0
2023 6 6 6 0 6 -> 6
2024 37 41 48 2 28 -> 33
2025 41 43 56 2 31 -> 37
2026 52 56 64 8 34 -> 50
No team goes over its own ESPN count in any season, before or after. The stints recover real legs the old spans hid, each a man traded back to a team he had left: in 2026 alone, one man went from one team to a second on day 11, back on day 27 and to a third on day 37; another left a team on day 26 and came back on day 70; a third left on day 30 and came back on day 43. The 2026 deal that began this (day 52) is now taken whole from the ledger, as is the day-85 deal the old write-up names as missed: all four of 2026's ledger trades are now whole on the team pages.
Move history's trade grades (rows are a team's trades; a row is matched before and after by its day and the teams):
season rows unchanged figures moved now gradeable new rows gone
2019 13 -> 14 8 4 0 2 1
2020 2 -> 2 0 0 2 0 0
2021 6 -> 8 6 0 0 2 0
2023 6 -> 6 6 0 0 0 0
2024 37 -> 41 27 7 3 4 0
2025 41 -> 43 27 8 6 2 0
2026 52 -> 56 30 8 14 4 0
A row's figures move where a man the old spans hid joins a side of it. The
one row gone (2019, day 95) is a team's two-team deal that now meets a
second deal on the same day through a team they share: the rule that one
day's connected teams are one deal reads the two as one deal between three
teams, which the trade page names and does not judge. It was judged before
and is not now; the opening-day rule (_opening_events: each pair of teams
with men going both ways is a deal of its own) would read it as two, and is
not applied in season by this change.
The trade page's list (season_trades): one new key in 2021, 2024,
2025 and 2026 each, two in 2019 (the three-team day above); the men named
change on 3, 1, 6, 6 and 10 trades of 2019, 2020, 2024, 2025 and 2026; 1,
1, 2 and 6 trades of 2020, 2024, 2025 and 2026 become whole (both sides
named, so the judge can read them), and the 2019 one above stops being whole. A stored
judgement is kept under its key and is not judged again by the job unless
asked (--rejudge-stale), so a trade judged on half its men keeps that
judgement until then.
Insights and trade of the week (the seasons with trades; every other board is unchanged):
- the trades board: 4 of 10 teams' figures move in 2019, 2 of 10 in 2020, 2 of 10 in 2021, 2 of 14 in 2024 (a third's count only), 9 of 12 in 2025, 9 of 14 in 2026; 2023 none;
- the wire's long board (the chains, which end where a man is traded): 1 of 12 teams in 2025, 2 of 14 in 2026;
- trade of the week: 3 of 20 weeks in 2019, 1 of 18 in 2020, 1 of 18 in 2021, 7 of 19 in 2024, 6 of 17 in 2025, 9 of 19 in 2026; no other award of any week moves.
The rebuild takes about three times as long (one season's every team: 0.2 s before, 0.5 to 1.1 s after), since the stints are worked out from each day rather than in the database.
17. Declared before the run, 2026-10-17: revision R7, the printed figure shrunk
The discipline of §7a to §7c: declared here and in
research/2026-10-17-trade-shrink/PLAN.md before any code changed and
before any figure came back, run once, published whichever way it falls.
The plan is the full text; this is the rule.
Why. The graded trades (§15) keep each side's expected figure beside
what it got over the matchup weeks within thirty days. Over the owner's
league's graded trades the sizes run about four times what happened (sides
expected at +0.10 a week or more were expected +0.335 and got +0.076), and
the lean is mildly informative when it is confident. One point is carried
into the run: the grade's expected is Move history's figure
(MoveGrade.decision), not the judge's. The judge prints its own week term,
season term, net and net a week, which the stored judgement keeps beside
the grade, so R7 is fitted on the judge's net a week (per_week, the figure
the bar is read on) against got, and the grade's own pairing is reported
beside it.
The rule. One factor k for each league. Wherever the judge builds a side's judgement it multiplies the week term and the season term by k, so the net, the net a week, the projected record's change, the build's balanced judgement and the "what would change the answer" figures all move by k. "Worth a look" is printed when the shrunk net a week clears the bar. Find a trade's figures are the season term and are shrunk the same way; its order does not change. Not shrunk: each man's value, the category table, the projected finish, the playoff lens. A most-categories league is not shrunk. One factor, not one a term: the thirty-day grade covers both terms' weeks at once and cannot separate them.
The estimator. k_hat = sum(x y) / sum(x^2) over the graded sides (x the
judge's unshrunk net a week, y what the side got), then k = k_hat clipped
to 0.10..1.00. Interval: a bootstrap over trades (both sides together),
2,000 draws. Out of sample: each trade's sides shrunk by the k fitted on
every other trade. Kept up to date as the calibration key trade_shrink:
measured on the league at 20 graded trades or more, else pooled, else the
default, which is this run's k on the owner's league; re-measured after the
job grades a trade, when the switch is on.
What will be measured: (a) out-of-sample calibration of the shrunk and unshrunk figures against what was got: the slope with its interval, mean absolute error, mean error, and the table by size; (b) sides that cleared the bar before and after, and how often they got the bar or more; (c) the published record, re-run with the switch on (it must stay 28 of 55); (d) every place the figure is printed or used, and what each would show.
The reading. Met if (1) the out-of-sample slope of what was got on the shrunk figure has an interval containing 1 and the unshrunk's does not; (2) the shrunk figure's mean absolute error is lower out of sample, with the paired interval of the difference below zero; (3) no direction flips and the record is unchanged. If the floor binds, the report says the printed figure carries too little lean for a factor to make it honest. Adoption is the owner's.
The run, 2026-10-17 (written after it)
Built as declared (app/trades/shrink.py, the key trade_shrink, the
switch FCP_TRADE_SHRINK, off), then run once. The data: every season of the
owner's league judged and graded by the job's own code on a local copy, 75
graded trades and 150 sides (production holds 52; the copy holds more
trades, and every local judgement is today's engine).
| today's figure | shrunk, each trade by the factor fitted without it | |
|---|---|---|
| slope of what was got on it (target 1) | +0.28 [-0.00, +0.64] | +0.57 [-0.19, +2.13] |
| mean absolute error, categories a week | 0.265 | 0.237 (paired -0.027 [-0.055, -0.0004]) |
| mean error | -0.010 | -0.036 |
| sides marked worth a look (0.20) | 21, of which 10 got 0.20 or more | 2, of which 1 did |
| deals where it pointed at the side that did better | 37 of 75 | 37 of 75 |
k = 0.28 (the slope +0.279, bootstrap 0.00 to 0.64 over trades; by season left out, 0.14 to 0.46). Its default is that figure, measured on another league. The record's calibration, re-run with the switch on, gives every "picked" cell and every rank correlation unchanged.
Reading it. All three tests pass, two by a hair: the shrunk figure's
interval holds 1 because it is wide (it holds 0 too), and the fall in the
miss is what any shrink of a weakly ranked figure buys. What R7 says is that
the judge's size is about a quarter of what it prints, and that the size
itself is barely known. The grade's own expected is Move history's figure,
which leans a little more than the judge's (correlation with what was got
+0.25 against +0.18) and scales the same (0.30). After the grade a trade's
line quotes Move history's figure, which R7 does not touch.
Noticed, not R7's. Today's engine replays 68 deals on the database of §7c's run, not 55, and points at the better side of 34 of them: the published 28 of 55 is a frozen run of the engine as it stood for R6.
17a. Declared before the page changed, 2026-10-17: revision R7a, the lean
R7 sizes the judge's figures to what trades here have delivered, and it leaves "Worth a look" read on the sized figure, which would mark 2 of 154 judged sides. A label that almost never appears says little; what a sized figure can say is which way it points and how near the two sides are. So with the switch on, a trade is read as a lean, with its record beside it. Nothing here changes a figure: R7's factor is applied as §17 declares, and the lean only reads the two sized figures.
The rule. Each side's sized net a week is the judge's net a week times the league's factor k (§17), each category counted once (a build or a week's focus orders, and changes no figure). The judge leans to a side when its sized net a week leads the other side's by 0.05 categories a week or more, read on the unrounded figures; nearer than that it leans to neither. k is positive, so the side leaned to is always the side today's figure points at: no direction can turn, and the record (§7) is unchanged.
What goes. "Worth a look" is not applied to a trade while the switch is on: not on the trade's page, the builder's judgement, its summary sentence, the "what would change the answer" line, the news line, the email or the connector. Pickups keep it and their 0.20 bar, which is measured on moves that deliver about what they say. A most-categories league is never shrunk (§17), so it keeps today's reading, bar and all.
What is printed, and where.
- A trade's page (
league-trade.html, the stored judgement) and the builder's judgement (trades.html): the headline is the lean, "Box Out leans {team}" or "Too close to lean either way"; each side shows its sized net a week ("categories a week") and over the season as "about +N" (a whole number; left out where it rounds to nothing); no "Worth a look"; then the sentence "Sized to what trades here deliver. Graded trades in this league came out at {k} of the projection's size, measured over {n} trades, so each figure is the projection's times {k}." Where the factor is not the league's own the sentence says whose it is: pooled from leagues shaped like this one, or the default, measured on another league. The projection's own figures (the judge's unsized net a week and over the season) are one tap away, in a disclosure. The record follows, reworded from the record's own numbers: "How often the lean is right: over the {deals} trades in this league's history that can be replayed, the side it leaned to did better in {picked}, judged on the thirty days after the deal; a coin lands between {low} and {high} of {deals} nineteen times in twenty. Read it as a lean, not a forecast." - The news line (What changed, the morning email): "Box Out leans {team}: +0.06 a week against −0.05." or "Too close to lean either way.", in place of each side's "worth a look" or "under the bar"; the grade, once written, reads as before.
- The connector (
judge_trade,find_trade,what_changed):lean, the team leaned to or null, with the sized figures, the factor and its provenance; the language stays "never accept, reject or recommend".
Stored trades. A trade judged before the switch went on keeps its
payload, and its page reads as it did, until it is judged again
(scripts/enqueue.py --trade-judgements <league> --season all
--rejudge-stale, which re-judges every row
whose factor is not today's). A page reads the lean from the factor the
judgement was made with, never from today's switch, so a payload's figures
and its wording always agree.
Not measured. The 0.05 is a reading rule, not a fitted figure: it is the smallest gap two printed figures to two places can show without reading a rounding. How often the lean is right is the record's own count (§7), which R7 cannot move.