# How to build a trading bot on the Binance API

> A reliable Binance bot needs more than a valid order request. It must preserve trading intent, validate current exchange rules, survive unknown execution results, reconcile after restarts, and stop itself when market data, limits, or risk controls become unsafe.

- Source: https://papaproxy.net/blog/binance-trading-bot.php
- Published: 2026-08-02
- Author: Alex Young
- Category: Automation · PapaProxy.net Blog

---

## Key takeaways

- Restart safety requires a persistent intent outbox and reconciliation; `newClientOrderId` is unique only among open orders and cannot prevent every duplicate by itself.
- A timeout, `-1007`, or `5XX` after order submission means execution may be unknown; check User Data Stream and query the exact client order ID before another POST.
- Copy Trading exposes lead-trader status and symbol-whitelist endpoints at IP weight 1, but not a public feed for independently cloning arbitrary traders’ private positions.
- Validate current symbol rules, separate `/order/test` from Spot Testnet, enforce risk controls, and use least-privilege keys before enabling real orders.

## What a bot needs beyond an order call

A bot is a set of independently failing layers. The current Spot trading bot documentation gives you endpoint contracts, but the application still has to preserve intent and state across network failures and process restarts.

**Authentication and timing.** Signed Spot requests use HMAC, RSA, or Ed25519, depending on the API-key type. The signature must cover the exact encoded parameters that are sent. Error `-1022` indicates an invalid signature. Error `-1021` indicates a timestamp problem: the request must be less than one second ahead of the server clock and remain inside `recvWindow`, which defaults to 5,000 ms and cannot exceed 60,000 ms. Binance checks the window again before forwarding the request for execution.

**Market data.** Values that change continuously belong on WebSocket Streams rather than a polling loop. A bot must monitor not only whether the socket is open, but also the age of the last event, sequence continuity where defined, processing lag, queue depth, and resynchronization state. Stale market data should trip a trading guard before it becomes a bad order.

**Order validation.** `exchangeInfo` is the runtime source for symbol status, supported order types, permissions, and filters. Excess precision can return `-1111 BAD_PRECISION`; a value that violates `PRICE_FILTER`, `LOT_SIZE`, `MIN_NOTIONAL`, `NOTIONAL`, or another rule can return a filter failure such as `-1013`. Symbol-filter violations are common and preventable, but they are not the only reason an order can be rejected.

**Failure semantics.** HTTP `429` requires backoff and respect for `Retry-After`; continued traffic can lead to HTTP `418`, an IP ban lasting from two minutes to three days for repeat violations. A timeout, `-1007`, or `5XX` on a state-changing request is different: execution may have succeeded even though the client did not receive the result. The correct state is **unknown**, not failed.

**Persistent intent.** A deterministic `newClientOrderId` is useful for reconciliation, but it is not a complete idempotency mechanism. Binance requires it to be unique only among open orders and can accept the same value again after the previous order is filled. Restart safety therefore requires a persistent outbox that records the logical decision before any network call.

### The order state machine

A practical order lifecycle is:

TextCopy code

```text
INTENT_CREATED
→ VALIDATED
→ READY_TO_SEND
→ SUBMITTING
→ ACKNOWLEDGED
→ PARTIALLY_FILLED
→ FILLED / CANCELED / EXPIRED / REJECTED

SUBMITTING
→ SUBMISSION_UNKNOWN
→ RECONCILING
→ ACKNOWLEDGED / TERMINAL / MANUAL_REVIEW
```

One transition must be forbidden:

TextCopy code

```text
SUBMISSION_UNKNOWN → new POST without reconciliation
```

The local database answers, “Which trading decision did the strategy already make?” Binance answers, “What happened to the exchange order?” Neither system alone is sufficient.

### Failure-lifecycle checklist

Before calling an executor restart-safe or safe to retry, test at least these cases:

