Products
Datacenter proxies from $19/mo Rotating proxies from $49/mo ISP proxies from $33/mo Dedicated proxies from $3.50/mo UDP proxies from $5/mo Try proxies
Use cases
Data & scraping AI services Social media & messaging E-commerce & finance Media & entertainment Marketing & ads Automation & tools All use cases →
Pricing
Full pricing table All 20 locations Money-back guarantee
Resources
Blog Proxy API MCP server Setup guides FAQ For business About us Affiliate program
English
English Русский
← PapaProxy.net Blog

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.

Automation · API & data August 2, 2026 9 min read Alex Young Alex Young Technical specialist
Key takeawaysRestart safety requires a persistent intent outbox and reconciliation; newClientOrderId is unique only among open orders and cannot prevent every duplicate by itself.
  • 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.

This summary was created with AI.

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:

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:

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:

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.

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: 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:

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.