The design for the first system that writes REAL trades to Polymarket’s CLOB — a source-agnostic auto-trade engine whose first signal source is first-mover copy of Core-tier cohort traders. Spec is LOCKED (after 3 adversarial review passes) at docs/superpowers/specs/2026-06-14-pmv2-autotrade-design.md in the repo. This note captures the load-bearing decisions and hard-won facts so a future session doesn’t re-derive them.
For Agents
This is the live-money lane. It runs alongside (does not replace) the paper lane. The frozen scoring/topbets/consensus/
decide_copymath is unchanged — the engine only consumes its outputs. Before any engine code, a mandatory Phase-0 spike (§14) must pin three things that cannot be settled on paper. Status: LOCKED, spike in progress.
Goal & Shape
- A source-agnostic auto-trade engine living in a new auto-trade lane. Signal sources emit a normalized
TradeSignal;engine::ingest(&TradeSignal)is the single pluggable seam. Sizing, risk-checks, execution, persistence, dry-run/live, exits, reconcile and breaker are all reused by any source unchanged. - First (and only-at-launch) signal source: first-mover entries from Core-tier cohort traders → real CLOB orders. Adapts the existing first-mover detection (see first-mover-overlap-watcher) plus a new exit read.
- A second exit source reads
pmv2_position_eventsSELL/exit events of copied traders via its own cursor (autotrade_exitsinpm_detection_cursors) — it does not reuse theexits.rsnotifier/shared cursor (see pmv2-exit-alerts). - Adding a source later = a small emitter + an enable flag. A mock source in tests asserts
ingestis fully source-agnostic.
Execution: vendored Rust CLOB client
- Vendor + pin the official
Polymarket/rs-clob-client-v2Rust crate (alloy-based) intocrates/polymarket-clob/. NOT a Python sidecar, NOT hand-rolled EIP-712 signing. - That crate is the only place that signs. Wrapper exposes
derive_creds,tick_size,neg_risk,market_status,book_top/book_depth,build_order(→ signed order +order_hash+ domainverifyingContract),place_order,cancel,open_orders,trades. - Pin an exact
-canaryrevision; keep it patchable.
Wallet & key custody
- Trade from the Bandi Polymarket PROXY wallet
0xdb0d23c2c3468da13c6e8abfe96edfecc72d9efe,signatureType=1(maker = proxy, signer = controlling EOA0xfa7772b9aa1230a53d3bea9a11f5c52f110684f7). - EOA private key read from a terraform secrets file (
/Users/levander/levandor/terraform/secrets/poly_private_key); held in-memory only, never logged or persisted. LIVE is refused if the key is missing. - No relayer / no gas management / no on-chain allowance setup. Orders are gasless off-chain EIP-712 signatures; the operator settles + pays gas; proxy allowances are pre-set. Preflight only reads allowances.
- Self-trade guard: Bandi is ∉ the cohort, so the engine’s own fills can’t loop back as a buy signal.
The V1/V2 landmine
V1/V2 contract generation must be resolved at RUNTIME, never hardcoded
Polymarket runs two live contract generations simultaneously, and they differ in ways that silently corrupt signing if you guess:
- Different exchange addresses (the EIP-712
verifyingContract).- Different EIP-712 domain version — version
1vs2.- Different collateral —
USDC.evspUSD.The active contract config and the collateral the Bandi proxy actually holds are resolved at preflight (spike-confirmed values). The engine then asserts the built order’s domain
verifyingContractmatches the market’sneg_riskflag at runtime, non-deferrable, before signing — and refuses LIVE if the proxy’s held collateral doesn’t match the resolved exchange. Collateral is resolved even before DRY_RUN signing. This is a §9.2 blocking item.
Control plane
- Owner-only Telegram commands via a new reusable authorization layer (
crates/polymarket-fetch/src/authz.rs):Scope::{Public,Owner},OwnerSet,authorize(scope, owners, user_id),scope_for(cmd). Owner ids come fromTELEGRAM_OWNER_IDSintoBotConfig.owner_ids(mirrorsallowed_chats). Authorize once at the sharedrun_commandchokepoint (message + callback both converge there). The per-chatallowed_chatsgate is preserved; owner gate is additive. - Kill-switch / modes:
OFF/DRY_RUN/LIVE, gating entries. Commands:/autotrade status|off|dryrun|live|exits auto|notify|source <id> on|off|filter <expr>|set <param> <value>|maker <addr>|reconcile|resolve <id> canceled|filled. - Exits are governed by the per-order
modecolumn, NOT the live kill-switch. A row whose order hasmode='live'exits LIVE regardless of the current kill-switch or a tripped breaker — closing / risk-reduction must never be disabled by the open-position kill-switch. Amode='dry_run'row (no real holding) never submits a real sell.
Safety architecture (the expensive part)
The bulk of the design is the crash-safe, double-spend-safe execution machinery — this is where the real-money risk lives.
Economic gates (around frozen decide_copy, never inside)
- Marketable-limit FAK execution with a slippage cap and a reference-anchored chase guard (
reference_pricefrozen at first detection — see below). - Price bounds
[min_entry_price, max_entry_price](no edge near 0/1). - EV-after-cost floor: skip unless
size_usd × min_round_trip_edge_bps/1e4 > est_round_trip_fee_usd(round-trip ≈ buy_fee + sell_fee + 2×expected_slippage). - Min-conviction floor on
bet_confidence. - Market-liveness gate: open/active/accepting-orders + min-time-to-resolution; unknown/
<2000end_date → fail-closed (skip). - Category filter reuses
/topbets’parse_topbets_filter/passes_filter(no new filter logic) — see pmv2-topbets-categories-filters.
Hard caps (config-row-locked, reserve-aware, fee-inclusive)
total_exposure_cap,daily_spend_cap,per_event_cap(keyed on event),max_copies_per_event,max_open_positions.- Computed including in-flight reservations (
pending/submitted/sellingat intended size) +opencost basis — under aSELECT … FOR UPDATEof the singleton config row (id=1), the single accounting mutex. daily_spendkeyed on the reserve day, carried forward to fill (one consistent clock). DRY_RUN reservations released at thepending→dry_runtransition so rehearsal caps don’t fill with phantoms.
Double-sell / deadlock prevention
- A committed
sellingreserve state serializes the inline Sell button, auto-sell, and resolution settlement — whoever flipsopen→sellingfirst wins. - Global lock order (deadlock-free invariant): always acquire the config row (id=1) before any order row, never the reverse. Entries lock the config row only; sell/resolution/reconcile lock the order row only. The breaker increment runs in its own dedicated config-row-only tx, after the failure-recording order-row tx has committed and released. A test asserts no path locks order→config (prevents ABBA deadlock).
- Symmetric ambiguous-failure handling: on a sell, unambiguous rejection (4xx/explicit-not-placed) → guarded
selling→open; ambiguous failure (timeout) → staysellingfor reconcile. Prevents the revert-then-double-sell. - Missed-exit race: if an exit matches an in-flight entry (no
openrow yet), setexit_pending; on the entry’s→opentransition the engine immediately evaluates the queued exit.
Ground truth & recovery
- On-chain CTF
balanceOf(proxy, token_id)is the primary reconciliation truth (Polygon RPC reads inonchain.rs); CLOB trades/open-orders correlated byexpected_order_hash→clob_order_idis best-effort secondary (the hash↔orderID identity is spike-verified or calibrated on first fill, never a hard gate). - Reconcile (startup + periodic +
/autotrade reconcile) operates only on non-terminal entry rows + stucksellingrows past a grace window, is forbidden from leaving terminal states, and alarms loudly on genuine ambiguity (never silently drops)./autotrade resolveis the owner escape-hatch for stuck rows. - Engine-ready is derived, not latched:
ready = preflight_ok AND last_reconcile_ok AND creds_valid, re-evaluated each reconcile. Ready gates LIVE entries only; DRY_RUN entries and all exits do not require the on-chain reconcile. Anerror_class=authfailure →ready=false+ re-run preflight (creds rotation). - Circuit breaker in the config row; counts only
balance|auth|network|settlement(NOTvalidation); “consecutive” = since last success; re-checked before each POST via an unlocked atomic read. Tripping disables entries only — exits still submit.
State machine
(DRY_RUN) detected ▶ pending ▶ dry_run (terminal; reservation released)
(LIVE) detected ▶ pending ▶ submitted ┬▶ filled ▶ open ─┐
├▶ partial ▶ open ─┤
├▶ unmatched ▶ canceled
└▶ failed
open ▶ selling ▶ closed (sell)
open ▶ closed (resolution settlement, periodic + idempotent)
Terminal: dry_run, closed, canceled, failed. Non-terminal (reservable/reconcilable): pending, submitted, filled, partial, open, selling. pending/selling are reserved states committed before their network call; the row lock is never held across a call. DB is the source of truth for accounting.
Phase-0 spike (MANDATORY, precedes engine planning)
Three things cannot be specified on paper — spike them first
Before writing the engine plan, vendor+pin the client and run a throwaway harness that builds+signs one known order for the Bandi proxy, to empirically pin:
- The exact CLOB amount-quantization rule — taken verbatim from the vendored client’s
roundDown/roundNormal(the earlier “GCD of price and 1e6” guess was wrong). Capture a test vector (e.g.0.82/$6.25).- The
order_hash↔ CLOBorderIDrelationship — decides whether reconcile anchors on confirmed-identity or balance-primary + calibrate-on-first-fill.- The active contract domain + collateral (USDC.e vs pUSD) the proxy actually requires (the V1/V2 landmine, §9.2).
Output: a short findings note that updates §4.9/§9.2/§10 with concrete values, unblocking a TDD-able plan.
Isolation guarantee
The frozen scoring/topbets/consensus/decide_copy math is unchanged — the engine only consumes its outputs. The only touches to the existing pipeline are additive read-path changes (no score change):
tiercolumn pulled into theCohortTradercohort read.bet_confidencecomputed unconditionally by the source (today only insideif copy_cfg.enabled).- A new exit read over
pmv2_position_eventskeyed(condition_id, outcome_index, proxy_wallet)with its own cursor.
New economic gates (price bounds, EV floor, liveness) live around decide_copy, never inside it. The frozen math is detailed in pmv2-two-tier-trader-scoring.
Process note
The spec went through 3 adversarial review passes and is now LOCKED. Rollout: Phase-0 spike → land mode=off + migrations + preflight green for Bandi → set TELEGRAM_OWNER_IDS + provision key → /autotrade dryrun rehearsal → small caps + /autotrade live + observe fills/fees/reconcile/restart → calibrate gates → add next source later via one emitter. Gate: bash scripts/gate.sh (see ci).
Related
- copy-trade-lane — the PAPER mirror this builds on; the auto-trade lane runs alongside it
- copy-trade-algorithm — the
decide_copysizing formula reused (wrapped, not changed) - first-mover-overlap-watcher — the first-mover detection the first source adapts
- pmv2-two-tier-trader-scoring — frozen scoring/tier classification the engine consumes
- category-consensus — sibling fetched-cohort signal lane (separate from this)
- pmv2-exit-alerts — existing exit feed; the auto-trade exit source uses its OWN cursor
- pmv2-topbets-categories-filters — the category filter (
eq:/neq:) reused - polymarket-fetch — project overview