
A copy-trading bot is not just a script that repeats another account's orders. It is a small trading system that must decide whether a signal is valid, translate it into a permitted order, survive partial failures, and leave enough evidence to explain what happened later.
The safest first version does not place live orders. It records signals, calculates the order it would have sent, and compares that decision with actual market data. Only after those checks are repeatable should you connect a test environment. Live trading comes last, if it comes at all.
This guide explains software architecture and testing. It is not investment advice. Crypto assets can be volatile, platforms can fail, and technical errors can cause losses. The SEC's Investor.gov crypto alert describes volatility, illiquidity, platform failure, fraud, and technical problems among the material risks.
What a copy-trading bot actually has to do
A basic system has six jobs:
- Receive a source trade or strategy signal.
- Validate the account, market, side, quantity, price, and timestamp.
- Apply the follower's own risk policy.
- Convert the approved intent into an exchange-specific order.
- Reconcile the submitted order with fills and account state.
- Record every decision, rejection, submission, and result.
Treat each job as a separate boundary. If a webhook handler can also sign an exchange order and write arbitrary database state, one malformed event can reach too far.
Start with an explicit signal contract
Do not copy the source exchange's payload directly into an order request. Convert it to a small internal schema first:
{
"signal_id": "sig_01K5...",
"source_account": "strategy_alpha",
"market": "BTC/USDT",
"side": "buy",
"order_type": "limit",
"source_quantity": "0.002",
"source_price": "62000.00",
"observed_at": "2026-08-22T10:15:30Z"
}
Validation should reject an event when:
- the signal ID is missing or has already been processed
- the source account is not allowlisted
- the market or order type is not permitted
- the timestamp is too old
- quantity or price is zero, negative, or outside a configured bound
- the event cannot be authenticated
Use decimal arithmetic for prices and quantities. Binary floating-point values can introduce rounding errors, and exchanges enforce market-specific increments.
Keep the follower's policy separate from the source trade
The follower should not inherit the source account's position size. It needs an independent policy that can reject or resize a signal.
A useful policy record contains:
- allowed markets
- maximum order value
- maximum total exposure per asset
- maximum number of open orders
- maximum signal age
- acceptable price deviation from a fresh quote
- daily loss or drawdown stop
- whether selling an asset not already held is prohibited
- a global enabled or disabled flag
The policy output should be a decision, not an order:
{
"signal_id": "sig_01K5...",
"decision": "approved",
"approved_quantity": "0.0003",
"reason": "within configured exposure and price limits",
"policy_version": "2026-08-22.1"
}
Saving the policy version matters. Without it, a later audit cannot tell which limits were active when an order was approved.
Make order submission idempotent
Networks time out. Webhooks retry. Workers restart. A timeout does not prove that an exchange rejected an order.
Binance's current Spot API documentation explicitly notes that a matching-engine timeout can leave the execution status unknown and tells integrators to query the order status rather than assume failure. The same defensive principle applies to any venue. Review the exact behavior in the exchange's current official API documentation before implementation.
Create one stable client order ID from the follower account and signal ID. Store the intent before calling the exchange. On retry:
- Read the stored intent.
- Query the venue using the client order ID when supported.
- Reuse the known result if the order exists.
- Submit only when the earlier attempt is proven not to exist.
The database state can look like this:
received -> validated -> approved -> submitting -> submitted
| |
v v
rejected partly_filled
|
v
filled
Do not jump directly from approved to filled. Partial fills, cancellations, expiry, and unknown states are normal cases that need their own handling.
Put the exchange behind an adapter
An exchange adapter keeps venue details out of the signal and policy layers. Its interface can stay small:
class ExecutionAdapter:
def get_market_rules(self, symbol): ...
def get_quote(self, symbol): ...
def preview_order(self, order): ...
def submit_order(self, order): ...
def get_order(self, client_order_id): ...
def cancel_order(self, client_order_id): ...
The adapter is responsible for:
- authentication and request signing
- symbol mapping
- price and quantity increments
- minimum order rules
- request limits and backoff
- venue-specific error translation
- order and fill reconciliation
If you use CCXT, check support per exchange rather than assuming every method behaves identically. The official CCXT manual documents the unified API and says sandbox mode, when supported, must be enabled immediately after creating the exchange instance and before any other call.
import ccxt
exchange = ccxt.binance({
"apiKey": "TESTNET_KEY_FROM_SECRET_STORE",
"secret": "TESTNET_SECRET_FROM_SECRET_STORE",
})
exchange.set_sandbox_mode(True)
Never store API secrets in source control, screenshots, logs, or article examples. Use a secret manager or deployment environment. Give the key only the permissions the bot requires and do not enable withdrawals.
Test in layers, not with one successful demo
One order that succeeds proves very little. Test the system in layers.
1. Offline unit tests
Test schema validation, decimal rounding, exposure calculations, stale signals, duplicate IDs, and policy versioning without any external API.
2. Recorded adapter tests
Feed saved API responses into the adapter. Include rejected orders, rate limits, partial fills, timeouts, and malformed responses.
3. Dry-run mode
Read current market data but write the proposed order to an audit table instead of submitting it. Compare the proposed price and quantity with the source signal and the configured limits.
4. Sandbox or testnet
Use a venue's non-production environment when it represents the behavior you need. Read its limitations carefully. Coinbase, for example, states that its Advanced Trade sandbox returns static, predefined responses. That makes it useful for response-shape and error-path testing, but it does not reproduce live liquidity or execution.
5. Failure injection
Deliberately test:
- duplicate delivery of the same signal
- a timeout after order submission
- stale or unavailable quote data
- a partial fill followed by cancellation
- exchange rate limiting
- database unavailability
- worker restart during submission
- the global kill switch while work is queued
For every test, define the expected state, whether another order may be sent, and what alert should fire.
Build a kill switch that does not depend on the worker
A kill switch should prevent new submissions even if a queue contains approved work. Store the switch in a system the execution worker reads immediately before submission, not only when it starts.
Use at least two controls:
- a software flag that blocks new orders
- the ability to revoke or disable the exchange API key
Decide separately whether activating the switch cancels open orders. Automatic cancellation can reduce one risk while creating another, so make that behavior explicit and test it.
Log decisions without leaking secrets
An audit record should answer:
- Which signal caused this action?
- Which follower and policy version were used?
- What market data was checked?
- What decision was made, and why?
- Which client order ID was sent?
- What response and later fills were observed?
- Which software version processed the event?
Do not log private keys, API secrets, full authentication headers, or unnecessary personal data. Restrict audit-log access and define a retention period.
Monitor the states that can lose money
Traffic and CPU graphs are not enough. Alert on conditions tied to trading state:
- an order remains
submittingorunknowntoo long - the exchange position differs from the internal ledger
- a signal is accepted after its maximum age
- order value exceeds the configured limit
- repeated authentication or rate-limit errors occur
- the data feed is stale
- the policy or kill-switch store cannot be read
A daily reconciliation should compare exchange balances, positions, open orders, and fills with the internal ledger. Stop new submissions when reconciliation cannot complete reliably.
A practical release checklist
Before any live use, confirm all of the following:
- Signal requests are authenticated and replay-resistant.
- Duplicate delivery cannot create duplicate orders.
- Quantity and price use decimal arithmetic and current market rules.
- The follower has independent exposure and order limits.
- A timeout enters an unknown state and triggers reconciliation.
- Partial fills and cancellations are represented explicitly.
- Secrets are outside the repository and logs.
- Withdrawal permission is disabled on the API key.
- Dry-run and non-production failure tests are recorded.
- The kill switch is checked immediately before submission.
- A human can inspect and reconcile every submitted order.
The main engineering lesson is simple: copying the signal is the easy part. The real system is the set of controls around that signal. Build those controls first, prove them without live funds, and keep the execution boundary small.
If you need help mapping an API workflow, defining its failure states, and documenting the handoff, see my automation consulting service. For general automation architecture questions, book a project call.