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.rscrates/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).
| Parameter | Meaning |
|---|---|
got_maker | Shares given (1e6-scaled) |
got_taker | USDC proceeds received (1e6-scaled) |
expected_shares | Shares we expect to sell (from the order) |
floor_price | Minimum acceptable SELL price |
neg_risk | Selects 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)
| Test | Inputs | Expected |
|---|---|---|
assert_sell_amounts_accepts_floor_vector | maker=10_000_000, taker=4_000_000, shares=10_000_000, floor=0.40 → implied=0.40 ≥ 0.40−tol | OK |
assert_sell_amounts_rejects_maker_mismatch | got_maker ≠ expected_shares | Err |
assert_sell_amounts_rejects_wrong_contract | bad contract address | Err |
assert_sell_amounts_rejects_below_floor | implied < floor − tol | Err |
assert_sell_amounts_rejects_zero_taker | taker=0 | Err |
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.
| Function | WHERE guard | Transition |
|---|---|---|
mark_selling(pool, id) | status='open' | open → selling |
mark_sold(pool, id, realized_pnl_usd) | status='selling' | selling → closed, sets P&L |
revert_selling(pool, id) | status='selling' | selling → open (reject path) |
set_exit_pending(pool, id) | status IN ('pending','submitted','filled','partial') | Sets exit_pending=TRUE |
clear_exit_pending(pool, id) | none | Clears exit_pending=FALSE |
For Agents
mark_sellingis the serialization gate. If multiple triggers (auto-sell, 1-click exit, settlement) race, the CAS ensures exactly one wins — the others seerows_affected == 0and abort. This is thesellingreserve state described in Safety machinery.
For Agents
clear_exit_pendinghas no status guard — it is intentionally idempotent. The bool return indicates “row exists” not “CAS won”.set_exit_pendingis used when an exit signal arrives before the order has reachedopen(the order is stillpending/submitted/filled/partial); the exit loop picks it up once the order settles toopen.
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 fullAutotradeOrderRow. The trimmed type contains only the fields needed for exit processing — it does not include all order metadata.AutotradeOrderRow(defined earlier inrepo.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.