| Failure | Required behavior |
| --- | --- |
| Process stops before the HTTP request | Resume from the persisted intent |
| Connection fails before sending bytes | Mark retryable only when the transport proves nothing was sent |
| Timeout after bytes may have been sent | Mark `SUBMISSION_UNKNOWN`; do not submit again |
| HTTP `5XX` or `-1007` | Check User Data Stream and query by client order ID |
| Process stops after execution but before local commit | Recover the order from Binance and update the outbox |
| Two workers receive the same decision | Database uniqueness allows only one logical intent |
| Partial fill before disconnect | Reconcile executed quantity and remaining state |
| Market-data gap or stale stream | Stop new orders until state is rebuilt |
| Filter metadata changes | Refresh `exchangeInfo`, revalidate, and require a new decision if economics change |

## What the Copy Trading API actually exposes

Binance Copy Trading is a platform service. Binance propagates a lead trader’s activity to users who elected to copy it; the public developer surface is not a stream of arbitrary traders’ live positions.

The current copy trading API documentation exposes a small lead-trader-oriented REST surface:

- `GET /sapi/v1/copyTrading/futures/userStatus` — whether the account is a Futures lead trader, IP weight 1;
- `GET /sapi/v1/copyTrading/futures/leadSymbol` — the current Futures lead-trading symbol whitelist, IP weight 1.

Official Python and Node connectors exist for these endpoints. The published API does not provide a general endpoint that lets a client subscribe to another trader’s private positions and independently reproduce them.

That leaves two different architectures:

1. **Automate your own lead-trader account.** Your strategy trades through the applicable Futures API, while Binance’s Copy Trading platform handles propagation to enrolled copiers.
2. **Mirror between accounts you control.** Your software receives authorized source-account events, converts them into destination intents, applies destination-specific sizing and risk rules, and submits orders through the relevant trading API.

A custom mirror is not “copy the source order verbatim.” It needs:

TextCopy code

```text
authorized source events
→ normalized source position or fill
→ destination sizing and risk transformation
→ destination persistent outbox
→ destination Futures order
→ fill and position reconciliation
```

Futures details such as leverage, margin mode, hedge mode, reduce-only behavior, available balance, symbol eligibility, and partial fills must be transformed explicitly. The generic Spot executor in the next section demonstrates durable order plumbing; it is not presented as an implementation of Binance Futures Copy Trading.

## A restart-aware Python order executor

The following Python trading bot example is a reference skeleton, not a complete production system. It demonstrates:

- a SQLite outbox with uniqueness at the logical-intent level;
- state written before the network call;
- exact lookup by `origClientOrderId`;
- `SUBMISSION_UNKNOWN` after a timeout or server error;
- validation without silently changing the strategy’s price;
- explicit separation of validation-only, Spot Testnet, and production modes.

The default mode is `validate`, which calls production `/api/v3/order/test`. That endpoint validates the request but does **not** send an order to the Matching Engine. Use `testnet` to exercise actual order lifecycle behavior with test funds. Production mode requires an explicit environment setting.

PythonCopy code

