Task 7a of the autotrade 3c2.7 selling-state exits plan: pure assertion logic for SELL orders in execute.rs plus five CAS transition functions and two query helpers in repo.rs. Zero submits — this task lays the foundation that 7b will wire into the auto-sell submit path.

Files Changed

  • crates/polymarket-fetch/src/pmv2/autotrade/execute.rs
  • crates/polymarket-fetch/src/pmv2/repo.rs

Branch: autotrade-foundation

Part 1: assert_sell_amounts in execute.rs

A SELL assertion added alongside the existing BUY assert_amounts.

Function Signature

pub fn assert_sell_amounts(
    got_maker: u128,
    got_taker: u128,
    got_contract: &str,
    expected_shares: u128,
    floor_price: Decimal,
    neg_risk: bool,
) -> Result<()>

SELL semantics are the inverse of BUY: maker gives shares, taker gives USDC proceeds. Implied price = taker / maker. The assertion rejects if implied < floor_price - 0.0001 (tolerance mirrors BUY’s ceiling tolerance for the same quantization-rounding reason).

ParameterMeaning
got_makerShares given (1e6-scaled)
got_takerUSDC proceeds received (1e6-scaled)
expected_sharesShares we expect to sell (from the order)
floor_priceMinimum acceptable SELL price
neg_riskSelects the verifying contract

DRY Extraction: assert_contract

A shared private function was extracted to DRY up the contract check between both assert_amounts and assert_sell_amounts:

fn assert_contract(got_contract: &str, neg_risk: bool) -> Result<()>

Before this extraction the same polymarket_clob::verifying_contract(neg_risk) comparison appeared twice.

TDD Tests (all pass)

TestInputsExpected
assert_sell_amounts_accepts_floor_vectormaker=10_000_000, taker=4_000_000, shares=10_000_000, floor=0.40 → implied=0.40 ≥ 0.40−tolOK
assert_sell_amounts_rejects_maker_mismatchgot_maker ≠ expected_sharesErr
assert_sell_amounts_rejects_wrong_contractbad contract addressErr
assert_sell_amounts_rejects_below_floorimplied < floor − tolErr
assert_sell_amounts_rejects_zero_takertaker=0Err

Part 2: CAS Transition Functions in repo.rs

Five new compare-and-swap (CAS) transition functions on pmv2_autotrade_orders. All return Ok(bool) where true = exactly one row affected.

FunctionWHERE guardTransition
mark_selling(pool, id)status='open'openselling
mark_sold(pool, id, realized_pnl_usd)status='selling'sellingclosed, sets P&L
revert_selling(pool, id)status='selling'sellingopen (reject path)
set_exit_pending(pool, id)status IN ('pending','submitted','filled','partial')Sets exit_pending=TRUE
clear_exit_pending(pool, id)noneClears exit_pending=FALSE

For Agents

mark_selling is the serialization gate. If multiple triggers (auto-sell, 1-click exit, settlement) race, the CAS ensures exactly one wins — the others see rows_affected == 0 and abort. This is the selling reserve state described in Safety machinery.

For Agents

clear_exit_pending has no status guard — it is intentionally idempotent. The bool return indicates “row exists” not “CAS won”. set_exit_pending is used when an exit signal arrives before the order has reached open (the order is still pending/submitted/filled/partial); the exit loop picks it up once the order settles to open.

All five carry #[allow(dead_code)] until 7b wires them.

Part 3: SellableOrderRow + Query Functions in repo.rs

SellableOrderRow Struct

pub struct SellableOrderRow {
    pub id: i64,
    pub token_id: String,
    pub neg_risk: bool,
    pub filled_size: Decimal,
    pub avg_fill_price: Decimal,
    pub cost_basis_usd: Decimal,
    pub mode: String,
    pub status: String,
}

Derives sqlx::FromRow. Carries the fields needed to build a SELL order and compute realized P&L.

autotrade_sellable_for_origin

pub async fn autotrade_sellable_for_origin(
    pool: &PgPool,
    condition_id: &str,
    outcome_index: i32,
    origin_ref: &str,
) -> Result<Vec<SellableOrderRow>>

Returns rows WHERE status IN ('open', 'partial') for the given origin. Used by the auto-sell path to find live positions eligible for exit.

autotrade_exit_pending_open

pub async fn autotrade_exit_pending_open(pool: &PgPool) -> Result<Vec<SellableOrderRow>>

Returns rows WHERE status='open' AND exit_pending=TRUE. The missed-exit queue: positions that received an exit signal before they had settled to open, and need to be closed on the next pass.

For Agents

Both query functions return SellableOrderRow, not the full AutotradeOrderRow. The trimmed type contains only the fields needed for exit processing — it does not include all order metadata. AutotradeOrderRow (defined earlier in repo.rs) remains for the broader order-read path.

Architectural Decisions

Lock Order Unchanged

The global lock order from the design spec is preserved: config row lock → order row lock. The new mark_selling CAS acquires the order row lock AFTER any config-row work — consistent with the structural scanner in breaker.rs (first_lock_order_violation).

Exit-Pending Race Handling

 Order state: pending → submitted → filled/partial → open
                                                        ↑
                                        mark_selling wins here
 Exit signal arrives here ─────────────────────────────────────────→
 set_exit_pending (status guard: IN pending/submitted/filled/partial)
                                                        ↑
                                      exit loop sees exit_pending=TRUE

This two-step pattern prevents a missed exit when the signal and the order settlement race.

Mode Propagation

SellableOrderRow includes mode (the per-order mode column set at insert time) because exits follow the per-order mode, not the live kill-switch. A dry_run order produces a dry-run sell simulation; a live order produces a real SELL POST.

Gate Status

PASS — 306 polymarket-fetch tests + 87 polymarket-clob tests, 0 failures.