Skip to main content

Trade candidates

Trade candidates are a precomputed, deterministic list of the trades we could propose. A job enumerates every small package against every counterparty and scores the promising ones with the trade scorer. It classifies each package with the worker's staging gates and stores the run. The worker's trade scan and the web UI's trade finder both read from it. Neither has to guess packages anymore.

How does a run work?​

StepRule
Entrypointpython -m src.jobs.trade_candidates (--league-id, --team-id, --season, --week, --horizon 4, --max-candidates 400)
Week--week, or the upcoming week from resolve_upcoming_week. Exit 1 if it can't be resolved; exit 0 without writing once the season is over.
Readsget_league_trade_context, the NFL schedule for each horizon week, and as-of-week VORP for every rostered player. Each is read once per run. The context also carries the players committed to our pending offers.
UniverseFor each counterparty, every 1-for-1, 2-for-1 and 1-for-2 package. A player is left out when he is in an IR slot, OUT/INJURY_RESERVE, has no internal id mapping, or has no position. Players in one of our unresolved outgoing offers (committed_players in the context) are also left out of what we send; the other team's players are not affected.
ShapeNamed from our side: 2for1 means we send two players and receive one.
Scoringshape_trade_score with the shared context, so each item matches POST /v1/trade/score for the same package at computed_at.
Storagetrade_candidate_runs (one row per league/season/week) and trade_candidates (one row per scored package). A run replaces its week in one transaction.
Auditrun_log row trade_candidates, whose summary holds the enumerated, prefiltered, scored, excluded_committed, proposable, needs_drop, one_sided and duration_seconds counts.

What does the prefilter keep?​

Before any solve, each team's optimal lineup is computed once per horizon week. The prefilter then checks each package against those lineups. It never drops a package that full scoring would mark proposable; a brute-force test in tests/test_trade_candidate_service.py checks this. A group is a position plus every position that shares a FLEX slot with it.

SideKept when
UsIn some week, a player we receive is at or above the weakest starter in his group. Our gain G > 0 is impossible otherwise.
CounterpartyIn some week, a player they receive reaches their weakest starter in his group, or there is some week where the trade costs them no starter. That is, each starter they send has a bench or incoming player in his group at least as good.

The counterparty check is looser than "both sides gain a starter", because the gate is I ≥ 0. A bench-for-bench swap that leaves their lineup unchanged passes it.

Ordering and capRule
EstimatePer side and week: the incoming players' VORP above their group's weakest starter (floored at 0), minus the largest gap between a departing starter and his best bench or incoming replacement
OrderPackages that fit both roster limits first, then those whose counterparty estimate isn't negative, then our estimate and theirs (descending), then candidate_key
SpreadCounterparties are interleaved round-robin, each in that order, so every team keeps its best packages
CapThe first 400 are scored. The rest are counted in prefiltered but not stored.

The estimates only rank the shortlist; they never decide what the prefilter keeps.

How is each package classified?​

The reason codes are the worker's own eligibilityReasons, so a proposable candidate passes the worker's gates when it was computed.

ReasonFails when
no_scored_weeksNo horizon week could be scored
no_gain_for_usOur raw lineup gain G is null or ≤ 0
hurts_counterpartyTheir raw lineup change I is null or < 0
exceeds_roster_limitEither roster ends up over its limit
unmapped_traded_playerA traded player has no internal id
statusRule
proposableNo reasons
needs_dropOnly exceeds_roster_limit. The offer needs a drop it doesn't contain, typical of 2-for-1s against full rosters.
one_sidedAnything else

What does GET /v1/trade/candidates return?​

ParameterValues
league_id, season, weekRequired
counterparty_team_idESPN team id
receive_position, send_positionAny traded player on that side has this position
shape1for1, 2for1, 1for2
statusproposable (default), needs_drop, one_sided, all
sortfinal_score (default), our_gain (G), their_benefit (their lineup change I). Descending, nulls last, then candidate_key.
limit, offset1–200 (default 50); ≥ 0
FieldMeaning
computed_at, run_id, horizonWhich run produced the list
totalMatching items before paging
team_counts[]Per counterparty: proposable, needs_drop and one_sided, over the whole run, ignoring filters
items[].counterparty{team_id, name}
items[].receive, items[].sendPlayers: espn_id, player_id, name, position, nfl_team, vorp (as-of-week), nfl_bye_week
items[].final_score, our_lineup_gain, our_weighted_gain, counterparty_lineup_change, counterparty_weighted_benefit, our_schedule_adjustmentFinal score, G, G*, I and C* from the trade score, plus the schedule adjustment G* − G
items[].scored_weeks, horizon_weeks, complete_horizonHorizon coverage
items[].bye_coverageEach side's bye-crunch weeks, before and after
items[].status, reasons, shapeClassification above
items[].candidate_keysha256 of league, season, both teams and the sorted ids on each side. The week is left out, so the key stays the same across runs.
CaseResponse
Job never ran for that week404, naming the job
Run found nothing200 with empty items
Bad parameter422
Other errorsMapped like the other trade reads (503 when the trade context is unavailable)

The MCP tool list_trade_candidates takes the same filters.

Candidates are a snapshot

Rosters change after computed_at. Re-score a candidate with POST /v1/trade/score before acting on it, and treat a list older than about 36 hours as stale. The worker scan and the UI both do this.