```python
from __future__ import annotations

import hashlib
import hmac
import json
import os
import sqlite3
import time
from dataclasses import dataclass
from decimal import Decimal
from typing import Any
from urllib.parse import urlencode

import requests

MODE = os.getenv("BINANCE_MODE", "validate").lower()
if MODE not in {"validate", "testnet", "production"}:
    raise ValueError("BINANCE_MODE must be validate, testnet, or production")

BASE_URLS = {
    "validate": "https://api.binance.com",
    "testnet": "https://testnet.binance.vision",
    "production": "https://api.binance.com",
}
ORDER_PATHS = {
    "validate": "/api/v3/order/test",
    "testnet": "/api/v3/order",
    "production": "/api/v3/order",
}

BASE_URL = BASE_URLS[MODE]
ORDER_PATH = ORDER_PATHS[MODE]
API_KEY = os.environ["BINANCE_API_KEY"]
API_SECRET = os.environ["BINANCE_API_SECRET"].encode()
DB_PATH = os.getenv("BOT_DB_PATH", "orders.sqlite3")
RECV_WINDOW_MS = 5_000

session = requests.Session()
session.headers["X-MBX-APIKEY"] = API_KEY

@dataclass(frozen=True)
class LimitIntent:
    intent_id: str
    client_order_id: str
    symbol: str
    side: str
    quantity: Decimal
    price: Decimal

def connect_db() -> sqlite3.Connection:
    db = sqlite3.connect(DB_PATH)
    db.row_factory = sqlite3.Row
    db.execute("PRAGMA journal_mode=WAL")
    db.execute(
        """
        CREATE TABLE IF NOT EXISTS order_intents (
            intent_id TEXT PRIMARY KEY,
            client_order_id TEXT NOT NULL UNIQUE,
            symbol TEXT NOT NULL,
            side TEXT NOT NULL,
            quantity TEXT NOT NULL,
            price TEXT NOT NULL,
            state TEXT NOT NULL,
            exchange_order_id INTEGER,
            exchange_status TEXT,
            last_error TEXT,
            created_at_ms INTEGER NOT NULL,
            updated_at_ms INTEGER NOT NULL
        )
        """
    )
    return db

def now_ms() -> int:
    return time.time_ns() // 1_000_000

def signed_params(params: dict[str, Any]) -> dict[str, Any]:
    signed = dict(params)
    signed["timestamp"] = now_ms()
    signed["recvWindow"] = RECV_WINDOW_MS
    query = urlencode(signed)
    signed["signature"] = hmac.new(
        API_SECRET,
        query.encode(),
        hashlib.sha256,
    ).hexdigest()
    return signed

def request_json(
    method: str,
    path: str,
    *,
    params: dict[str, Any],
    timeout: float = 10.0,
) -> tuple[requests.Response, dict[str, Any]]:
    response = session.request(
        method,
        f"{BASE_URL}{path}",
        params=signed_params(params),
        timeout=timeout,
    )
    try:
        payload = response.json()
    except ValueError:
        payload = {"msg": response.text[:500]}
    return response, payload

def decimal_multiple(value: Decimal, step: Decimal) -> bool:
    if step == 0:
        return True
    return value % step == 0

def validate_range(
    *,
    name: str,
    value: Decimal,
    minimum: Decimal,
    maximum: Decimal,
    step: Decimal,
) -> None:
    if minimum != 0 and value < minimum:
        raise ValueError(f"{name} {value} is below minimum {minimum}")
    if maximum != 0 and value > maximum:
        raise ValueError(f"{name} {value} is above maximum {maximum}")
    if not decimal_multiple(value, step):
        raise ValueError(f"{name} {value} is not aligned to step {step}")

def load_symbol_rules(symbol: str) -> dict[str, Any]:
    response = session.get(
        f"{BASE_URL}/api/v3/exchangeInfo",
        params={"symbol": symbol},
        timeout=5,
    )
    response.raise_for_status()
    payload = response.json()
    symbols = payload.get("symbols", [])
    if len(symbols) != 1:
        raise ValueError(f"symbol {symbol} was not returned by exchangeInfo")
    return symbols[0]

def validate_limit_intent(intent: LimitIntent) -> None:
    rules = load_symbol_rules(intent.symbol)
    if rules["status"] != "TRADING":
        raise ValueError(f"{intent.symbol} status is {rules['status']}")
    if "LIMIT" not in rules.get("orderTypes", []):
        raise ValueError(f"LIMIT orders are not enabled for {intent.symbol}")

    filters = {
        item["filterType"]: item
        for item in rules.get("filters", [])
    }

    price_filter = filters["PRICE_FILTER"]
    lot_filter = filters["LOT_SIZE"]

    validate_range(
        name="price",
        value=intent.price,
        minimum=Decimal(price_filter["minPrice"]),
        maximum=Decimal(price_filter["maxPrice"]),
        step=Decimal(price_filter["tickSize"]),
    )
    validate_range(
        name="quantity",
        value=intent.quantity,
        minimum=Decimal(lot_filter["minQty"]),
        maximum=Decimal(lot_filter["maxQty"]),
        step=Decimal(lot_filter["stepSize"]),
    )

    notional = intent.price * intent.quantity

    if "MIN_NOTIONAL" in filters:
        minimum = Decimal(filters["MIN_NOTIONAL"]["minNotional"])
        if notional < minimum:
            raise ValueError(f"notional {notional} is below {minimum}")

    if "NOTIONAL" in filters:
        item = filters["NOTIONAL"]
        minimum = Decimal(item["minNotional"])
        maximum = Decimal(item["maxNotional"])
        if notional < minimum or (maximum != 0 and notional > maximum):
            raise ValueError(
                f"notional {notional} is outside {minimum}..{maximum}"
            )

    # Account-dependent and dynamic filters remain authoritative on Binance:
    # PERCENT_PRICE(_BY_SIDE), MAX_POSITION, MAX_NUM_ORDERS, and others.
    # Do not claim local validation proves that the order will be accepted.

def persist_intent(db: sqlite3.Connection, intent: LimitIntent) -> None:
    timestamp = now_ms()
    with db:
        db.execute(
            """
            INSERT INTO order_intents (
                intent_id, client_order_id, symbol, side,
                quantity, price, state, created_at_ms, updated_at_ms
            ) VALUES (?, ?, ?, ?, ?, ?, 'INTENT_CREATED', ?, ?)
            ON CONFLICT(intent_id) DO NOTHING
            """,
            (
                intent.intent_id,
                intent.client_order_id,
                intent.symbol,
                intent.side,
                str(intent.quantity),
                str(intent.price),
                timestamp,
                timestamp,
            ),
        )

def set_state(
    db: sqlite3.Connection,
    intent_id: str,
    state: str,
    *,
    exchange_order_id: int | None = None,
    exchange_status: str | None = None,
    error: str | None = None,
) -> None:
    with db:
        db.execute(
            """
            UPDATE order_intents
            SET state = ?,
                exchange_order_id = COALESCE(?, exchange_order_id),
                exchange_status = COALESCE(?, exchange_status),
                last_error = ?,
                updated_at_ms = ?
            WHERE intent_id = ?
            """,
            (
                state,
                exchange_order_id,
                exchange_status,
                error,
                now_ms(),
                intent_id,
            ),
        )

def query_order(
    symbol: str,
    client_order_id: str,
) -> dict[str, Any] | None:
    response, payload = request_json(
        "GET",
        "/api/v3/order",
        params={
            "symbol": symbol,
            "origClientOrderId": client_order_id,
        },
    )
    if response.ok:
        return payload
    if payload.get("code") == -2013:
        return None
    response.raise_for_status()
    return None

def reconcile(
    db: sqlite3.Connection,
    intent_id: str,
) -> None:
    row = db.execute(
        "SELECT * FROM order_intents WHERE intent_id = ?",
        (intent_id,),
    ).fetchone()
    if row is None:
        raise KeyError(intent_id)

    set_state(db, intent_id, "RECONCILING")

    try:
        order = query_order(row["symbol"], row["client_order_id"])
    except requests.RequestException as exc:
        set_state(
            db,
            intent_id,
            "SUBMISSION_UNKNOWN",
            error=f"reconciliation failed: {type(exc).__name__}: {exc}",
        )
        return

    if order is None:
        # A production implementation should also wait for User Data Stream
        # and query recent trades before deciding that the order never existed.
        set_state(
            db,
            intent_id,
            "MANUAL_REVIEW",
            error="order not found; do not resubmit automatically",
        )
        return

    status = str(order.get("status", "UNKNOWN"))
    terminal = status in {
        "FILLED", "CANCELED", "REJECTED", "EXPIRED",
        "EXPIRED_IN_MATCH",
    }
    state = status if terminal else "ACKNOWLEDGED"
    set_state(
        db,
        intent_id,
        state,
        exchange_order_id=order.get("orderId"),
        exchange_status=status,
    )

def submit(
    db: sqlite3.Connection,
    intent: LimitIntent,
) -> None:
    persist_intent(db, intent)
    row = db.execute(
        "SELECT * FROM order_intents WHERE intent_id = ?",
        (intent.intent_id,),
    ).fetchone()
    if row is None:
        raise RuntimeError("intent was not persisted")

    if row["state"] in {
        "ACKNOWLEDGED", "PARTIALLY_FILLED", "FILLED",
        "CANCELED", "REJECTED", "EXPIRED", "EXPIRED_IN_MATCH",
        "VALIDATED", "MANUAL_REVIEW",
    }:
        return

    if row["state"] in {"SUBMITTING", "SUBMISSION_UNKNOWN", "RECONCILING"}:
        reconcile(db, intent.intent_id)
        return

    validate_limit_intent(intent)
    set_state(db, intent.intent_id, "VALIDATED")

    order_params = {
        "symbol": intent.symbol,
        "side": intent.side,
        "type": "LIMIT",
        "timeInForce": "GTC",
        "quantity": str(intent.quantity),
        "price": str(intent.price),
        "newClientOrderId": intent.client_order_id,
    }

    set_state(db, intent.intent_id, "SUBMITTING")

    try:
        response, payload = request_json(
            "POST",
            ORDER_PATH,
            params=order_params,
        )
    except (requests.Timeout, requests.ConnectionError) as exc:
        set_state(
            db,
            intent.intent_id,
            "SUBMISSION_UNKNOWN",
            error=f"{type(exc).__name__}: {exc}",
        )
        return

    if MODE == "validate" and response.ok:
        set_state(db, intent.intent_id, "VALIDATED")
        return

    if response.status_code >= 500 or payload.get("code") == -1007:
        set_state(
            db,
            intent.intent_id,
            "SUBMISSION_UNKNOWN",
            error=json.dumps(payload),
        )
        return

    if response.status_code in {418, 429}:
        set_state(
            db,
            intent.intent_id,
            "REJECTED",
            error=(
                f"rate limit response; Retry-After="
                f"{response.headers.get('Retry-After')}"
            ),
        )
        return

    if not response.ok:
        set_state(
            db,
            intent.intent_id,
            "REJECTED",
            error=json.dumps(payload),
        )
        return

    set_state(
        db,
        intent.intent_id,
        "ACKNOWLEDGED",
        exchange_order_id=payload.get("orderId"),
        exchange_status=str(payload.get("status", "NEW")),
    )

def reconcile_incomplete(db: sqlite3.Connection) -> None:
    rows = db.execute(
        """
        SELECT intent_id
        FROM order_intents
        WHERE state IN ('SUBMITTING', 'SUBMISSION_UNKNOWN', 'RECONCILING')
        """
    ).fetchall()
    for row in rows:
        reconcile(db, row["intent_id"])

if __name__ == "__main__":
    if MODE == "production" and os.getenv("ALLOW_REAL_ORDERS") != "YES":
        raise RuntimeError(
            "Set ALLOW_REAL_ORDERS=YES only after testnet validation"
        )

    database = connect_db()
    reconcile_incomplete(database)

    example = LimitIntent(
        intent_id="strategy-1:decision-20260802-0001",
        client_order_id="s1-20260802-0001",
        symbol="BTCUSDT",
        side="BUY",
        quantity=Decimal("0.00100000"),
        price=Decimal("50000.00000000"),
    )
    submit(database, example)
```

