---
title: Distinguish an unpublished pick price from an unrecoverable one
type: task
status: planned
description: >-
  The trade-review engine withholds an unpriceable pick as null on the at-trade side and silently
  converts it to 0 on the current side. Separate the vendor never having published a price (a real
  zero) from a price that existed and is gone (unknown), via an additive predicate rather than a
  change to ktc_pick_at's return shape.
base_uri: user:task/league/distinguish-unpublished-from-unrecoverable-pick-prices.md
created_at: '2026-08-15T05:48:28.562Z'
entity_id: d3d57999-12c6-49d5-abd5-1fd467452757
observations:
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
public_read: true
relations:
  - relates [[██████████████████████████-█████-████████-██-█████-███-█████-████████]]
tags:
  - user:tag/league-xo-football.md
updated_at: '2026-11-01T00:58:46.708Z'
visibility_analyzed_at: '2026-11-05T23:49:10.107Z'
---

# Implementation Plan: Distinguish an unpublished pick price from an unrecoverable one

## Overview

- The trade-review engine applies opposite policies to the same population on the two halves of one card: a pick it cannot price is withheld as `null` on the at-trade side and silently converted to `0` on the current side.
- Give pick pricing a state that separates a price the vendor never published (a real zero) from a price that existed and is gone (genuinely unknown), and apply it consistently.
- Impact: 13 of league 1's 228 trades are withheld today solely because of a round-5-or-later pick, and would recover a complete at-trade grade. Measured 2026-08-15.
- Ordering: split out of the trade-review value-attribution work because it is a different defect in a different module, and because it rests on a vendor question that is not yet answered. It is independent of that task — nothing there needs this.

## Background

`terminal_value` in `libs-server/trade-review/grade-trades.mjs` ends `ktc_pick_at({...}) ?? 0`. One field away, `keeptradecut_value_at_trade` withholds the entire at-trade figure as `null` when any leg is unpriced, precisely so a zero cannot read as an even trade.

KeepTradeCut publishes pick rounds 1 through 4. A round 6 pick therefore has no quote because the market treats it as worthless, which is a different fact from the pre-2023 case the engine's header describes, where a draft class was deleted after its draft passed and the quote is unrecoverable. All 17 round-5-and-later pick legs in league 1 were traded after 2023-09-08, so this population is entirely the first kind.

### Assumptions

- All 17 round-5-and-later pick legs in league 1 carry a null at-trade value and were traded after 2023-09-08. Checked: query over `view_trade_asset_flow` filtered on `pick_round >= 5`.
- 13 trades recover a complete at-trade grade if round-5-and-later prices as a real 0; one further trade has a round-5 null plus another unpriced leg and stays withheld. Checked: counted against `view_trade_asset_flow`.
- `ktc_pick_at` has three production callers, not one. Checked: `grep -rn ktc_pick_at` returns `libs-server/trade-review/grade-trades.mjs:333`, `libs-server/roster-asset-lineage/compute-snapshots-bulk.mjs:392,401`, `libs-server/league-pick-holding-values.mjs:96`, plus `test/libs-server.ktc-pick-value-at.spec.mjs` with 15 call sites asserting a bare scalar.
- A change to pick pricing reaches the STORED at-trade baseline, not just the live figure. Checked: `compute-snapshots-bulk.mjs:401` writes `keeptradecut_value_at_termination`, `view_trade_asset_flow.keeptradecut_value_at_trade` is defined as that column, and `refresh-roster-asset-lineage.mjs:109` calls `generate_roster_asset_lineage` which calls `compute_snapshots_bulk` — fingerprint-gated on a `*/5` cron, plus an unconditional 05:00 sweep. So the baseline re-bases within five minutes for an active league.
- UNCHECKED, and the gate on this whole task: whether the rounds-1-through-4 coverage is the vendor's editorial policy or an artifact of this importer. `scripts/import-keeptradecut.mjs:33-34` hardcodes `1st|2nd|3rd|4th` in both a lookup and a regex, so a published "2026 Mid 5th" would fail the regex and be dropped silently. The 36-row shape of `keeptradecut_pick` therefore evidences the importer, not the vendor.

## Design

**Do not change `ktc_pick_at`'s return shape.** Three production callers assign its return straight into a number, one of them into a database column on a five-minute cadence. Adding a `pricing_state` to the return breaks the daily team-value job and the lineage snapshot writer, and re-bases the stored at-trade values that the value-attribution task's measurements rest on.

