Key design decisions for the new position_manager (PM) crate: a focused Polymarket execution + watch crate that the deferred autotrade → position_manager rename lands into, scoped down to execution + state + watching. This is a behavior-preserving migration out of polymarket-fetch, not a greenfield engine rehome. Source artifacts: docs/superpowers/specs/2026-06-20-position-manager-interface-design.md and docs/superpowers/plans/2026-06-20-position-manager-execution-migration.md in /Users/levander/coding/pmv2/position_manager.
Plan 1 IMPLEMENTED (2026-06-21) — see position-manager-execution-migration-implemented
The execution migration described in this note is DONE. All 11 tasks executed, final whole-branch opus review verdict READY TO MERGE (no Critical/Important). Committed on
mainin both repos (PM headcbd0f4c, fetch headafc785d), not pushed, autotrade mode OFF. Outcome, gate results, and open user decisions: position-manager-execution-migration-implemented.
For Agents
Reference note for future sessions. PM repo:
/Users/levander/coding/pmv2/position_manager(new Cargo workspace,git initonce in Task 1, NOT yet a git repo). Upstream:/Users/levander/coding/polymarket_fetch. Plan 1 (execution migration) is written; Plans 2 (self-wallet watcher) and 3 (watched-tx watcher) are sequenced but not yet written.
Post-audit revision (2026-06-20) — read before acting
This note was revised after a design audit. Two earlier claims were walked back:
- Migration ownership — PM does NOT run a second
sqlx::migrate!. Thepmv2_autotrade_orderstable stays created by the single existing upstream migrator; PM owns the ledger CODE only. Physical migration relocation is deferred. The earlier “adopt-if-exists migration owned by PM” plan was a sqlx trap — see sqlx-shared-migrations-table-trap and §4.place_orderscope — it now covers BOTH buy and sell. The auto-sell/exit path folds into PM so the whole position lifecycle (buy → submitted → filled/partial → open → selling → sold) lives in PM. Also folded in: a latency win (use_server_time(false)) and config table co-ownership (PM reads caps, writes breaker counters).
1. Boundary — focused execution+watch crate, NOT a full engine rehome
PM owns: place_order (build/sign/POST + record) for both buy and sell, the full position lifecycle (buy → submitted → filled/partial → open → selling → sold, including the exit/auto-sell path), the position ledger code (ledger.rs), the circuit-breaker counter writes, self-wallet reconciliation, watched-transaction detection.
place_order now spans the whole lifecycle (buy + sell)
place_ordermigrates BOTH today’ssubmit_live(buy entry) ANDauto_sell(sell exit).Side::Buydrives the entry path;Side::Selldrives the exit path. The exit transitions —mark_selling/record_partial_sell/mark_sold/settle_placed_fill— fold into PM, so the entire position state machine lives in one crate. Earlier the design scopedplace_orderto the buy path only; that was widened in the audit.
Strategy stays upstream in polymarket_fetch: detection sources, gates (EV, conviction, price-bounds, category, time-to-resolution), sizing, first-mover gating, the NATS Pmv2Consumer, and mode selection. Upstream depends on PM and calls in-process. The mode != "live" / dust / floor-price guards stay upstream in route_exit, which builds an already-validated sell OrderRequest.
2. Dependency direction — one-way, in-process
polymarket_fetch → position_manager (one-way, no cycle). PM is a leaf crate: the polymarket-clob crate (vendor rs-clob-client-v2 SDK + V2 signing semantics) moves INTO PM. Single host process to start (today’s pmv2 consumer binary links PM and runs both watchers + strategy in-process). Event sinks are traits, so a later split into a separate watcher binary needs only a NATS-publishing sink impl — no PM internal changes.
3. Exposed API
PositionManager::connect(signer, proxy, pool, clob_cfg) -> Result<PositionManager>
async fn place_order(&self, req: OrderRequest) -> Result<PlacedOutcome>;
async fn watch_self_wallet(&self, sink: impl FillSink) -> JoinHandle<()>;
async fn watch_transactions(&self, addrs: AddressSet, sink: impl FillEventSink) -> JoinHandle<()>;place_order(OrderRequest) -> PlacedOutcome— migrates BOTH today’ssubmit_live(buy) andauto_sell(sell). Preserves FAK order type, proxysignatureType = 1, V2 contracts.PlacedOutcomewraps today’sPlacedOrder(clob_order_id,status,making_amount,taking_amount,fee_usd,tx_hashes,trade_ids,ledger_id).OrderRequestcarriesreserve_id, NOT aprovenanceblock. It acts on an already-inserted reserve row (pmv2_autotrade_ordersid).source_id/origin_ref/source_score/dedup_key/signal_tsare written upstream at reserve time bypersistence.rs’sRESERVE_INSERT_SQL(INSERT … RETURNING id); PM operates on that row byreserve_idand needs none of it. (Earlier the design proposed an opaqueprovenanceblock — removed in the audit.)- Buy fields (
size_usd,limit= BUY price bound) drive the entry;build_orderis called withsize_usd+Decimal::ZERO. - Sell fields (
sell_shares= quantized shares /build_order’sfilled_sizearg,floor_price= SELL floor,expected_shares,held= currentfilled_sizefor settle proration,cost_basis_usd,slippage_cap_bps) drive the exit;build_orderis called withDecimal::ZERO+sell_shares. This buy/sell split mirrors the two realSigningClient::build_order(token_id, side, size_usd, filled_size, limit_price, neg_risk)call shapes.
watch_self_wallet— long-running task: CLOB/userWS (L2 auth, subscribe by held condition_ids) matched to the ledger byclob_order_id, PLUS a periodic on-chainbalanceOf/ pUSD truth check. On divergence: chain is authoritative — correct the ledger toward chain truth and emit a reconcile event (never silent overwrite).watch_transactions— long-running task, the unbuilt Phase-2 detector: PolygonOrderFilledWSS (eth_subscribe logs, topic0-filtered) over a configurable address set → rawFillEvent { trader, token_id, side, size, price, tx_hash, log_index, block_ts }→FillEventSink→ upstream first-mover gating.- Sinks are traits (
FillSinkfor reconciled self-fills,FillEventSinkfor raw watched fills). Default impls are in-process channels; a NATS impl drops in later, making the separate-watcher-process split cheap.
4. Schema split — code ownership only; NO second migrator (walked back)
| Table / columns | Migrator (runs DDL) | Code owner (runs queries) |
|---|---|---|
pmv2_autotrade_orders (whole ledger) | upstream (single existing sqlx::migrate!) | PM (ledger.rs) |
Caps: total_exposure_cap_usd, daily_spend_cap_usd, max_open_positions, max_copies_per_event | upstream | PM reads (load_caps) — sole counter of exposure / open positions |
Breaker: breaker_tripped, breaker_fail_count | upstream | PM writes (bump_breaker_count, config-only id=1 FOR UPDATE lock) + reads (load_breaker) |
Strategy filters (category_filter, min_conviction, entry-price bounds, edge/time/slippage/premium/fill-size) + mode | upstream | upstream (mode read upstream, passed per-call into OrderRequest) |
Migration ownership WALKED BACK — PM runs NO migrator (DEFERRED)
The earlier plan had PM adopt
pmv2_autotrade_ordersvia aCREATE TABLE IF NOT EXISTSmigration. That was removed. PM owns the ledger CODE (ledger.rs— status transitions, caps reads, breaker writes), NOT a secondsqlx::migrate!. The table stays created by the single existing upstream migrator (it already exists in prod). PM must not callsqlx::migrate!, must not create amigrations/dir, must not editsupabase/migrations/20260614000000_pmv2_autotrade.sql, and thesqlxmigratefeature is intentionally omitted fromCargo.toml.Why: sqlx 0.8 tracks ALL applied migrations in one shared
_sqlx_migrationstable keyed by version. A secondmigrate!set throwsVersionMissingon the ~43 other upstream migrations; reusing version20260614000000collides asVersionMismatch; editing the already-applied file bricks boot via checksum mismatch; andCREATE TABLE IF NOT EXISTSdoes NOT save you (sqlx tracks by version row, not table existence). Physical migration relocation is deferred to a coordinated single-migrator change. Full breakdown: sqlx-shared-migrations-table-trap.
pmv2_autotrade_configis CO-OWNED, not read-onlyThe config table physically stays upstream (strategy filters +
modelive there). PM reads the caps subset (load_caps) AND writes the breaker counters (bump_breaker_count). Call it co-owned — the split is ownership of meaning per column, not a table relocation. PM is the single component that can count open positions / sum exposure from the ledger.
5. Behavior-preserving — proven by golden parity
No column changes, no semantic changes to build/sign/POST/record — only relocation of which crate runs the code. No migration file is moved or edited. Proven by golden-parity tests: the same OrderRequest produces the same maker/taker/contract triple (locked buy vector maker 6_250_000 / taker 7_621_900 / std contract / limit 0.82; locked sell vector maker 10_000_000 / taker 4_000_000 / floor 0.40) AND the same ledger transition sequences (pending → submitted → filled|partial → open for buy; open → selling → closed for sell). assert_amounts / assert_sell_amounts reject the same mutations (wrong contract, amount drift, below-floor) the upstream execute.rs / exits.rs tests rejected. A wiremock mock-CLOB integration test drives place_order for success / partial / reject / timeout (and a sell) and asserts the exact ledger status sequence + recorded filled_size / avg_fill_price / cost_basis_usd.
6. Three sequenced plans
- Execution migration — written. Move
polymarket-clobin → moveexecute/ledger/onchain→ rewiresubmit_livetoPM::place_order→ movepmv2_autotrade_ordersmigration ownership + caps/breaker readers → declareFillSink/FillEventSink/FillEventseams (unimplemented). - Self-wallet watcher — CLOB
/userWS +balanceOfreconcile. - Watched-tx watcher —
OrderFilledWSS detector wired to upstream first-mover gating.
7. Correctness invariants to preserve (must not regress)
- V2 hard cutover (2026-04-28):
verifyingContractstd0xE111180000d2663C0091e4f400237545B87B996B/ neg-risk0xe2222d279d744050d28e00520010520000310F59; pUSD collateral0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB; domain version"2", chain_id 137. A stale-V1 order is rejected, not slow (see 2026-06-20-fastest-polymarket-trade-submission). connect_timeoutrule: every reqwest/WS client sets BOTHtimeoutANDconnect_timeout— the 75-min silent-hang lesson. The movedonchain.rseth_callclient gainsconnect_timeouton the move.- Latency win —
use_server_time(false)(folded into the migration): the clob client hard-codesConfig::builder().use_server_time(true)atpolymarket-clob/src/lib.rs:329, which fires a serialGET /timebefore every order POST (~20–100 ms). Flip it tofalsewhen the crate moves into PM: stamp the HMAC auth header from localUtc::now()instead. The signed-order EIP-712 bytes are unchanged (only the auth-header timestamp moves), so the locked hash/parity tests stay green; add a one-line NTP clock-sanity note (host clock must be sane). The 3 per-tradeGETs (~150–600 ms) inmarket_data.rs::resolveare upstream and out of PM scope; HTTP/2 + colocation are deferred pending p50/p99 measurement. - Execution ordering: 30s
SUBMIT_TIMEOUTonbuild_orderandplace_order;mark_submittedBEFORE POST (idempotency); reserve race → skip POST; POST timeout → leave rowsubmitted, bump breakernetwork, no blind retry (order may have landed); breaker-tripped → cancel without POST; error classificationauth | balance | network | validation | other; default mode OFF, dry-run-in-prod first. - Watcher resilience: WS reconnect with exponential backoff + jitter; gap-backfill on reconnect (bounded historical sweep, see watcher-onchain-gap-backfill); fill dedup by
(tx_hash, log_index).
8. Open items
- Shared-table migration is DEFERRED. PM does not run a migrator in Plan 1 — the single existing upstream
sqlx::migrate!keeps creatingpmv2_autotrade_orders. A physical relocation would require a coordinated single-migrator change (sqlx 0.8’s one shared_sqlx_migrationstable makes a secondmigrate!, a version reuse, or an edit to the applied file all fatal at boot — see sqlx-shared-migrations-table-trap). Out of scope here. - Process split deferred — sink traits keep the separate-watcher-binary option open without rework.
- babylon
deployopen redeploy tasks (e.g. #268) are not visible topositionmanager; decide whether deploy/redeploy responsibility moves to the PM handle. - No code change to the live trade path during design — migration starts only after spec + plan approval.
Related
- sqlx-shared-migrations-table-trap
- pmv2-autotrade-engine-design
- pmv2-position-manager-nats-consumer
- autotrade-3c2-complete-handoff
- 2026-06-20-fastest-polymarket-trade-submission
- watcher-onchain-gap-backfill
- autotrade-onchain-balanceof
- autotrade-signing-client-order-ops
- autotrade-reserve-outcome-enum
- polygon-wss-provider-drpc
- polymarket-fetch