What this code guarantees is limited and explicit:

- a duplicate `intent_id` cannot be inserted twice into the local outbox;
- the state becomes `SUBMITTING` before the HTTP request;
- a timeout or server error cannot trigger an automatic second POST;
- an incomplete intent is reconciled on restart;
- the strategy’s price and quantity are rejected if they do not match the static filters instead of being silently rounded.

It does **not** implement User Data Stream, recent-trade lookup, distributed locking across multiple databases, portfolio risk, Futures position modes, or a final automated decision after “order not found.” Those are required before calling the whole bot production-ready.

### Why the code does not round automatically

Rounding is part of strategy semantics, not merely formatting. Always rounding price down makes a BUY order less aggressive but can make a SELL order more aggressive. A generic executor should reject a price that is not aligned to `tickSize`, unless the strategy has supplied an explicit side-aware normalization policy and accepted the changed economics.

### Validation endpoint, Spot Testnet, dry-run, and production

These modes are not interchangeable:

| Mode | What it proves |
| --- | --- |
| Local dry-run or mock | Application branching and persistence without contacting Binance |
| Production `/api/v3/order/test` | Authentication, signature, parameters, and validation; no Matching Engine order |
| Spot Testnet `/api/v3/order` | Exchange-like order lifecycle with test assets |
| Production `/api/v3/order` | Real order and financial exposure |

