The cohort first-mover auto-trade lane would (in DRY-RUN) “BUY” a market whose match was already decided — an ITF tennis loser at ~$0.002 — because neither the entry price-band gate nor the liveness gate caught a market that had collapsed and been resolution-proposed to the UMA oracle but was not yet formally closed. Fixed on autotrade-foundation with two complementary entry guards: an ask-band gate (Part A) and a resolution-proposed entry guard (Part B). Both committed, gate green (503 workspace tests), not yet deployed.

For Agents

Branch autotrade-foundation. Commits: 824a22d (Part A — ask-band gate, engine.rs), 8b3c451 (Part B — resolution-proposed guard: GammaClient::market_resolution_proposed + MarketStatusView.resolution_proposed + liveness_gate). Both are ENTRY-ONLY — exits (is_terminal_market/exit_decision_for) are untouched, so a resolution-proposed position stays sellable. See autotrade-gates (now also checks resolution_proposed) and autotrade-market-data-trait (MarketStatusView gained resolution_proposed).

Symptoms

  • Operator saw a DRY-RUN alert: the cohort lane would BUY "Yidi Yang" at ~$0.002.
  • Live Gamma check on the market ITF Zhengzhou: Yidi Yang vs Junhan Zhang:
    • closed = false
    • acceptingOrders = true
    • umaResolutionStatuses = ["proposed"]
    • outcomePrices = ["0.0005", "0.9995"]
  • I.e. the match was already decided — the result had been proposed to the UMA oracle — but the market was not yet formally closed, so it still accepted orders. “Yidi Yang” is the losing side, priced at ~$0.0005, and the lane was about to buy it.

Root causes (two independent holes)

1. The entry price-band validates the trader’s reference price, never the live ask

The cohort entry price_bounds_gate ([min_entry_price, max_entry_price]) is fed the copied trader’s frozen reference pricereference_price = p.avg_price at the trader’s original entry (see autotrade-sources-signal-emitter) — which was a healthy in-band ~0.44. It is never fed the live best_ask (0.001) we would actually execute at. So a market that collapsed after the trader entered slips straight through the band: we validate where the trader got in, not where we’d get in now.

2. liveness_gate only skips formally resolved / closed / non-accepting markets

A UMA-proposed market is still closed=false and acceptingOrders=true, so it passes every existing liveness check (resolved / closed / inactive / not-accepting / too-close-to-resolution). The “result is in, just not finalized” state had no representation in MarketStatusView at all.

The fix

Part A — ask-band gate (824a22d, engine.rs)

In plan_context (the cohort / Context signal path), after best_ask is known, skip the entry when best_ask falls outside [min_entry_price, max_entry_price], reusing the existing price_bounds_gate — but applied to the ask instead of the trader’s reference price. Reason string: "ask … outside entry band".

Cohort/Context path ONLY — never the weather Directive path

This guard lives in plan_context and applies to the cohort first-mover (Context) signals only. It is deliberately NOT applied to the weather Directive path, which legitimately buys high-confidence outcomes up to max_price = 0.95. Applying an entry-band ceiling there would reject valid weather directives. Keep these two planning paths’ price logic separate.

Part A is the primary safety net — it is data-source-independent (a live order-book read we already have), so even if Gamma is unavailable or wrong, an out-of-band ask is rejected.

Part B — resolution-proposed entry guard (8b3c451)

A defense-in-depth second layer that catches the cause (result proposed) rather than the symptom (collapsed ask):

  1. GammaClient::market_resolution_proposed(condition_id) -> Result<bool> — reads umaResolutionStatuses for the market and returns whether any status is "proposed".
  2. Adapter (market_data.rs) sets a new MarketStatusView.resolution_proposed flag, fail-open: 30s timeout, warn! on failure, default false. A Gamma hiccup must never break resolve() and block the engine — Part A remains the hard safety net if Gamma is degraded.
  3. liveness_gate now skips entries when resolution_proposed is set, with reason "market in resolution".

Entry-only — positions stay sellable

Part B touches only the entry path (liveness_gate). is_terminal_market / ExitDecision / exit_decision_for (the exit classifier from autotrade-exit-cursor-404-wedge-fix) are unchanged. A position that becomes resolution-proposed after we hold it must still be exitable — so the resolution-proposed flag deliberately does NOT make a market “terminal” for exit purposes.

Key gotchas worth recording

umaResolutionStatuses is a JSON-ENCODED STRING, not a native JSON array

Gamma returns umaResolutionStatuses as a stringified JSON array — "[]" or "[\"proposed\"]" — exactly like outcomes and outcomePrices. It is NOT a native JSON array. Parse it via the existing parse_json_string_array helper. A naive Vec<String> deserialize silently fails (you get an empty/absent value and the guard never fires).

MarketStatus.resolved in polymarket-clob is a MISNOMER — it carries no UMA signal

In the vendored CLOB binding, MarketStatus.resolved is set resolved: m.closed — it is just a copy of closed, not a real resolution flag. So the CLOB meta carries no UMA-resolution signal whatsoever; only Gamma (umaResolutionStatuses) does. This is the same aliasing caveat noted in autotrade-exit-cursor-404-wedge-fix (where is_terminal_market = resolved||closed collapses to “closed”). If you need the “result proposed but not closed” state, you MUST go to Gamma — the CLOB cannot tell you.

Gamma query used

GET https://gamma-api.polymarket.com/markets?limit=1&condition_ids=<condition_id> returns the market record carrying umaResolutionStatuses, outcomePrices, closed, acceptingOrders.

Why two guards instead of one

They fail in different directions and cover each other:

  • Part A (ask-band) needs no extra network call (the ask is already resolved) and catches any collapsed/out-of-band entry regardless of cause — but it would also reject a legitimately cheap entry the trader actually wanted at the low end of the band, which is acceptable for the cohort lane’s risk posture.
  • Part B (resolution-proposed) is precise about the reason (oracle result in) and would let a genuinely-cheap-but-live entry through, but depends on Gamma being reachable — hence fail-open with Part A as backstop.

Status

  • Both commits on autotrade-foundation, NOT merged / NOT deployed (the live DRY-RUN lane on the VM is still the old code and would still surface these alerts until merge + redeploy).
  • Gate green: 503 workspace tests pass.