# Как построить торгового бота на Binance API

> Надёжному боту Binance недостаточно корректного запроса ордера. Он должен сохранять торговое намерение, проверять актуальные правила биржи, переживать неизвестный результат исполнения, сверяться после перезапуска и останавливать торговлю при небезопасных данных, лимитах или риске.

- Источник: https://papaproxy.net/ru/blog/binance-trading-bot.php
- Опубликовано: 2026-08-02
- Автор: Alex Young
- Рубрика: Автоматизация · Блог PapaProxy.net

---

## Ключевые выводы

- Безопасность перезапуска требует persistent intent outbox и reconciliation: `newClientOrderId` уникален только среди открытых ордеров и сам по себе не предотвращает все дубли.
- Timeout, `-1007` или `5XX` после отправки означают неизвестное исполнение; до нового POST проверьте User Data Stream и запросите точный client order ID.
- Copy Trading API открывает статус lead trader и whitelist символов с IP weight 1, но не публичный поток для самостоятельного клонирования приватных позиций произвольного трейдера.
- Проверяйте актуальные symbol rules, отделяйте `/order/test` от Spot Testnet, применяйте risk controls и least-privilege keys до включения реальных ордеров.

## Что нужно боту помимо вызова ордера

Бот состоит из независимо отказывающих слоёв. Актуальная документация Spot описывает контракты эндпоинтов, но сохранение намерения и состояния при сетевых сбоях и перезапусках остаётся обязанностью приложения.

**Аутентификация и время.** Подписанные Spot-запросы используют HMAC, RSA или Ed25519 в зависимости от типа API-ключа. Подпись должна рассчитываться по точной строке параметров, которая отправляется. Ошибка `-1022` означает неверную подпись. Ошибка `-1021` относится к timestamp: запрос должен опережать серверные часы меньше чем на одну секунду и оставаться внутри `recvWindow`, равного 5 000 мс по умолчанию и не более 60 000 мс. Binance повторно проверяет окно перед передачей запроса на исполнение.

**Рыночные данные.** Непрерывно меняющиеся значения нужно получать через WebSocket Streams, а не циклом опроса. Бот должен контролировать не только открытое соединение, но и возраст последнего события, непрерывность sequence, processing lag, глубину очереди и состояние ресинхронизации. Устаревшие данные должны останавливать торговлю до создания плохого ордера.

**Валидация ордера.** `exchangeInfo` — runtime-источник статуса символа, order types, permissions и filters. Избыточная точность может вернуть `-1111 BAD_PRECISION`; нарушение `PRICE_FILTER`, `LOT_SIZE`, `MIN_NOTIONAL`, `NOTIONAL` или другого правила может прийти как filter failure, например внутри `-1013`. Нарушения symbol filters — распространённый и предотвращаемый класс отказов, но не единственная причина отклонения.

**Семантика отказов.** HTTP `429` требует backoff и соблюдения `Retry-After`; продолжение запросов может привести к HTTP `418` и IP-бану продолжительностью от двух минут до трёх дней при повторных нарушениях. Timeout, `-1007` или `5XX` на изменяющем состояние запросе означают другое: исполнение могло пройти, хотя клиент не получил результат. Правильное состояние — **неизвестно**, а не «не исполнено».

**Постоянное намерение.** Детерминированный `newClientOrderId` полезен для сверки, но не является полной идемпотентностью. Binance требует его уникальности только среди открытых ордеров и может снова принять тот же ID после исполнения предыдущего. Поэтому безопасность перезапуска требует persistent outbox, куда логическое решение записывается до сетевого вызова.

### Машина состояний ордера

Практический жизненный цикл:

TextКопировать код

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

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

Один переход должен быть запрещён:

TextКопировать код

```text
SUBMISSION_UNKNOWN → новый POST без reconciliation
```

Локальная база отвечает на вопрос «какое торговое решение уже принято?», а Binance — «что произошло с биржевым ордером?». По отдельности ни одна сторона не даёт полной картины.

### Проверка жизненного цикла отказов

