Ключевые выводыБезопасность перезапуска требует persistent intent outbox и reconciliation: newClientOrderId уникален только среди открытых ордеров и сам по себе не предотвращает все дубли.
- Безопасность перезапуска требует 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, куда логическое решение записывается до сетевого вызова.
Машина состояний ордера
Практический жизненный цикл:
INTENT_CREATED
→ VALIDATED
→ READY_TO_SEND
→ SUBMITTING
→ ACKNOWLEDGED
→ PARTIALLY_FILLED
→ FILLED / CANCELED / EXPIRED / REJECTED
SUBMITTING
→ SUBMISSION_UNKNOWN
→ RECONCILING
→ ACKNOWLEDGED / TERMINAL / MANUAL_REVIEW
Один переход должен быть запрещён:
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 для подписки на чужие приватные позиции и самостоятельного их воспроизведения.
Остаются две разные архитектуры:
- Автоматизировать собственный lead-trader account. Стратегия торгует через соответствующий Futures API, а платформа Binance переносит сделки зарегистрированным copiers.
- Зеркалировать между принадлежащими вам аккаунтами. Программа принимает авторизованные события source account, преобразует их в destination intents, применяет собственный sizing и risk rules и отправляет ордера через нужный trading API.
Custom mirror — это не буквальное копирование source order:
авторизованные события 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 включается только явной переменной окружения.
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: выделенные IPv4, зарезервированные только для вас на срок плана и дающие предсказуемый egress для IP whitelisting и действительно независимых workloads. Они не предназначены для обхода rate limits, банов или требований платформы.
Production-метрики
Полезный набор:
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.