Box Out · Written down

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:

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.

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:

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:

  1. 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_delta near zero beside a large delta, and the summary already says so in words.
  2. review_source, historical_wire and every note. They are the difference between a number and a number you can argue with.
  3. thin and hurt on every PlayerCard. A trade evaluation that hides a 12-game sample is worse than none (THIN_GAMES = 12).
  4. 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.
  5. fills, and the two counts beside places_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_filled is how many the caller named somebody for, places_left_open how many are still valued as a lane, and opened_value is about the second kind alone. §11 is the rule.
  6. 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:

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

What could not be scored

(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:

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:

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:

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:

What will be measured, from one run of scripts/trade_calibration.py, all of it reported:

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:

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:

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):

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.

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


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.)


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:

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:

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):

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 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:

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:

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.

  1. A spell was a pair's first and last day. _roster_spans took 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.
  2. 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.

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:

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 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.

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.