До заявления о restart safety или безопасном retry проверьте минимум:

| Сбой | Требуемое поведение |
| --- | --- |
| Процесс остановился до HTTP-запроса | Продолжить с сохранённого intent |
| Соединение оборвалось до отправки байтов | Считать retry допустимым только при доказанном отсутствии отправки |
| Timeout после возможной отправки | Записать `SUBMISSION_UNKNOWN`, не повторять POST |
| HTTP `5XX` или `-1007` | Проверить User Data Stream и запросить ордер по client ID |
| Процесс упал после исполнения, но до локального commit | Найти ордер на Binance и обновить outbox |
| Два worker получили одно решение | Уникальность в базе пропускает только один logical intent |
| Частичное исполнение до разрыва | Сверить executed quantity и остаток |
| Gap или устаревший market stream | Остановить новые ордера до восстановления состояния |
| Filters изменились | Обновить `exchangeInfo`, повторно проверить и запросить новое решение, если экономика изменилась |

## Что на самом деле открывает Copy Trading API

Binance Copy Trading — платформенный сервис. Binance переносит активность lead trader к пользователям, которые выбрали копирование; публичный developer surface не является потоком приватных позиций произвольных трейдеров.

В актуальной документации открыты два lead-trader-oriented эндпоинта:

- `GET /sapi/v1/copyTrading/futures/userStatus` — статус Futures lead trader, IP weight 1;
- `GET /sapi/v1/copyTrading/futures/leadSymbol` — текущий whitelist символов Futures lead trading, IP weight 1.

Для них существуют официальные Python- и Node-коннекторы. Опубликованный API не предоставляет универсальный endpoint для подписки на чужие приватные позиции и самостоятельного их воспроизведения.

Остаются две разные архитектуры:

1. **Автоматизировать собственный lead-trader account.** Стратегия торгует через соответствующий Futures API, а платформа Binance переносит сделки зарегистрированным copiers.
2. **Зеркалировать между принадлежащими вам аккаунтами.** Программа принимает авторизованные события source account, преобразует их в destination intents, применяет собственный sizing и risk rules и отправляет ордера через нужный trading API.

Custom mirror — это не буквальное копирование source order:

TextКопировать код

```text
авторизованные события source account
→ нормализованная позиция или fill
→ преобразование sizing и risk для destination
→ persistent outbox destination
→ Futures order destination
→ сверка fills и позиции
```

Leverage, margin mode, hedge mode, reduce-only, доступный баланс, symbol eligibility и partial fills нужно преобразовывать явно. Generic Spot executor ниже показывает надёжную обвязку ордера, но не выдаётся за реализацию Binance Futures Copy Trading.

## Python-executor с учётом перезапуска

Следующий пример — reference skeleton, а не законченная production-система. Он показывает:

- SQLite outbox с уникальностью logical intent;
- запись состояния до сетевого вызова;
- точный запрос по `origClientOrderId`;
- `SUBMISSION_UNKNOWN` после timeout или server error;
- валидацию без молчаливого изменения цены стратегии;
- разделение validation-only, Spot Testnet и production.

Режим по умолчанию — `validate`: он вызывает production `/api/v3/order/test`. Этот endpoint проверяет запрос, но **не** отправляет ордер в Matching Engine. Для проверки реального жизненного цикла с тестовыми средствами нужен `testnet`. Production включается только явной переменной окружения.

PythonКопировать код

```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:
        # Production must 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)
```

Возможности кода ограничены и обозначены явно:

- duplicate `intent_id` нельзя дважды записать в локальный outbox;
- состояние становится `SUBMITTING` до HTTP-запроса;
- timeout или server error не запускает автоматический второй POST;
- незавершённый intent сверяется после перезапуска;
- цена и количество отклоняются при несоответствии статическим filters, а не молча округляются.

Код не реализует User Data Stream, запрос recent trades, distributed lock между разными базами, portfolio risk, Futures position modes или автоматическое решение после «order not found». Всё это требуется до заявления о полной production readiness.

### Почему цена не округляется автоматически

