- Home
- Case studies
- Real-time mirror trading against an on-chain order book
Real-time mirror trading against an on-chain order book
Copying on-chain trades within seconds while resizing, slippage-checking, and deduplicating each action so retries never become double execution.
Published Jun 30, 2026Reviewed Jul 2, 2026
The short version
- Problem
- Near-real-time trade mirroring had to detect source trades quickly without replaying the same transaction or accepting bad fills.
- Constraint
- The process depended on refreshing RPC data, asynchronous settlement, balance differences, and an indefinite runtime with bounded memory.
- System built
- A polling and execution loop with proportional sizing, slippage gates, transaction-hash deduplication, and bounded in-memory working sets.
- Result
- Each source trade produced at most one follower action, unsafe fills were rejected, and the process could run continuously without memory drift.
- Where this applies
- Relevant for automation systems where external state changes fast and every retry needs explicit idempotency and risk controls.
- Stack
- Python · Web3 RPC · on-chain CLOB
Why this matters
If your team is planning a system with the same failure modes, this is the level of thinking a first call starts at.
Overview
The constraint above is the entire brief: the RPC connection this process reads through refreshes on its own schedule, not the trader's; the chain it depends on settles trades asynchronously; the account being mirrored into never holds the same balance as the account being copied from; and the process itself has to run indefinitely on memory that is not allowed to grow without bound. There is no synchronous handshake between the two accounts, no shared ledger, and no natural pause point where the loop can safely stop and resume from a clean state. Everything below is built out of one polling loop and a small set of guards wrapped tightly around it — sizing, a slippage gate, a dedup check, and a bound on how much the loop is allowed to remember. None of those guards is exotic on its own; the discipline is in never letting any one of them slip under the pressure of a fast market.
Sub-three-second polling for trade detection
New trades are detected by polling the source account's on-chain activity under a three-second interval — short enough that a mirrored action still lands close behind the trade that triggered it, long enough that the loop isn't hammering the RPC endpoint on every tick. Each poll asks the same question: what has this account done since the last time it was checked, and each answer is walked in order rather than assumed to be a single new trade. A market mover, or a network hiccup that delays one poll past the next, can easily surface more than one trade in a single response, and the loop has to treat that as the normal case rather than an edge case.
The interval itself is a tuned tradeoff, not a constant chosen once and forgotten. Shorter narrows the detection lag but multiplies RPC load, and on a rate-limited endpoint risks the very throttling that would widen the lag it's trying to close. Longer eases load but stretches the window in which a follower trade lands meaningfully behind the trade it's copying. Three seconds is where that tradeoff sits for this workload — fast enough that a mirrored trade still executes against a market that hasn't moved far, comfortably inside what a standard RPC provider tolerates without a dedicated subscription tier.
Why not subscribe to a push/webhook feed
The obvious alternative to polling is a push feed — a WebSocket subscription or a webhook that fires the moment a new trade lands, removing the interval entirely and, in theory, cutting detection lag to whatever the network itself takes. In practice, three properties of on-chain infrastructure make that theory weaker than it looks.
Freshness is not actually guaranteed by a push notification: an event fires on a provisional view of the chain, and a block can still reorg after the event has already been delivered, so a subscription still needs the same "is this final yet" check a poll would have made anyway — it just makes that check after having already acted, instead of before. Provider support is uneven: not every RPC endpoint offers a reliable subscription channel, the ones that do often gate it behind a higher-priced tier, and a subscription's guarantees around delivery order and gap-free delivery are usually weaker and less documented than the plain request-response API the same provider exposes. And reconnection is where a push feed's simplicity quietly disappears — a dropped WebSocket has to be detected, reopened, and, critically, backfilled for whatever happened during the gap, which means the client ends up writing a recovery path that is itself a poll: "what changed since the last event this process is sure it received?"
A system that has to keep that poll-shaped recovery path correct anyway gains little by also maintaining a separate steady-state push path next to it. Polling under a short, fixed interval is the same mechanism used for both the common case and the recovery case, which is one fewer thing to keep correct under pressure.
Proportional position sizing
A mirrored trade is never sized to match the source trade's absolute size; it is sized as a proportion of the follower account's own balance, using the same fraction of that balance the source trader committed of theirs. The two accounts almost never hold comparable balances — a source account trading a large position and a follower account with a fraction of that capital are guaranteed to diverge if the follower simply copies the raw size, either overcommitting a small account or, at the other extreme, leaving most of a large account's capital unused.
Proportional sizing keeps the relationship between the two accounts stable regardless of their absolute size: a trader committing ten percent of their book to a position produces a follower action committing roughly ten percent of the follower's book, whatever that follower's balance happens to be. That balance is itself a moving target — deposits, withdrawals, and the follower's own prior mirrored trades all change it between polls — so sizing has to read the follower's current balance at the moment of the mirrored action, not a cached figure from an earlier point in the loop. Reading it fresh each time costs a little more RPC traffic in exchange for a guarantee that matters more: the follower never ends up trading against a balance figure that's already stale by the time the order reaches the chain.
Slippage gating
Every mirrored action carries an expected-slippage check before it's allowed to execute, comparing the price the order book is likely to fill at against the price the source trade executed at. If that gap is wider than a fixed tolerance, the action is rejected outright — not resized, not retried at a smaller amount, simply declined.
That's a deliberate asymmetry: a missed trade costs the follower an opportunity, but a bad fill costs the follower money, and the two are not equally recoverable. An order book that has thinned out since the source trade executed — because the source trader's own fill already moved it, or because the market is simply moving fast — will reprice a same-size follower order at a visibly worse level, and mirroring that fill without a gate would mean the system silently accepts a loss on the follower's behalf that the follower never agreed to.
expected_slippage = estimate_slippage(order_book, side, size)
if expected_slippage > MAX_ALLOWED_SLIPPAGE:
reject_trade(trade, reason="slippage_exceeded")
return
execute_trade(trade, size)
The slippage gate — decline the fill rather than accept it
The gate is deliberately blunt: one threshold, checked once, immediately before execution, rather than a scaled response that tries to salvage a partial fill. A blunt gate is easier to reason about under time pressure than a clever one, and the failure mode of a blunt gate — a missed trade — is the one this system is built to prefer.
Transaction-hash deduplication
Every trade the source account executes settles with a transaction hash, and that hash is the single source of truth for whether this process has already acted on it. Before a detected trade is allowed to trigger a follower action, its hash is checked against an in-memory set of hashes already handled; if it's present, the trade is skipped without further processing, no matter how it was detected.
if trade.tx_hash in seen_hashes:
return # already handled, retry or replay
seen_hashes.add(trade.tx_hash)
execute_follower_trade(trade)
The dedup check that makes retries safe
That check is what makes the rest of the system safe to retry. A poll that overlaps the previous one's block range because the loop restarted after a crash, a reconnect that re-fetches trades already seen before the connection dropped, a manual re-run after an operator intervened — none of those are special cases the code has to detect and handle differently, because the hash check collapses all of them into the same outcome as a trade that was never seen twice in the first place.
The guarantee this produces is exactly-once action per source trade, not exactly-once detection. The loop can and does see the same trade more than once, sometimes deliberately, by overlapping poll windows on purpose to make sure a trade near a window boundary is never missed. It's the dedup check, not the detection step, that carries the correctness guarantee.
Bounded in-memory working sets
A set of every transaction hash this process has ever seen, kept forever, would make the dedup check in the previous section trivially correct, and would also make the process's memory grow without limit for as long as it runs — which, for an automation process with no scheduled restart, is indefinitely. The working set has to be bounded, which means it has to forget hashes it no longer needs, and the difficulty is choosing a forgetting rule that never discards a hash the dedup check might still need.
A trade only needs to stay in the set for as long as it could plausibly resurface — the width of the largest overlap the polling loop deliberately introduces between windows, plus enough margin to absorb a reconnect gap without a trade re-entering as if it were new. Past that window, a hash that hasn't resurfaced is not coming back, and holding onto it any longer buys no additional safety, only memory the process will never use.
In practice that means the set is evicted on a rolling basis — old entries aged out once they fall outside the window that matters, rather than the set being cleared wholesale on a timer, which would reopen exactly the double-execution risk the dedup check exists to close. The same bounding logic applies to any other per-trade state the loop accumulates — pending confirmations, retry counters — for the same reason: an indefinite runtime turns any unbounded collection into a slow leak, and a slow leak in a process nobody is watching minute to minute eventually becomes an outage.
Tradeoffs and limitations
- A three-second poll interval is a floor on detection latency by design; a source trade can sit undetected for nearly that long before the follower even begins to react, which a push-based feed would narrow, at the cost of the reconnection and freshness complexity discussed above.
- Proportional sizing still depends on a balance reading taken at the moment of the mirrored action; if the follower's balance changes between when a trade is detected and when it's read for sizing, the fraction copied is not perfectly synchronized with the fraction the source trader actually committed.
- A slippage gate tuned conservatively enough to avoid ever mirroring a bad fill will also, by construction, decline some fills that would have been fine — the two failure modes trade directly against each other, and no threshold eliminates both.
- Transaction-hash deduplication is only as reliable as the hash the chain and the RPC provider report; a provider that returns an inconsistent or non-canonical hash for the same underlying trade would defeat the dedup check silently, without raising an error anywhere.
- The bounded working set's eviction window is a tuned parameter, not a fixed law; as trade volume or reconnect frequency grows, a window that felt generous at low volume can start clipping legitimate retries, and it needs periodic revisiting rather than a set-once value.
- None of this observes intent. The system mirrors trades; it has no view into why the source trader made one, and a proportional, slippage-gated copy of a bad decision is still a bad decision, just made twice.
References and further reading
Related case studies
Systems that share a constraint with this one — retries, isolation, cost, recovery.
Idempotent webhook ingestion at scale
Data-loss risk moved from a quarterly investigation to continuous checks that detect gaps, repair them, and expose operational health.
Go · Postgres (SKIP LOCKED, matviews) · HMAC · advisory locksA durable job queue that replaced a wall of no-code automations
Failures became queryable rows, retries became safe by construction, and new automations could be tested against live traffic before acting.
Go · Postgres leases · gocron · bearer + HMAC auth