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 checksresolution_proposed) and autotrade-market-data-trait (MarketStatusViewgainedresolution_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 = falseacceptingOrders = trueumaResolutionStatuses = ["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 price — reference_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_contextand applies to the cohort first-mover (Context) signals only. It is deliberately NOT applied to the weatherDirectivepath, which legitimately buys high-confidence outcomes up tomax_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):
GammaClient::market_resolution_proposed(condition_id) -> Result<bool>— readsumaResolutionStatusesfor the market and returns whether any status is"proposed".- Adapter (
market_data.rs) sets a newMarketStatusView.resolution_proposedflag, fail-open: 30s timeout,warn!on failure, defaultfalse. A Gamma hiccup must never breakresolve()and block the engine — Part A remains the hard safety net if Gamma is degraded. liveness_gatenow skips entries whenresolution_proposedis 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
umaResolutionStatusesis a JSON-ENCODED STRING, not a native JSON arrayGamma returns
umaResolutionStatusesas a stringified JSON array —"[]"or"[\"proposed\"]"— exactly likeoutcomesandoutcomePrices. It is NOT a native JSON array. Parse it via the existingparse_json_string_arrayhelper. A naiveVec<String>deserialize silently fails (you get an empty/absent value and the guard never fires).
MarketStatus.resolvedinpolymarket-clobis a MISNOMER — it carries no UMA signalIn the vendored CLOB binding,
MarketStatus.resolvedis setresolved: m.closed— it is just a copy ofclosed, 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 (whereis_terminal_market = resolved||closedcollapses 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 carryingumaResolutionStatuses,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.
Related
- autotrade-gates —
liveness_gate(now also rejectsresolution_proposed) andprice_bounds_gate(reused on the ask in Part A) - autotrade-market-data-trait —
MarketStatusView, which gained theresolution_proposedflag (set fail-open by the adapter) - autotrade-engine-impl —
engine.rsgate sequence; Part A’s ask-band check lives inplan_context - autotrade-sources-signal-emitter — where
reference_price = p.avg_priceis frozen (the trader’s entry price the band was validating instead of the live ask) - autotrade-exit-cursor-404-wedge-fix — the sibling entry-vs-exit boundary; source of the
resolved==closedvendored-field aliasing caveat and the terminal-market exit classifier this fix deliberately leaves alone - pmv2-autotrade-engine-design — the locked engine design these gates implement
- pmv2-gamma-tags-categorization — Gamma
/marketsvs/eventsquirks (related stringified-array encoding family)