A successful `/order/test` call cannot demonstrate fills, open-order uniqueness, User Data Stream recovery, unknown execution, or restart reconciliation. Exercise those paths on Spot Testnet and through controlled fault injection before production.

### Official connector versus manual signing

Manual HMAC code is useful for understanding the protocol. For a maintained application, evaluate Binance’s official Python connector, which covers current REST, WebSocket API, and WebSocket Streams interfaces. A connector reduces custom signing and serialization code, but it does not provide your business-level idempotency, outbox, position reconciliation, or risk policy. Keep those in your own architecture.

## Risk controls are a separate layer

Correct plumbing can still execute a dangerous strategy perfectly. A production design should define at least:

- maximum order quantity and notional;
- maximum position by symbol and strategy;
- daily realized and unrealized loss limits;
- maximum accepted market-data age;
- maximum slippage or price deviation;
- maximum number of open orders;
- balance and position reconciliation;
- self-trade-prevention policy;
- circuit breaker after repeated rejects or unknown executions;
- manual kill switch;
- API-key revocation procedure.

The risk layer consumes normalized intent and can reject it before the outbox reaches `READY_TO_SEND`. Risk settings should be versioned and recorded with the intent so that a later investigation can reconstruct the rule set that approved the order.

## What the exchange allows

Trading automation is a documented Binance API use case, but conduct and permissions depend on the product and legal entity serving the account. Do not use one entity’s rules as universal terms of use for trading bots.

