How to prevent duplicate orders in a trading bot
A request timeout does not mean an order failed. A webhook delivery does not mean it is new. A restarted worker does not know what happened while it was down. Safe bots assume events can repeat and acknowledgements can disappear, then make every trade intent converge on one durable outcome.
Duplicate orders are usually a delivery problem
| Failure | Naive behavior | Result |
|---|---|---|
| Order call times out | Send it again with a new ID | Both orders may be live |
| Webhook is retried | Process every delivery | One signal creates two intents |
| Two workers read one signal | Both pass the same pre-check | Race creates two orders |
| Process crashes after send | Restart assumes nothing happened | Existing venue order is repeated |
| Fill event is replayed | Add filled quantity again | Local exposure becomes wrong |
| Cancel/replace crosses | Submit replacement before old status is known | Old and new orders can both fill |
Networks commonly provide at-least-once delivery or uncertain acknowledgement, not magical exactly-once effects across your database and a remote venue. The practical goal is idempotent processing: repeat the same input as often as necessary and still produce one logical effect.
Give every trade intent a stable identity
A signal is not yet an order. First create an intent containing the strategy version, instrument, side, desired exposure change, signal timestamp or bar, and risk decision. Give that intent a stable key. A deterministic key can be useful when one strategy must emit at most one action per symbol and bar; a generated unique ID works when multiple independent intents are valid. The business rule determines identity.
example intent keyintent_key = strategy_id
+ ":" + strategy_version
+ ":" + symbol
+ ":" + signal_bar_close
+ ":" + action
database UNIQUE(intent_key)
Generate the venue client order ID from or alongside that intent before the first network call, then persist both. Respect the venue's documented character, length, reuse, and retention rules. Never generate a fresh client order ID merely because the response was slow.
For TradingView webhook bots, require an event ID or derive a stable key from authenticated alert fields. Store it with a uniqueness constraint and return success for already-processed deliveries without placing another order.
Use a write-before-send submission flow
- Begin a local transaction and insert the unique intent.
- Reserve position and risk capacity so a second worker cannot use it.
- Create and store the client order ID with status
SUBMITTING. - Commit that evidence before calling the venue.
- Submit once with a bounded deadline.
- On a clear rejection, store the rejection and release the reservation.
- On acknowledgement, attach the venue order ID and move to the accepted state.
- On timeout or connection reset, move to
UNKNOWN—not failed. - Query by client ID or reconcile open and recent orders before any new send.
If the venue may have accepted a request, the only honest local status is unknown. Block conflicting intents for that instrument until a read or event resolves it. Treating unknown as rejected is the classic double-order bug.
The persistence layer and startup procedure are detailed in trading-bot state persistence. General retry classification belongs in error handling: safe market-data reads can often retry with backoff, while money-moving writes require reconciliation semantics.
Make fills, locks, and cancel-replace idempotent
Order submission is only half the problem. Private streams can redeliver events after reconnection, and a REST snapshot can overlap with streamed fills. Insert each venue fill using its stable fill or trade ID under a unique constraint. Recomputing the position from the fill ledger is safer than incrementing an unprotected variable every time a message arrives.
- Use a database uniqueness constraint for intent keys, client IDs, venue order IDs where appropriate, and fill IDs.
- Acquire an atomic lock or reservation at the same boundary as intent creation; a prior read followed by a later write is still race-prone.
- Keep per-instrument exposure invariants in the risk gate, not only inside a strategy worker.
- Make event handlers tolerate duplicates, late delivery, and out-of-order transitions.
- Expire deduplication records only after the maximum upstream retry and venue lookup window, with historical audit retained separately.
Cancel/replace needs its own state machine. A cancel request does not prove the old order is cancelled. It may fill while the cancellation is in flight. Wait for an authoritative terminal state, account for any late fill, recalculate remaining desired quantity, and then place the replacement. See partial fills and order reconciliation.
Test every ambiguity and race
| Injection | Expected invariant |
|---|---|
| Same signal delivered 10 times | One intent, at most one logical order |
| Two workers act simultaneously | One wins the atomic reservation |
| Venue accepts, response is dropped | Status becomes unknown; query finds original |
| Crash after send, before acknowledgement write | Restart reconciles by saved client ID |
| Same fill arrives via stream and snapshot | Fill posts once |
| Old order fills during cancel | Replacement quantity is reduced or skipped |
| Venue client-ID lookup unavailable | New exposure stays blocked; operator alert fires |
Monitor duplicate-intent conflicts, unknown-order age, unmatched venue orders, duplicate fill attempts, lock contention, and reconciliation differences. A rising dedupe count may indicate a noisy upstream retry policy even if financial exposure remains safe. Connect unresolved unknown orders to the kill-switch ladder.
Frequently asked questions
Why does a trading bot place duplicate orders?
Common causes are retrying an ambiguous timeout, receiving the same webhook or fill event twice, two workers acting on one signal, losing local state during restart, and cancel-replace races. The shared problem is treating delivery or acknowledgement as proof that an intent has happened exactly once.
What is an idempotent trading order?
An idempotent order workflow makes repeated processing of the same trade intent converge on the same logical order rather than creating additional exposure. It combines a stable intent key, a client order ID, durable status, uniqueness constraints, and reconciliation after uncertain results.
Should a bot retry an order after a timeout?
Not blindly. A timeout means the outcome is unknown: the venue may have accepted the order. Query by the original client order ID or reconcile recent orders and fills. Retry only under the venue's documented semantics and keep the same logical intent from producing a second order.
Is a unique client order ID enough to stop duplicate trades?
No. It helps only when it is created before the first send, persisted, reused or queried correctly, and supported by the venue's uniqueness rules. Local races, duplicate signals, expired ID windows, or a retry with a new ID can still create duplicates, so database constraints and exposure reconciliation are also required.