Контур /api/v1 предназначен для ботов и внешних сервисов: рыночные данные без
ключа, приватные ручки — по ключу с HMAC-подписью каждого запроса.
Машиночитаемое описание всех ручек — справочник ручек,
он же спека public-v1.ru.yaml (OpenAPI 3.0.3):
её можно скормить openapi-generator и получить готовый клиент. Здесь — то,
чего в спеке нет: как подписать запрос, как устроены лимиты и где контур ведёт
себя не так, как кажется на первый взгляд.
Ключи не для браузера. CORS на
/api/v1выключен намеренно, и включать его не планируется: секрет, попавший во фронтенд, утекает вместе со страницей — его увидит и расширение браузера, и любой, кому пользователь покажет DevTools. Контур строго server-to-server. Веб-интерфейсу нужен приватный/api/*под сессией, а не ключ.
1. Ключ и секрет
Ключи выдаются в личном кабинете, на странице API-ключи.
Под ней — ручки приватного контура, работающие под сессией (Sanctum, cookie),
а не через /api/v1:
| Метод и путь | Что делает |
|---|---|
GET /api/api-keys |
Список ваших ключей (без секретов) |
POST /api/api-keys |
Создать ключ; единственный ответ, где есть секрет |
PUT /api/api-keys/{id} |
Переименовать, поправить ip_allowlist / expires_at |
DELETE /api/api-keys/{id} |
Отозвать (идемпотентно) |
Создание:
POST /api/api-keys
{
"name": "market-maker-1",
"scopes": ["read", "trade"], // read | trade | withdraw
"ip_allowlist": ["203.0.113.7"], // опционально; для withdraw — обязательно
"expires_at": null, // ISO-8601 или null
"code": "123456" // TOTP: нужен для trade/withdraw-ключа
}
201 Created
{
"key": { "id": 7, "public_id": "ek_9f1c…", "scopes": ["read","trade"], "is_active": true, … },
"secret": "…",
"secret_shown_once": true
}
Что важно знать заранее:
secretпоказывается ровно один раз. Ручки «показать секрет ещё раз» не существует и не будет: иначе угнанная сессия ЛК давала бы доступ ко всем ранее выданным ключам. Потеряли — отзывайте ключ и выпускайте новый.- Скоупы неизменяемы.
PUTсоscopesвернёт 422. Нужен другой набор прав — новый ключ. - Выдача
trade/withdraw-ключа требует второго фактора (TOTP, а если 2FA выключена — пароля). Ключ только на чтение — без подтверждения. withdraw-ключ невозможно выпустить без 2FA и без непустогоip_allowlist— см. раздел 8.- Активных (неотозванных) ключей на аккаунт — не больше
PUBLIC_API_MAX_KEYS(по умолчанию 10), иначе 422key_limit_reached. - Ключи привязаны к
APP_KEYприложения: её ротация делает все секреты нерасшифровываемыми, и все ключи придётся перевыпустить.
2. Подпись запроса
Каждый приватный запрос несёт три заголовка (плюс один необязательный):
| Заголовок | Значение |
|---|---|
X-API-KEY |
public_id ключа (ek_…) |
X-API-TIMESTAMP |
Время клиента в миллисекундах epoch |
X-API-SIGNATURE |
HMAC-SHA256, hex в нижнем регистре |
X-API-RECV-WINDOW |
Окно приёма в мс: по умолчанию 5000, максимум 60000 |
Каноническая строка
canonical = TIMESTAMP + "\n" + METHOD + "\n" + PATH + "\n" + QUERY + "\n" + BODY
signature = lowercase(hex(hmac_sha256(api_secret, canonical)))
Правила по каждому полю — их несоблюдение и есть 99% причин invalid_signature:
| Часть | Как ровно |
|---|---|
TIMESTAMP |
Та же строка, что уйдёт в X-API-TIMESTAMP, символ в символ |
METHOD |
Верхний регистр: GET, POST, DELETE |
PATH |
Путь с ведущим слэшем и с префиксом контура: /api/v1/orders. Без схемы, хоста и без ? |
QUERY |
Сырая query-строка без ?; пустая строка, если параметров нет |
BODY |
Сырое тело ровно теми байтами, что уйдут в сокет; пустая строка для GET/DELETE без тела |
Разделитель — один \n (0x0A). Строк всегда пять, даже когда QUERY и BODY
пусты — значит, канон заканчивается на \n\n или на \n перед телом.
Отдельно про QUERY: сервер берёт её как есть и ничего не сортирует.
Подписывайте ту строку, которую реально отправляете, включая порядок параметров и
URL-кодирование. Библиотека, которая пересобирает URL после подписи (или
добавляет свой параметр), сломает подпись.
Пример 1 — GET с параметрами
Секрет для примеров (использовать только для самопроверки):
c2VjcmV0LWRvLW5vdC11c2UtaW4tcHJvZA
Запрос GET /api/v1/ticker?symbol=BTC_USDT, метка 1757500000123. Канон
(показан с явными \n):
1757500000123\nGET\n/api/v1/ticker\nsymbol=BTC_USDT\n
Подпись:
5414b01b270ef6e5518005d2c23b6bd8118300ebcfc789c2bf21b884c25838b5
Проверить свою реализацию побайтно:
printf '1757500000123\nGET\n/api/v1/ticker\nsymbol=BTC_USDT\n' \
| openssl dgst -sha256 -hmac 'c2VjcmV0LWRvLW5vdC11c2UtaW4tcHJvZA' -r
Пример 2 — POST с телом
Запрос POST /api/v1/orders, метка 1757500000456, query пустая, тело
(136 байт, ровно одной строкой, без переводов строки и лишних пробелов):
{"symbol":"BTC_USDT","side":"buy","type":"limit","tif":"gtc","price":"64150.10","quantity":"0.015","client_order_id":"mm-1757500000123"}
Канон:
1757500000456\nPOST\n/api/v1/orders\n\n{"symbol":"BTC_USDT",…,"client_order_id":"mm-1757500000123"}
Подпись:
db9795f324e502083ee00fef5b6bd66ecaa34666b3526b07497b58cd0c50f1fb
Если ваш JSON-сериализатор ставит пробелы после : или меняет порядок ключей —
это другой байтовый поток и другая подпись. Сериализуйте тело один раз, в
строку/буфер, подпишите именно её и её же отправьте.
Окно времени и защита от повтора
- Окно двустороннее: метка из будущего отбивается так же, как из прошлого
(
stale_timestamp). Съехавшие часы клиента — вторая по частоте причина отказов после кривого канона; сверьтесь сGET /api/v1/time. X-API-RECV-WINDOWбольше60000, ноль или не число — 401bad_recv_window. Расширять окно «на всякий случай» вредно: оно же определяет, сколько живёт защита от повтора.- Каждая принятая подпись запоминается на время окна. Повтор той же подписи —
401
replayed_request.
Острый угол. Подпись — функция от (timestamp, method, path, query, body). Два одинаковых запроса с одинаковой меткой времени дают одну и ту же подпись, и второй будет отбит как повтор — хотя это не атака, а честные два ордера подряд. На быстрой машине два вызова легко попадают в одну миллисекунду.
Поэтому клиент обязан гарантировать строго возрастающие метки. Приём, которым пользуются собственные тесты биржи:
timestamp = max(current_time_ms, last_timestamp + 1)
last_timestamp = timestamp
Метка, ушедшая на пару миллисекунд вперёд, безопасна — окно двустороннее.
Альтернатива — добавлять в каждый запрос уникальное поле (client_order_id для
ордеров), но она работает не везде, а монотонный счётчик — всегда.
3. Рабочие примеры
Все три примера делают одно и то же: читают /account/balances, затем ставят
лимитный ордер. Достаточно подставить API_KEY, API_SECRET и BASE.
bash + curl + openssl
#!/usr/bin/env bash
set -euo pipefail
BASE="https://wallex.zone"
API_KEY="ek_…"
API_SECRET="…"
# Строго монотонная метка: два запроса в одну миллисекунду дали бы одну подпись.
LAST_TS=0
next_ts() {
local now
now=$(date +%s%3N)
if (( now <= LAST_TS )); then now=$(( LAST_TS + 1 )); fi
LAST_TS=$now
printf '%s' "$now"
}
signed() {
local method="$1" path="$2" query="${3:-}" body="${4:-}"
local ts sig url
ts=$(next_ts)
sig=$(printf '%s\n%s\n%s\n%s\n%s' "$ts" "$method" "$path" "$query" "$body" \
| openssl dgst -sha256 -hmac "$API_SECRET" -r | cut -d' ' -f1)
url="$BASE$path"
[[ -n "$query" ]] && url="$url?$query"
curl -sS -X "$method" "$url" \
-H "X-API-KEY: $API_KEY" \
-H "X-API-TIMESTAMP: $ts" \
-H "X-API-SIGNATURE: $sig" \
${body:+-H 'Content-Type: application/json' --data-raw "$body"}
}
signed GET /api/v1/account/balances
ORDER='{"symbol":"BTC_USDT","side":"buy","type":"limit","price":"50000.00","quantity":"0.001","client_order_id":"bash-'"$(date +%s%3N)"'"}'
signed POST /api/v1/orders '' "$ORDER"
--data-raw здесь принципиален: --data вырезает переводы строк и может
изменить байты тела, а подпись считается по ним.
Python
import hashlib
import hmac
import json
import time
import requests
BASE = "https://wallex.zone"
API_KEY = "ek_…"
API_SECRET = "…"
class Client:
def __init__(self, base: str, key: str, secret: str) -> None:
self.base, self.key, self.secret = base, key, secret
self.session = requests.Session()
self._last_ts = 0
def _timestamp(self) -> str:
# Строго монотонно: одинаковая метка на двух одинаковых запросах даёт
# одну подпись, и второй прилетит обратно как replayed_request.
ts = max(int(time.time() * 1000), self._last_ts + 1)
self._last_ts = ts
return str(ts)
def request(self, method: str, path: str, params: dict | None = None, body: dict | None = None):
query = "&".join(f"{k}={v}" for k, v in (params or {}).items())
# Тело сериализуем ОДИН раз и отправляем ровно те же байты, что подписали.
payload = json.dumps(body, separators=(",", ":")) if body is not None else ""
ts = self._timestamp()
canonical = "\n".join([ts, method.upper(), path, query, payload])
signature = hmac.new(self.secret.encode(), canonical.encode(), hashlib.sha256).hexdigest()
headers = {
"X-API-KEY": self.key,
"X-API-TIMESTAMP": ts,
"X-API-SIGNATURE": signature,
"Accept": "application/json",
}
if payload:
headers["Content-Type"] = "application/json"
url = f"{self.base}{path}" + (f"?{query}" if query else "")
response = self.session.request(method.upper(), url, headers=headers, data=payload or None)
if response.status_code >= 400:
error = response.json()
raise RuntimeError(f"{response.status_code} {error['code']}: {error['message']}")
return response.json()
client = Client(BASE, API_KEY, API_SECRET)
print(client.request("GET", "/api/v1/account/balances"))
order = client.request("POST", "/api/v1/orders", body={
"symbol": "BTC_USDT",
"side": "buy",
"type": "limit",
"price": "50000.00", # деньги — строками, всегда
"quantity": "0.001",
"client_order_id": f"py-{int(time.time() * 1000)}",
})
print(order["order"]["id"], order["order"]["status"])
Node.js (18+, без зависимостей)
import { createHmac } from 'node:crypto';
const BASE = 'https://wallex.zone';
const API_KEY = 'ek_…';
const API_SECRET = '…';
let lastTimestamp = 0;
function nextTimestamp() {
// Node легко делает два запроса в одну миллисекунду — тогда совпадут и подписи.
const now = Math.max(Date.now(), lastTimestamp + 1);
lastTimestamp = now;
return String(now);
}
async function signed(method, path, { params = {}, body = null } = {}) {
const query = new URLSearchParams(params).toString();
const payload = body === null ? '' : JSON.stringify(body);
const timestamp = nextTimestamp();
const canonical = [timestamp, method.toUpperCase(), path, query, payload].join('\n');
const signature = createHmac('sha256', API_SECRET).update(canonical).digest('hex');
const headers = {
'X-API-KEY': API_KEY,
'X-API-TIMESTAMP': timestamp,
'X-API-SIGNATURE': signature,
Accept: 'application/json',
};
if (payload) headers['Content-Type'] = 'application/json';
const url = `${BASE}${path}${query ? `?${query}` : ''}`;
const response = await fetch(url, { method: method.toUpperCase(), headers, body: payload || undefined });
const data = await response.json();
if (!response.ok) {
throw new Error(`${response.status} ${data.code}: ${data.message}`);
}
return data;
}
console.log(await signed('GET', '/api/v1/account/balances'));
const { order } = await signed('POST', '/api/v1/orders', {
body: {
symbol: 'BTC_USDT',
side: 'buy',
type: 'limit',
price: '50000.00',
quantity: '0.001',
client_order_id: `node-${Date.now()}`,
},
});
console.log(order.id, order.status);
Отлаживать подпись удобнее всего на GET /api/v1/ping: она отвечает только на
корректно подписанный запрос, но ничем не рискует.
4. Лимиты
Ведро считается по ключу (для публичных ручек — по IP). Значения по умолчанию; переменные окружения перечислены для тех, кто разворачивает биржу у себя:
| Ведро | Что покрывает | Лимит | Переменная окружения |
|---|---|---|---|
api-v1-public |
/time, /symbols, /ticker, /depth, /trades, /klines, /funding |
1200 запросов / 1 мин | PUBLIC_API_LIMIT_PUBLIC, PUBLIC_API_LIMIT_PUBLIC_PER |
api-v1-read |
/ping и все ручки скоупа read |
600 / 1 мин | PUBLIC_API_LIMIT_READ, PUBLIC_API_LIMIT_READ_PER |
api-v1-trade |
POST /orders, DELETE /orders, DELETE /orders/{id} |
300 / 1 мин | PUBLIC_API_LIMIT_TRADE, PUBLIC_API_LIMIT_TRADE_PER |
api-v1-withdraw |
POST /withdrawals |
10 / 1 мин | PUBLIC_API_LIMIT_WITHDRAW, PUBLIC_API_LIMIT_WITHDRAW_PER |
| отказы аутентификации | неудачные попытки с одного IP | 60 / 5 мин | PUBLIC_API_AUTH_FAILURE_LIMIT, PUBLIC_API_AUTH_FAILURE_WINDOW |
Прочие настройки контура: PUBLIC_API_MAX_KEYS (10 активных ключей на аккаунт),
PUBLIC_API_ADDRESS_COOLDOWN_HOURS (24 часа охлаждения адреса вывода).
Особенности:
- Ответ на превышение — 429
rate_limit_exceededс заголовкамиRetry-After,X-RateLimit-Limit,X-RateLimit-Remaining. ЖдитеRetry-After, а не фиксированную паузу. - Ведро отказов аутентификации считает неудачи, а не запросы, и живёт по IP. Успешный запрос его не обнуляет. Практический вывод: не гоняйте в проде бота с заведомо неверной подписью — после 60 отказов с адреса перестанут отвечать по существу все ключи, в том числе исправные.
- Отдельного ведра «ордеров в секунду» нет:
api-v1-tradeобщий на постановку и отмену.
5. Ошибки
Формат один на весь контур, включая 401/403/404/429/500:
{ "code": "unknown_symbol", "message": "Unknown symbol `BTC_USD`. See GET /api/v1/symbols for the list." }
У ошибок валидации (422) добавляется третье поле:
{
"code": "invalid_parameter",
"message": "The request body did not pass validation.",
"errors": { "quantity": ["The quantity must be sent as a JSON string (e.g. \"0.015\") to avoid float rounding."] }
}
Опирайтесь на code: message человеческий и может измениться.
Аутентификация и доступ
code |
HTTP | Что случилось |
|---|---|---|
missing_credentials |
401 | Нет одного из трёх обязательных заголовков |
invalid_key |
401 | Ключа с таким public_id нет |
key_revoked |
401 | Ключ отозван владельцем или админом |
key_expired |
401 | Прошёл expires_at |
invalid_signature |
401 | Подпись не совпала — проверьте канон побайтно |
stale_timestamp |
401 | Метка вне окна (в любую сторону) |
replayed_request |
401 | Такая подпись уже принималась — см. про монотонные метки |
bad_recv_window |
401 | X-API-RECV-WINDOW не число, ноль или больше 60000 |
unauthorized |
401 | Общий отказ авторизации |
ip_not_allowed |
403 | Адрес запроса не в ip_allowlist ключа |
insufficient_scope |
403 | У ключа нет нужного скоупа |
account_blocked |
403 | Аккаунт заблокирован — в поддержку |
kyc_required |
403 | Нужна пройденная верификация (вывод) |
forbidden |
403 | Общий запрет |
rate_limit_exceeded |
429 | Лимит ведра или лимит отказов аутентификации |
Параметры и ресурсы
code |
HTTP | Что случилось |
|---|---|---|
unknown_symbol |
404 | Рынка нет либо он делистнут |
order_not_found |
404 | Ордера нет у этого аккаунта |
unknown_asset |
404 | Актива с таким символом нет |
not_found |
404 | Неизвестный путь |
method_not_allowed |
405 | Путь есть, метод не тот |
not_acceptable |
406 | Запрошен формат ответа, которого контур не отдаёт |
csrf_token_mismatch |
419 | Признак того, что запрос ушёл в приватный контур, а не в /api/v1 |
invalid_parameter |
422 | Параметр или тело не прошли валидацию (errors) |
service_unavailable |
503 | Зависимость временно недоступна |
server_error / request_failed |
5xx | Наша ошибка; повторять с backoff |
Отказы торгового домена (POST /api/v1/orders)
code |
HTTP | Что случилось |
|---|---|---|
trading_paused |
503 | Торги на паузе — единственный отказ, который пройдёт сам |
market_not_tradable |
422 | Рынок не в торгуемом состоянии |
perp_unavailable |
422 | Перп-контур недоступен |
no_liquidity |
422 | Рыночному ордеру не с чем сводиться |
tick_lot_mismatch |
422 | Цена не кратна tick_size либо количество — lot_size |
order_too_small |
422 | Количество меньше минимального |
notional_too_small |
422 | Объём в котируемом активе ниже min_notional |
notional_too_large |
422 | Объём выше max_notional |
insufficient_funds |
422 | Не хватает свободного баланса |
reduce_only_invalid |
422 | reduce_only без подходящей позиции |
order_rejected |
422 | Прочий отказ домена |
Вывод (POST /api/v1/withdrawals)
code |
HTTP | Что случилось |
|---|---|---|
ip_allowlist_required |
403 | У ключа пустой ip_allowlist |
two_factor_required |
403 | У аккаунта выключена 2FA |
address_not_whitelisted |
403 | Адреса нет в адресной книге для этой пары актив+сеть |
address_in_cooldown |
403 | Адрес ещё отлёживается (в message — время готовности) |
demo_withdraw_forbidden |
422 | Демо-аккаунт не выводит |
asset_not_withdrawable |
422 | Актив нельзя вывести в блокчейн-сеть |
insufficient_funds |
422 | Не хватает доступного баланса |
6. Формат данных и пагинация
- Время — целое число миллисекунд epoch. Никаких ISO-строк в
/api/v1. - Деньги и количества — строки, и в ответах, и в запросах.
"quantity": 0.015вместо"0.015"— 422: число в JSON означает, что сумма уже прошла черезdouble, а такие ошибки на бирже видны только в отчётности. - Пагинация одна на все истории (
/orders,/orders/open,/my-trades,/deposits,/withdrawals): курсорfrom_id(строго больше) +limit(1..1000, по умолчанию 100), порядок по возрастаниюid. Offset-а нет — на живой ленте он пропускает записи. - Признак конца — короткая страница: элементов меньше, чем
limit. Отдельного поляhas_moreнет. limitвне границ — 422, а не молчаливое обрезание до 1000.
Типовой цикл выгрузки:
cursor = 0
while True:
page = client.request("GET", "/api/v1/my-trades", {"from_id": cursor, "limit": 1000})["trades"]
if not page:
break
handle(page)
cursor = page[-1]["id"]
7. Торговля: острые углы
client_order_id и идемпотентность
client_order_id — ваш ключ идемпотентности, уникальный в пределах аккаунта.
Повтор запроса с уже использованным значением не создаёт второй ордер: биржа
возвращает ранее созданный и отвечает 200 OK вместо 201 Created.
Различайте создание и повтор по коду ответа: тело в обоих случаях одинаковое. Это делает безопасным retry по таймауту — сеть оборвалась, ответ не дошёл, повтор вернёт тот же ордер, а не удвоит позицию. Пользуйтесь этим всегда, когда у запроса возможен ретрай.
Пространство идентификаторов своё у каждого аккаунта: чужой client_order_id
никогда не вернёт чужой ордер.
post_only отклоняется асинхронно
post_only-ордер, скрестившийся с книгой, нельзя отбить в ответе на POST:
решение принимает движок, а не API. Такой ордер получает 201 со статусом
pending, а rejected появится позже.
Следствие: 201 на post_only не означает, что ордер встал в книгу. Если
логика на это опирается (мейкер-стратегия), статус надо опросить —
GET /api/v1/orders/{id} или GET /api/v1/orders/open.
Ровно так же асинхронна отмена: DELETE /api/v1/orders/{id} отвечает 200 и может
вернуть ещё прежний статус — команда ушла в движок. Повторный DELETE — тоже
200 (идемпотентно), а не 404 и не 409.
/my-trades — это только исполнения ордеров
Ликвидаций и списаний/начислений funding в /my-trades нет вовсе. У них нет
ордера, а ручка отдаёт исполнения ордеров.
Практически: перп-PnL, посчитанный по одним лишь сделкам, разойдётся с
балансом — тем сильнее, чем дольше держится позиция. Полную картину дают
realized_pnl из GET /api/v1/account/positions и движения в /account/balances.
Делистнутые пары: асимметрия
Делистнутый рынок для контура не существует: его нет в /symbols, и любой запрос
с таким symbol — 404 unknown_symbol.
Но ордера и сделки по нему остаются в истории и приходят в общей выдаче без
фильтра. Значит, GET /api/v1/orders вернёт ордер по паре FOO_USDT, а
GET /api/v1/orders?symbol=FOO_USDT ответит 404. Клиент, который для каждого
символа из истории делает уточняющий запрос, на этом падает — выгружайте историю
без фильтра и группируйте на своей стороне.
Прочее
GET /api/v1/tickerвсегда возвращает список, даже для одного символа.DELETE /api/v1/ordersтребуетsymbol; без него 422, а не «снять всё». В ответе — спискиcanceledиfailed: то, что попало вfailed, надо перечитать, оно осталось активным.- Демо-аккаунт торгует тем же API; сделки и балансы помечены
is_demo, а вывод для него закрыт.
8. Вывод средств
TOTP-код бот ввести не может, поэтому второй фактор на выводе заменён набором условий. Все четыре проверяются до любого движения денег:
- У ключа непустой
ip_allowlist. Ключ без ограничения по адресу выводить не может в принципе — 403ip_allowlist_required. Выпуститьwithdraw-ключ без allowlist ЛК тоже не даст. - У аккаунта включена и подтверждена 2FA — иначе 403
two_factor_required. - Адрес есть в адресной книге ЛК для этой пары актив+сеть, иначе 403
address_not_whitelisted. Добавление адреса само стоит второго фактора (POST /api/withdrawal-addressesсcode/password). - Адрес отлежался 24 часа (
PUBLIC_API_ADDRESS_COOLDOWN_HOURS) с момента добавления, иначе 403address_in_cooldown; время готовности есть вmessageи в полеusable_atзаписи книги. Это то, что реально спасает при угоне сессии: у владельца есть сутки заметить чужой адрес.
Сверху — общие гейты: пройденный KYC (403 kyc_required), незаблокированный
аккаунт (403 account_blocked), запрет для демо (422 demo_withdraw_forbidden).
Банковского вывода через API нет: у реквизитов нет адреса, а значит и заменять
второй фактор нечем. Такие заявки создаются только в ЛК; в истории
GET /api/v1/withdrawals они видны с type: bank, но без реквизитов.
Запрос:
POST /api/v1/withdrawals
{
"asset": "USDT",
"network": "trx", // легаси-написания вроде "TRC-20" нормализуются
"address": "T…", // должен быть в адресной книге
"amount": "100.5" // строкой
}
Ответ — 201 с телом {"withdrawal": {…}}. В заявку попадает написание адреса
из адресной книги, а не из запроса.
Заявка асинхронна: status идёт pending → processing → completed | rejected,
опрашивайте GET /api/v1/withdrawals.
9. Чек-лист интеграции
- Выпустить ключ с минимальным набором скоупов; для вывода — обязательно с
ip_allowlist. - Проверить подпись на
GET /api/v1/ping, только потом идти в торговые ручки. - Синхронизировать часы (
GET /api/v1/time) и сделать метки строго монотонными. - Прочитать
/symbolsпри старте:tick_size,lot_size,min_notional,max_leverage— оттуда, а не из конфига бота. - Ставить
client_order_idна каждом ордере и повторять запрос при таймауте. - Все ошибки разбирать по
code; на 429 ждатьRetry-After. - Для перпов считать PnL по позициям, а не по
/my-trades.