Two bugs found and fixed (in the working tree, not yet committed or deployed as of writing) in the Polymarket copy-trade paper lane (pm-arb crate): copy-lane rows always recorded detect_latency_ms = 0, and some rows recorded an implausible negative chase_spread (copy entry cheaper than the cohort’s on-chain fill). Both stemmed from a downstream layer discarding precise upstream data and substituting a lossier value.
For Agents
- Project:
polymarket_fetch/pm-arb. Crates touched:crates/pm-arb/src/copy.rs,crates/pm-arb/src/copy_lane.rs. Upstream source of the precise latency:crates/polymarket-data/src/onchain.rs(Detection.latency).- Verified: 282
pm-arb+polymarket-datatests pass; clippy pedantic + nursery clean.- Repo rule: no code comments — fixes are comment-free.
- Status: fixed in working tree, NOT committed / NOT deployed. Expected effects (sub-second
detect_latency_ms, no mid-as-ask bias) are predictions to confirm post-deploy.- Both fixes directly close two of the “analytics niggles” tracked as open in copy-trade-lane (
detect_latency_mssemantics; no stale-book guard on the copy ask — the latter is partially addressed and partially deferred, see Residual).
Bug 1 — copy lane detect_latency_ms was always 0
Symptom
Every pm_paper_trades row with lane='copy' had detect_latency_ms = 0 — integer 0, not NULL. The fact it was 0 and not NULL was itself a clue: it proved block_time was being populated (the newHeads block-time cache was working), and that the true differences were all under one second.
Root cause
crates/polymarket-data/src/onchain.rs already computes a millisecond-precise Detection.latency = received_at - block_time (a Duration). But crates/pm-arb/src/copy_lane.rs (handle_detection) discarded it — it kept only block_time.timestamp() (whole seconds) on CohortFill.block_ts. Then crates/pm-arb/src/copy.rs (evaluate_copy) re-derived latency as (now_ts - b).max(0) * 1000 from those whole seconds.
Real on-chain detection latency is sub-second, so the whole-second subtraction floored every value to 0, and the .max(0) masked any sign issues. Classic truncate-then-recompute precision loss.
Fix
- Replaced
CohortFill.block_ts: Option<i64>withdetect_latency_ms: Option<i64>. - Built it from the precise upstream value:
detection.latency.and_then(|d| i64::try_from(d.as_millis()).ok()). evaluate_copynow passes it through instead of recomputing from seconds.- Expected post-deploy: real sub-second values (~200–900 ms).
Lesson
When a downstream layer re-derives a value that an upstream layer already computed precisely, you lose precision. Carry the precise value through; don’t truncate-then-recompute.
Bug 2 — implausible negative chase_spread (entry cheaper than cohort fill)
chase_spread = entry_price − cohort_price. Some rows had large negatives (e.g. entry 0.21 vs cohort 0.36 = −0.15), mixed with exact-0.00 rows.
Refuted hypothesis (gamma token-order inversion)
A subagent proposed a “gamma token-order inversion” in the outcome→token mapping. The data refuted it. Inversion would be systematic, but four rows had entry == cohort_price to the cent — exactly what you’d expect if a taker bought at the ask and we read that same ask. So the outcome/token mapping was fine. (Documented because it’s a tempting wrong turn: don’t blame the on-chain decode when some rows match to the cent.)
Root cause
copy_lane.rs read last_up_ask.or(last_up_mid) / last_down_ask.or(last_down_mid) — falling back to the mid as if it were an ask. The mid sits below the ask, so when no real ask was present we booked a too-cheap entry → optimistic PnL bias. Worst case: buy_down over an up-only book feed used 1 − up_mid (the down mid).
Fix — synthesize a real ask by no-arbitrage, never the mid
New helper copy_ask(st, side):
- Use the same-side real ask if present.
- Else synthesize a real ask from the opposite-side BID by no-arbitrage:
down_ask = 1 − up_bidup_ask = 1 − down_bid
- Never use the mid.
This is consistent with how market_state.rs already builds ask_history_down from 1 − bid. It removes the optimistic bias and preserves fill volume on single-sided feeds — declining to fill outright would tank buy_down copies on up-only books.
No-arbitrage ask synthesis (remember this)
On a binary up/down market the two outcomes sum to 1, so:
ask_up = 1 − bid_downask_down = 1 − bid_upUse the opposite-side bid to synthesize a real ask. The mid is NOT a valid ask — it sits ~half a spread below the ask and biases entries optimistically.
Residual (NOT fixed — deferred)
The largest outlier (−15¢) is too big to be mid-vs-ask: the mid is only ~half a spread below the ask. This is a stale / fast-moved book — our separate CLOB book feed lagged a fast BTC move while the cohort bought at the new price. Not fixable retroactively because book-age is not stored.
Deferred mitigation: a config-driven staleness guard. The threshold is not pickable yet because it interacts with the planned low-traffic <60s trading lane — quiet markets legitimately don’t tick often, so an aggressive staleness cutoff would wrongly reject good fills there. Needs data before choosing a threshold.
Open follow-ups
- Confirm post-deploy that
detect_latency_msshows real sub-second values (~200–900 ms).- Confirm the mid-as-ask bias is gone and fill volume on single-sided feeds is preserved.
- Decide on a book-staleness guard once there’s data; threshold must coexist with the low-traffic <60s lane. Consider persisting book-age to enable this.
Verification
- 282
pm-arb+polymarket-datatests pass. - clippy pedantic + nursery clean.
- Files touched:
crates/pm-arb/src/copy.rs,crates/pm-arb/src/copy_lane.rs. - No code comments (repo rule). Not committed / not deployed.
Related
- copy-trade-lane — the durable on-chain copy-lane note (strategy, architecture, decode facts, deploy runbook, the analytics niggles these fixes close)
- top-trader-edge — cohort edge thesis the copy lane mirrors
- longshot-strategy — the other
pm-arblane;market_state.rsask_history_down = 1 − bidprecedent reused here