Add an additive exported predicate instead — `is_unpublished_pick_round({ pick_round, idx })` — deriving its answer from the round coverage actually present in the loaded index. Existing callers are untouched. The engine consults it at the two sites that need to tell a real zero from an unknown.

Vocabulary, on the engine side only: `priced`, `unpublished`, `unrecoverable`. `unpublished` means the vendor never quoted it, so its value is a real 0 and it must not withhold a trade's figure. `unrecoverable` means a quote existed and the archive is gone, so it is unknown and must withhold. `below_board` was rejected — "board" already carries three unrelated senses in this codebase, and `grade-trades.mjs:46-48` uses "off the board" for a different zero entirely.

### Knowledge Base Impact

No new entity. The engine's module header is the canonical statement of its pricing policy and is rewritten here. Relates to the value-attribution task and to the abandoned pre-2023 backfill task, whose vendor probe is the existing evidence for the `unrecoverable` half.

### Shape

One module plus one predicate. A single task, gated on one question.

## Plan

### Gate

- [ ] **Answer the vendor question before writing any code**
  - Purpose: designing a permanent vocabulary on an unresolved vendor question is designing for a hypothetical axis of change, and the failure direction is silent — a transiently short import would price real round-4 picks at a real 0.
  - Changes:
    - Probe the vendor for a round-5 pick series directly, reusing the slug pattern recorded on the abandoned pre-2023 backfill task.
    - If rounds 5 and later are genuinely unpublished, proceed. If the importer is dropping them, this task becomes an importer fix and the engine change is unnecessary.
  - Verify: a vendor response for a round-5 series, recorded as an observation with the request and its status.

### Core Implementation

- [ ] **Add the unpublished-round predicate**
  - File: `libs-server/keeptradecut/ktc-pick-value-at.mjs`
  - Purpose: separate the two reasons a pick has no price, without touching a return value three callers treat as a number.
  - Changes:
    - Export `is_unpublished_pick_round({ pick_round, idx })`, deriving covered rounds from the loaded index rather than from a literal.
    - Leave `ktc_pick_at`'s signature and return untouched.
  - Verify: extend `test/libs-server.ktc-pick-value-at.spec.mjs` with a case per branch, proven red at the pre-fix revision; the existing 15 call sites must still pass unchanged, which is what shows the return shape did not move.

- [ ] **Apply the distinction in the engine**
  - File: `libs-server/trade-review/grade-trades.mjs`
  - Purpose: a real zero and an unknown must stop being the same value.
  - Changes:
    - Replace `terminal_value`'s trailing null coalesce with a branch on the predicate: unpublished yields 0, unrecoverable yields the withheld path.
    - Apply the same distinction when counting unpriced legs. Note this is a DIFFERENT mechanism at a different site: that count reads `keeptradecut_value_at_trade` off the leg row (a stored snapshot column), not `ktc_pick_at`, so it means overriding a null leg value to 0 in `build_asset_outcome` from the leg's own `pick_round`, which `asset_identity` already reads. The 13-trade recovery depends entirely on this site, not on `terminal_value`.
    - Rewrite the header passage describing pick pricing so it states both reasons rather than one. Note the sibling value-attribution task also rewrites this header, in adjacent paragraphs of the same file; sequence the two so neither clobbers the other.
  - Verify: 13 trades newly carry a complete at-trade grade and none loses one, measured against production with the value-attribution harness. The 13 is stable across the sibling task's zero-price fix — none of the 13 also carries a zero-priced leg — so no re-baseline is needed.

- [ ] **Guard against a short import reading as editorial policy**
  - File: `libs-server/keeptradecut/ktc-pick-value-at.mjs`
  - Purpose: absence of a round in one load must not price a real pick at a real zero.
  - Changes:
    - Require positive evidence for the unpublished verdict — coverage stable across the loaded draft years — rather than treating a missing series as sufficient.
  - Verify: a spec seeding an index whose round-4 series is missing for one year only, asserting the predicate does NOT report unpublished.

### Testing and Validation

- [ ] **Population-level check against production**
  - Purpose: a per-pick spot check cannot distinguish a working predicate from one that reports unpublished for everything.
  - Changes:
    - Count picks by round and verdict across the whole corpus and assert rounds 1 to 4 report priced or unrecoverable and never unpublished.
  - Verify: the distribution matches; any round-1-to-4 pick reporting unpublished fails the run.