Округление относится к семантике стратегии, а не только к форматированию. Постоянное округление вниз делает BUY менее агрессивным, но может сделать SELL более агрессивным. Generic executor должен отклонять цену, не кратную `tickSize`, если стратегия не передала явную side-aware normalization policy и не приняла изменение экономики ордера.

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

Режимы не взаимозаменяемы:

| Режим | Что он подтверждает |
| --- | --- |
| Локальный dry-run или mock | Логику ветвления и persistence без обращения к Binance |
| Production `/api/v3/order/test` | Аутентификацию, подпись, параметры и валидацию; ордер не попадает в Matching Engine |
| Spot Testnet `/api/v3/order` | Жизненный цикл ордера в тестовой биржевой среде |
| Production `/api/v3/order` | Реальный ордер и финансовый риск |

Успешный `/order/test` не проверяет fills, open-order uniqueness, восстановление через User Data Stream, unknown execution или restart reconciliation. Эти сценарии нужно проходить на Spot Testnet и через controlled fault injection.

### Официальный connector и ручная подпись

Ручной HMAC-код полезен для понимания протокола. Для поддерживаемого приложения стоит оценить официальный Python connector Binance, покрывающий актуальные REST, WebSocket API и WebSocket Streams. Connector уменьшает объём собственного signing и serialization кода, но не создаёт business-level идемпотентность, outbox, position reconciliation или risk policy — они остаются частью вашей архитектуры.

## Risk controls — отдельный слой

Исправная обвязка способна безошибочно исполнить опасную стратегию. Production-дизайн должен определить минимум:

- максимальное количество и notional ордера;
- максимальную позицию по символу и стратегии;
- дневной лимит realised и unrealised loss;
- максимальный возраст market data;
- максимально допустимый slippage или price deviation;
- максимальное число открытых ордеров;
- сверку баланса и позиции;
- self-trade-prevention policy;
- circuit breaker после серии rejects или unknown executions;
- ручной kill switch;
- процедуру отзыва API-ключа.

Risk layer получает нормализованный intent и может отклонить его до перехода outbox в `READY_TO_SEND`. Версию risk settings нужно сохранять вместе с intent, чтобы позднее восстановить набор правил, разрешивший ордер.

## Что разрешает биржа

Автоматическая торговля — документированный сценарий Binance API, но поведение и permissions зависят от продукта и юридического лица, обслуживающего аккаунт. Правила одной сущности нельзя выдавать за универсальные условия.

Binance.US прямо запрещает false trading и market manipulation, а её terms запрещают активность, создающую необоснованно большую нагрузку на инфраструктуру. Пользователь global Binance или другой региональной сущности должен проверить terms, trading rules и product restrictions именно своего аккаунта.

У встроенных Binance bot products отдельные условия и платформенные controls. Их circuit breakers и kill switches не защищают автоматически код, написанный через API.

Применяйте least privilege:

- оставляйте execution key только необходимые permissions;
- при необходимости разделяйте `TRADE` и `USER_DATA` по разным ключам;
- не включайте withdrawal permission торговому боту;
- настройте IP whitelisting на ключах;
- храните secrets вне исходного кода;
- поддерживайте rotation и немедленный revoke.

Request weight применяется по IP, а unfilled-order limits — по аккаунту. Стабильный egress упрощает IP whitelisting, воспроизводимые incident logs и контролируемый redeploy. Он не заменяет общий rate limiter, circuit breaker и соблюдение `Retry-After`.

В этом состоит уместная роль наших [прокси для Binance](/target/binance-proxy-server/): выделенные IPv4, зарезервированные только для вас на срок плана и дающие предсказуемый egress для IP whitelisting и действительно независимых workloads. Они не предназначены для обхода rate limits, банов или требований платформы.

## Production-метрики

Полезный набор:

TextКопировать код

```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}
```

Настройте alerts для unknown submissions, долгой reconciliation, устаревших market data, ошибок обновления filters, насыщения rate limits, расхождения позиций и любого перехода в manual review.