Binance.US explicitly prohibits false trading and market manipulation, and its terms prohibit activity that imposes an unreasonable or disproportionately large load on its infrastructure. A user of global Binance or another regional entity must check the terms, trading rules, and product restrictions governing that specific account.

Built-in Binance bot products have separate product terms and platform controls. Their circuit breakers or kill switches do not automatically protect software written against the API.

Use least-privilege credentials:

- give the execution key only the permissions it needs;
- consider separate `TRADE` and `USER_DATA` keys;
- keep withdrawal permission disabled for a trading bot;
- use IP whitelisting on keys;
- store secrets outside source code;
- support rotation and immediate revocation.

Request weight is enforced by IP, while unfilled-order limits are maintained per account. A stable egress address helps with IP whitelisting, reproducible incident logs, and controlled redeployment. It does not replace a shared rate limiter, circuit breaker, or compliance with `Retry-After`.

That is the relevant role of our [proxies for Binance](/target/binance-proxy-server/): dedicated IPv4 addresses reserved exclusively for you for the plan term, providing predictable egress for IP-whitelisted and genuinely independent workloads. They are not a mechanism for bypassing rate limits, bans, or platform eligibility.

## Production metrics

Useful metrics include:

TextCopy code

```text
trading_intent_total{strategy}
trading_intent_state_total{strategy,state}
order_submission_total{strategy,status}
order_submission_unknown_total{strategy}
order_reconciliation_duration_ms{strategy}
order_manual_review_total{strategy,reason}
duplicate_intent_blocked_total{strategy}
order_reject_total{symbol,code,filter}
user_data_stream_lag_ms{account}
market_data_age_ms{stream}
market_data_gap_total{stream}
symbol_filters_age_seconds{symbol}
rate_limit_usage{type,interval,ip}
open_order_count{account,symbol}
open_position_notional{strategy,symbol}
risk_reject_total{strategy,rule}
circuit_breaker_state{strategy}
```

Alert on unknown submissions, long reconciliation, stale market data, filter refresh failures, rate-limit saturation, position drift, and any transition to manual review.
