name: poly-copy-trading
description: "Cogni poly copy-trade mirror pipeline specialist. Load when working on the mirror loop itself — mirror-coordinator, wallet-watch, poly_copy_trade_{targets,config,fills,decisions} tables, poll cadence, v0 live-money caps, RLS on copy-trade tables, shared poller refactor (task.0332), status-sync / sync-truth cache (task.0328), v1 hardening bucket (task.0323), or Phase 4 streaming prep (task.0322). Also triggers for: 'mirror this wallet', 'why didn't the mirror fire', 'flip copy_trade_config', 'tracked wallet add', 'mirror skip reason=already_placed noise', 'fills ledger status drift', 'cap exceeded daily', 'WebSocket streaming poly', 'target ranker'. For provisioning trading wallets / CLOB creds / CTF approvals see poly-auth-wallets; for CLOB order semantics / Data-API / wallet screening see poly-market-data."
Poly Copy-Trading Pipeline
You are the expert for the mirror loop — everything that turns "target wallet traded" into "our wallet placed a mirror order." Trading-wallet provisioning, signing, and CLOB semantics live in sibling skills.
Architecture in one pass
wallet-watch source (WS wake-up + Data-API drain, or legacy source in old branches)
│
▼
features/wallet-watch (normalize → Fill; no decisions, no writes)
│
▼
mirror-pipeline.ts::runMirrorTick (dedup, target policy, position context)
│
▼ INSERT_BEFORE_PLACE (correctness gate — at-most-once)
poly_copy_trade_fills row lands (status=pending, client_order_id set)
│
▼
PolyTradeExecutor.placeIntent (authorizeIntent → CLOB adapter)
│
▼
ledger updated + decision row written (placed | skipped | error)
Invariant order matters. Flipping INSERT and PLACE means a successful CLOB submit whose ledger row never committed → next poll double-mirrors. Never reorder.
Durable flowchart: Poly Order And Position Lifecycle § Mirror Decision To Limit Order.
Key code landmarks
nodes/poly/app/src/features/copy-trade/mirror-pipeline.ts—runMirrorTickgluenodes/poly/app/src/features/copy-trade/plan-mirror.ts— pure mirror decision policynodes/poly/app/src/features/wallet-watch/— wallet observation source, no decisionsnodes/poly/app/src/bootstrap/jobs/copy-trade-mirror.job.ts— poll shim + target-config hydrationnodes/poly/app/src/features/copy-trade/target-source.ts—dbTargetSource+envTargetSource; target policy fieldsnodes/poly/packages/db-schema/src/copy-trade.ts— schema forpoly_copy_trade_*tablesnodes/poly/app/src/features/trading/order-ledger.ts— ledger writes + sync-truth reads
Tables (RLS-scoped, per-tenant)
| Table | Purpose |
|---|---|
poly_copy_trade_targets |
Per-tenant tracked target wallets plus target policy fields. RLS via created_by_user_id. |
poly_copy_trade_fills |
The ledger. Row per decision. idempotency_key = keccak256(target_id + ':' + fill_id). |
poly_copy_trade_decisions |
Append-only audit log of every decide() outcome. Never updated or deleted. |
dbTargetSource.listForActor(actorId) — RLS-clamped via appDb, used by per-user routes.
dbTargetSource.listAllActive() — under serviceDb, the ONE sanctioned BYPASSRLS read across tenants. Only called by the mirror-poll enumerator in container.ts. If you find a second caller, that is a bug.
Runtime config — candidate-a
Enable switch: there is no per-tenant kill-switch table. An active target row plus active wallet connection plus active grant is the opt-in gate. Remove or disable the target row to stop mirroring that wallet.
Poll cadence: 30s. Warmup backlog: 60s. Hardcoded in copy-trade-mirror.job.ts. Bounds our latency floor at ~30-60s (task.0322 P4 swaps to CLOB WebSocket).
Target policy: poly_copy_trade_targets carries mirror_filter_percentile and mirror_max_usdc_per_trade. The enumerator threads these through buildMirrorTargetConfig into MirrorTargetConfig.sizing.
Grant caps: poly_wallet_grants carries per_order_usdc_cap, daily_usdc_cap, and hourly_fills_cap. These are enforced by authorizeIntent, downstream of planMirrorFromFill.
Tracked wallets — add via dashboard + or POST /api/v1/poly/copy-trade/targets; remove via − or DELETE /api/v1/poly/copy-trade/targets/[id]. Never seed wallets via env vars (the Phase-A envTargetSource exists for local-dev only).
Observability — mirror signals
| Signal | Where | Good state |
|---|---|---|
poly.mirror.poll.singleton_claim |
Loki | Fires exactly once per pod start |
poly.wallet_watch.fetch |
Loki | raw=N, fills=N, phase=ok when a drain runs |
poly.wallet_watch.ws.wakeup_total |
Metrics | Increments when watched assets trade |
poly.mirror.decision outcome=placed |
Loki | Emitted when mirror fires |
poly.mirror.decision outcome=skipped reason=already_placed |
Loki | Dedup. Noisy — reducing noise is in task.0323 §1. |
poly.mirror.decision outcome=skipped reason=position_cap_reached |
Loki | Hitting target-policy cap. Expected under load; investigate if spiking. |
poly.mirror.poll.tick_error |
Loki | ZERO. Any hit = bug. |
poly_copy_trade_fills |
poly DB | Row per mirror decision |
Status-sync gotcha (task.0323 §2 / task.0328): poly_copy_trade_fills.status=open is written at INSERT time and never re-read from CLOB. Actual CLOB state may be filled, canceled, or partial. Don't trust the ledger's status column alone — cross-check Data-API /positions?user=<addr> or the synced_at staleness window exposed via /api/v1/poly/sync-health.
MCP-down fallback: scripts/loki-query.sh '{env="candidate-a",service="app",pod=~"poly-node-app-.*"} | json | event=~"poly.mirror.*"' 30 — see top-level poly-dev-manager skill for details.
Idempotency + fill_id — frozen
fill_id = data-api:<tx>:<asset>:<side>:<ts>— assembled inpolymarket-source.ts. Shape is frozen. Phase 4 addsclob-ws:<…>as a sibling scheme; never mix schemes within one fill.idempotency_key = keccak256(target_id + ':' + fill_id)— written to ledger and submitted asclient_order_idto CLOB. Invariant: two poll ticks seeing the same on-chain fill produce the same key → CLOB dedups → at-most-once.- The
target_idin the key is the UUIDv5 derived fromtarget_wallet, not the DB row PK (UUIDv4). This was the source of revision-1 bug #2 on task.0318 Phase A — HTTP routes had a drift betweentarget_id(UUIDv5 for ledger correlation) andparams.id(UUIDv4 DB row PK for DELETE). Same term, different space.
v1 + Phase 4 roadmap pointers
- task.0323 v1 hardening — cursor persistence, CTF SELL (wallet-owned, overlaps with
poly-auth-wallets), status-sync, metrics, alerting.In Reviewat time of writing. - task.0328 sync-truth cache — DB-as-CLOB-cache with
synced_at+/sync-health. Done. - task.0332 shared poller — replace per-wallet
setIntervalwith one poll loop +TargetSubscriptionRouter. Blocks Phase 3 multi-tenant scale.Needs Design. - task.0322 Phase 4 — CLOB WebSocket dual-path ingestion + hot signer + target ranker + counterfactual PnL.
Needs Design.
Active bugs that affect this pipeline
- bug.0335 — shared operator BUY empty reject on candidate-a. Every autonomous mirror attempt rejected with empty CLOB response. Likely operator-wallet balance / allowance / keys — not a code bug in this pipeline. Ground-truth in
poly-auth-wallets. Until resolved, candidate-a mirror attempts always fail at the CLOB submit step. - bug.0329 — SELL on neg_risk market empty reject. Any position opened on neg_risk becomes roach-motel until resolution. Blocks close-position. Root cause: missing CTF approval (see
poly-auth-wallets).
Anti-patterns specific to the mirror
- "I placed a trade from my own per-tenant wallet and the mirror should see it." No — the mirror watches the TARGET wallet, not your trading wallet. Placing from yourself validates CLOB + signing, not the mirror.
- Adding a second
BYPASSRLSread somewhere other than thecontainer.tsenumerator. Cross-tenant reads are a cross-tenant isolation hole; the enumerator is the audited seam. - Generalizing
scripts/experiments/place-polymarket-order.ts(scope-narrow $1 post-only dress-rehearsal) instead of usingprivy-polymarket-order.ts. The dress-rehearsal has deliberate guardrails. - Smuggling P4 scope (WebSocket, ranking, streaming) into v0 or v1. Keep the fill_id shape + idempotency formula fixed.
- Silencing
outcome=skipped reason=already_placedat the coordinator layer. It's noise at the Loki level but correctness-preserving at the code level. Fix per task.0323 §1 (batch / sample), not by removing the skip branch. - Changing v0 caps via
kubectl set env. Argo reverts on next sync. Caps are in code. task.0347 is the work item to lift them to tenant-config.
Enforcement rules
INSERT_BEFORE_PLACEis the correctness gate — never reorder.fill_idand idempotency formulas are frozen.- Per-tenant RLS on
poly_copy_trade_*tables must stay intact. Any new table joins throughbilling_accounts.owner_user_idEXISTS-pattern (same shape asllm_charge_details), not directcreated_by_user_idcoupling. - Caps are defense-in-depth. Even when task.0347 lifts them to per-tenant config, the code must enforce whichever is smaller: configured cap or hardcoded safety cap.