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-data tests 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_ms semantics; 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> with detect_latency_ms: Option<i64>.
  • Built it from the precise upstream value: detection.latency.and_then(|d| i64::try_from(d.as_millis()).ok()).
  • evaluate_copy now 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):

  1. Use the same-side real ask if present.
  2. Else synthesize a real ask from the opposite-side BID by no-arbitrage:
    • down_ask = 1 − up_bid
    • up_ask = 1 − down_bid
  3. 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_down
  • ask_down = 1 − bid_up

Use 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_ms shows 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-data tests 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.
  • 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-arb lane; market_state.rs ask_history_down = 1 − bid precedent reused here