Что это
brokapi - единый интерфейс к торговым API. Один и тот же код бота работает с любым брокером. Начинаем с API брокеров российского рынка: БКС, Т-Банк, Финам и АЛОР, есть также технический коннектор к API Bybit (страница). Дальше другие брокеры и биржевые протоколы под тот же интерфейс.
Для ИИ-агентов: llms.txt / llms-full.txt. Для тех, что пишут на Python: python/llms.txt
Стадия: открытая альфа. Интерфейс до беты может меняться. Перед первой заявкой на настоящем счете: Перед боевым счетом.
Выберите путь
В книге одна общая часть (как устроено, брокеры, ошибки, события) и у каждого продукта свой раздел. Общее написано один раз и верно для любого пути. Работают Rust, Python и Gateway (все в альфе).
-
Rust
альфаЯдро: рыночные данные, заявки, стоп-заявки, сверка, портфель, любой метод брокера через
raw(). Работает сейчас. Быстрый старт и инструкции. -
Python
альфаТонкая обертка над ядром: события через
async for, деньги какDecimal, те же правила и ошибки, что у ядра. Работает сейчас. Быстрый старт и инструкции.pip install brokapi. -
Gateway
альфаПрограмма
brokapi: служба держит связь со счетами за все ваши боты, боты на любом языке подключаются к ней по gRPC, один счетчик лимитов на всех. Команды той же программы запускают службу, показывают гейтвеи (brokapi ps,brokapi top) и читают счет (brokapi quote, вывод--jsonдля скриптов и ИИ-агентов). Бесплатно, ставится у себя: готовый файл для Linux, macOS и Windows илиcargo install. Работает сейчас.
Что общее у всех путей
- Переводчик. Бот говорит на одном языке (заявка, стакан, событие) со всеми брокерами. Разница протоколов (REST, WebSocket, gRPC) спрятана внутри. Сменить брокера - поменять коннектор, код бота почти не меняется.
- Предохранитель. Главная задача библиотеки - чтобы бот никогда не поставил заявку дважды и не торговал вслепую. Поэтому команда без ответа не повторяется, а торговля закрывается, когда данные о своих заявках могли потеряться.
- Без памяти. Между запусками библиотека ничего не хранит. Журнал заявок ведет бот, библиотека дает номер
cidв каждом событии заявки и сверку со счетом.
Книга устроена так: сначала общая часть (как устроено, брокеры), затем разделы продуктов со своим быстрым стартом и инструкциями, в конце справочник. Код на общих страницах дан вкладками Rust и Python там, где он есть.
Что умеет
| Возможность | Что внутри |
|---|---|
| Рыночные данные | стакан (снимок и поток), сделки рынка, котировки, свечи, справочник инструментов, лот и шаг цены, статус торгов, расписание |
| Заявки | постановка, отмена, изменение (где оно есть у брокера), статус по номеру бота, список активных, поток статусов |
| Свои сделки | история и поток исполнений |
| Портфель | снимок и поток: деньги и позиции |
| Стоп-заявки | стоп-лосс, тейк-профит, стоп-лимит одним набором методов |
| Сверка после рестарта | бот приносит список своих заявок, библиотека сравнивает его с брокером |
| Лимиты запросов | общий счетчик на область, где брокер считает лимит |
| Любой другой метод брокера | через raw(), с теми же токеном, адресом и счетчиком лимитов |
Из чего состоит
У каждого брокера два коннектора: MD (рыночные данные, один на брокера) и UD (заявки, свои сделки, портфель, один на счет). Как они связаны с ботом, с лимитами и между собой, показывает Карта.
Общие трейты (OrderBookSource, OrderExecutor, StopOrderExecutor и другие) закрывают то, что умеют все
брокеры. Бот, написанный на них, работает с любым брокером. Все остальное, что есть у брокера (купоны, отчеты,
избранное, новые методы), доступно через метод raw() каждого коннектора: raw. Сколько методов брокера
закрыто общим интерфейсом, а сколько только raw(): Покрытие API.
Три правила, на которых все держится
cidпридумывает бот и записывает у себя до отправки. Это единственная нить, по которой можно спросить брокера о заявке без ответа. Если номер выдает библиотека, он теряется вместе с ответом, и после падения бот не знает, что за заявка могла встать. Подробно: Круг команд.- Команда без ответа не повторяется.
Unknownвыясняется у брокера, новая попытка идет с новымcid. Заявка без ответа могла встать, и повтор дал бы две заявки на счете. Что делать вместо повтора: Если что-то пошло не так. - Торговля только после сверки. После старта, после перегрузки, при сомнении. Пока бот не знает, какие заявки уже стоят у брокера, новая команда может задвоить заявку или спорить с той, которую бот не видит. Подробно: Круг жизни.
Чего библиотека не делает
- Не придумывает номер заявки.
cidсоздает бот, по нему он узнает свою заявку после рестарта. - Не повторяет команды. Заявка, на которую не пришел ответ, не отправляется второй раз. Ни при каких условиях.
- Не хранит состояние на диске. Журнал своих заявок ведет бот.
- Не собирает общий портфель по счетам и не выбирает счет для заявки. Это слой над коннекторами.
- Не трогает чужие заявки. Заявки, поставленные руками или другим роботом, она только показывает.
- Не снимает заявки при остановке. Заявка на бирже переживает процесс бота.
С чего начать
- Хочу запустить: сначала Установка, потом Быстрый старт на Rust или на Python.
- Пишу бота (сам или с ИИ-агентом): сначала Контракт для агентов, затем инструкция по задаче: торговый бот (Rust, Python), несколько брокеров и счетов (Rust, Python), исследовательский движок, история свечей на Python.
- Несколько ботов на одном счете или бот не на Rust и Python: Gateway, затем быстрый старт.
- Хочу понять устройство: Карта, затем три круга: события, команды, жизнь коннектора.
- Что делать при старте, рестарте и остановке бота: Запуск, рестарт и остановка.
- Пришла ошибка или странное состояние: Ошибки и что с ними делать.
- Особенности конкретного брокера: Брокеры.
- Хочу поставить заявку на настоящем счете: сначала Перед боевым счетом.
- Нужен метод, тип или ошибка до последнего поля: Справочник API Rust (rustdoc, лежит рядом с книгой в
api/) или Python. - Что появилось нового: Изменения.
Установка
Как получить библиотеку и убедиться, что она встала. Это первый шаг обоих быстрых стартов: Rust, Python.
Открытая альфа: интерфейс до беты может меняться. Перед первой заявкой на счете прочитайте Перед боевым счетом. Код открыт:
https://github.com/runinfra/brokapi. Ниже в каждом разделе команда для пакета и для самой свежей версии из репозитория.
Что понадобится
| Rust | Python | |
|---|---|---|
| Язык | Rust 1.88 или новее (редакция 2024) | Python 3.10 или новее. Пакет один на все версии (abi3) |
| Инструменты | cargo (идет вместе с Rust, ставится через rustup) | pip, лучше виртуальное окружение |
| Для установки из репозитория | git | git и Rust 1.88 или новее: расширение собирается на месте, сборщик maturin подтянется сам. Из PyPI Rust не нужен на Linux (x86_64, aarch64), macOS и Windows: там готовые пакеты |
Брокеры включаются флагами, как у tokio: bcs, tbank, finam, alor, bybit, можно несколько. В Python все брокеры уже в пакете.
Rust
Флаг выбирает брокера, здесь БКС:
cargo add brokapi --features bcs
Самая свежая версия, из репозитория:
cargo add --git https://github.com/runinfra/brokapi brokapi --features bcs
Обе команды добавляют зависимость в Cargo.toml. Для программ из быстрого старта нужны еще tokio и dotenvy:
cargo add tokio --features full
cargo add dotenvy
Из клона репозитория (без доступа в сеть к GitHub во время сборки, например на закрытой машине):
[dependencies]
brokapi = { path = "../brokapi", features = ["bcs"] }
Проверка: cargo build собирает проект без ошибок (первая сборка занимает несколько минут: компилируются зависимости).
В коде все лежит под brokapi::: общее в brokapi::core и brokapi::runtime, брокеры в brokapi::bcs,
brokapi::tbank, brokapi::finam, brokapi::alor, brokapi::bybit. Дальше: быстрый старт на Rust.
Python
Готовые пакеты есть для Linux (x86_64 и aarch64), macOS (Intel и Apple silicon) и Windows, на остальных системах pip соберет расширение сам (нужен Rust):
python -m venv .venv && . .venv/bin/activate
pip install brokapi
Самая свежая версия, из репозитория (нужен Rust: расширение собирается при установке, это несколько минут):
python -m venv .venv && . .venv/bin/activate
pip install "git+https://github.com/runinfra/brokapi#subdirectory=crates/python"
Из клона репозитория: pip install ./crates/python.
pip install brokapi дает только библиотеку для Python. Команды brokapi (служба Gateway, brokapi check, brokapi cancel-all) после него нет: программа ставится
отдельно, см. Программа brokapi (Gateway) ниже. Имя brokapi одно у библиотеки на Rust, пакета для Python и программы.
Проверка:
python -c "import brokapi; print(brokapi.__version__)"
Должен напечататься номер версии. Не напечатала: смотрите таблицу ниже. Дальше: быстрый старт на Python.
Если что-то пошло не так
| Что видно | Что значит | Что делать |
|---|---|---|
Git спрашивает имя и пароль или пишет Repository not found | Опечатка в адресе репозитория | Сверить адрес: https://github.com/runinfra/brokapi. Или ставить пакет: cargo add brokapi, pip install brokapi |
error: package ... requires rustc 1.88 | Версия Rust старее нужной | rustup update |
pip пишет, что не нашел cargo или rustc, при установке из репозитория | Расширение Python собирается из исходников, нужен Rust | Поставить Rust через rustup и повторить |
ModuleNotFoundError: No module named 'brokapi' | Пакет поставлен в другое окружение | Активировать то окружение, в которое ставили (. .venv/bin/activate), и проверить python -m pip show brokapi |
could not find brokapi-gateway in registry у cargo install brokapi-gateway | Не указана версия: у альфы нет стабильного выпуска | Добавить --version, как в разделе «Через cargo» |
the crate brokapi could not be found in registry index у cargo add brokapi | Устарел индекс crates.io на машине или старый cargo | rustup update и повторить. Или ставить из репозитория (команда выше) |
Программа brokapi (Gateway)
Gateway - это программа brokapi: служба и команды в одном бинарнике (что это). Ставится готовым файлом (Rust не нужен)
или через cargo install.
Готовый файл
Файлы лежат на странице выпусков, в выпуске v0.1.0-alpha.5 и в каждом следующем. В выпуске пять архивов
и файл с суммами SHA256SUMS:
| Система | Архив |
|---|---|
| Linux x86_64 | brokapi-0.1.0-alpha.5-linux-x86_64.tar.gz |
| Linux aarch64 (ARM) | brokapi-0.1.0-alpha.5-linux-aarch64.tar.gz |
| macOS, процессор Intel | brokapi-0.1.0-alpha.5-macos-x86_64.tar.gz |
| macOS, Apple silicon (M1 и новее) | brokapi-0.1.0-alpha.5-macos-arm64.tar.gz |
| Windows x86_64 | brokapi-0.1.0-alpha.5-windows-x86_64.zip |
Файлы для Linux статические: им не нужна определенная версия glibc, они запускаются на Debian, Ubuntu, RHEL, Alpine и в пустом контейнере.
Файл для Windows не просит установить Visual C++. Файлы не подписаны сертификатом разработчика, поэтому macOS и Windows могут предупредить о неизвестном
издателе: сверьте сумму (ниже) и подтвердите запуск.
Linux и macOS, на примере Linux x86_64 (для другой системы замените имя архива):
V=0.1.0-alpha.5
F=brokapi-$V-linux-x86_64.tar.gz
curl -fLO https://github.com/runinfra/brokapi/releases/download/v$V/$F
curl -fLO https://github.com/runinfra/brokapi/releases/download/v$V/SHA256SUMS
grep "$F" SHA256SUMS | sha256sum -c - # на macOS: shasum -a 256 -c -
tar -xzf $F
mkdir -p ~/.local/bin && cp brokapi-$V-linux-x86_64/brokapi ~/.local/bin/
Последняя команда кладет программу в ~/.local/bin: этот каталог должен быть в PATH (можно любой другой каталог из PATH). Строка с sha256sum
должна ответить OK. Если на macOS браузер скачал архив сам и система не дает запустить brokapi, снимите пометку скачанного файла:
xattr -d com.apple.quarantine ~/.local/bin/brokapi. Через curl пометки нет.
Windows, PowerShell:
$V = "0.1.0-alpha.5"
$F = "brokapi-$V-windows-x86_64.zip"
curl.exe -fLO "https://github.com/runinfra/brokapi/releases/download/v$V/$F"
curl.exe -fLO "https://github.com/runinfra/brokapi/releases/download/v$V/SHA256SUMS"
(Get-FileHash $F -Algorithm SHA256).Hash.ToLower() # эта сумма должна совпасть
Select-String -Path SHA256SUMS -Pattern $F # с суммой в этой строке
Expand-Archive $F -DestinationPath .
Дальше положите brokapi-0.1.0-alpha.5-windows-x86_64\brokapi.exe в каталог из PATH (или вызывайте по полному пути). В архиве рядом лежат README.md и лицензии.
Через cargo
Нужен Rust 1.88 или новее. Версия предварительная (альфа), поэтому номер указывается явно: без --version cargo install ищет только стабильные выпуски
и ответит, что крейта нет.
cargo install brokapi-gateway --version 0.1.0-alpha.5 --locked
Сборка занимает несколько минут. Бинарник brokapi попадает в ~/.cargo/bin.
Самая свежая версия, из клона репозитория:
git clone https://github.com/runinfra/brokapi
cd brokapi
cargo install --path crates/gateway --locked
Проверка
brokapi --version
Должно напечататься brokapi 0.1.0-alpha.5 (номер как у библиотеки). Не напечатало: убедитесь, что каталог с файлом есть в PATH (для cargo install
это ~/.cargo/bin), а при ошибке сборки смотрите таблицу выше (rustc, cargo).
Дальше: быстрый старт Gateway.
Если вы пишете бота на Rust и вам нужен только клиент гейтвея, программа не нужна: Подключить бота.
Перед боевым счетом
brokapi в открытой альфе. Заявки, сверка и лимиты проверены на имитациях брокеров. Коннектор Bybit проверен вживую (что именно и что нет). БКС, Т-Банк, Финам и АЛОР проверены только на имитациях, на настоящих счетах их пока не запускали, поэтому первый запуск на них делайте осторожно.
- Правило 0. Боевой токен выдает человек, чей это счет, и первую боевую заявку ставят только с его слова. Если код пишет ИИ-агент, он сам не создает токен с правом торговли
и не включает флаги записи (
BCS_IS_WRITEи подобные). Что попросить у человека: Контракт для агентов, правило 0. - Начните без токена: имитации (
brokapi.testing, быстрый старт на Python). Потом Т-Банк: песочница. У БКС и Финама песочницы нет, заявка уйдет на настоящий счет. У АЛОРа есть тестовый контур со своим счетом, у Bybit демо-счет с виртуальными деньгами. - Первая заявка - лимитная, минимальное количество, цена заведомо далеко от рынка, сразу отмена.
- Перезапустите бота при стоящей заявке и убедитесь, что сверка ее узнала.
- Не оставляйте бота без присмотра, пока не проверили оба пункта выше на своем счете.
- Заранее решите, как остановить бота, и проверьте это: Аварийная остановка бота. Остановка бота заявки на бирже не снимает.
Что сделает брокер в спорных случаях, мы берем из его документации. Если на вашем счете оно работает иначе, нажмите «Сообщить об ошибке». На страницах брокеров в разделе «Что учесть» собрано то, что меняет код бота.
Аварийная остановка бота
Инструкция. Цель: остановить бота, который ведет себя не так, и твердо знать, что при этом останется на счете. Заранее, до первой боевой заявки, решите, какой из путей ниже ваш, и проверьте его на имитации или песочнице.
Главное, что надо знать: остановка бота заявок на бирже не снимает. Заявка живет у брокера дольше, чем процесс, который ее поставил. Стоп-заявки тоже остаются. Позицию не закрывает ничто из того, что описано здесь.
Что остается на счете после каждого действия
| Действие | Заявки | Стоп-заявки | Позиция |
|---|---|---|---|
Остановка процесса бота (Ctrl+C, kill, падение) | остаются | остаются | остается |
client.close() в Python, shutdown в Rust | остаются | остаются | остается |
brokapi stop <гейтвей>, brokapi down | остаются | остаются | остается |
brokapi stop <гейтвей> --cancel-orders | снимаются все | снимаются все, и защитные тоже | остается |
brokapi cancel-all <гейтвей> | снимаются все | снимаются все, и защитные тоже | остается |
| Скрипт «снять заявки бота» (ниже) | снимаются заявки из журнала бота | остаются | остается |
flowchart TB A[Бот ведет себя не так] --> B[Остановить процесс бота] B --> C[Что должно остаться на счете] C -- ничего, позицию закрою сам --> D[brokapi cancel-all] C -- позиция под защитой стопа --> E[Снять только заявки бота из журнала] E --> F[Сверить позиции и стопы]
На схеме: сначала всегда останавливают бота, чтобы он не ставил новые заявки, а потом выбирают, снимать ли все подряд или только заявки бота. От выбора зависит, останется ли позиция защищенной.
Что делает close()
await client.close() останавливает два коннектора клиента (рыночных данных и счета), дает им до deadline секунд закончить дела (по умолчанию 5, None - сразу)
и завершает поток событий. Он безопасен при повторном вызове и возвращает отчеты коннекторов. Заявки на бирже он не снимает, как и shutdown в Rust
(Контракт для агентов, правило 10). Если бот должен уйти, не оставив заявок, он снимает их сам до close().
У клиента, подключенного через гейтвей, close() только закрывает его соединение. Гейтвей продолжает работать, заявки и стопы остаются, подписки этого бота
гейтвей отпускает сам, когда бот долго не появляется.
brokapi cancel-all
brokapi cancel-all <гейтвей>
Команда снимает все активные заявки и все стоп-заявки счета. В том числе защитные стопы вашего бота и чужие заявки, поставленные руками или другой программой. После нее открытая позиция остается без стопа, а заявки, о которых вы не знали, исчезают.
Это аварийная кнопка на случай, когда важнее всего очистить счет, а позицию вы защитите или закроете сами. Гейтвей при этом продолжает работать. Команда заявок не ставит.
На отказ по лимиту она ждет и повторяет, пока не снимет. Нужен запущенный гейтвей (Команды brokapi, Запуск и обслуживание).
Ту же работу с остановкой гейтвея делает brokapi stop <гейтвей> --cancel-orders.
Прежде чем запускать, посмотрите, что будет снято:
brokapi orders <гейтвей> # активные заявки счета
brokapi stops <гейтвей> # активные стоп-заявки счета
Агент запускает cancel-all только со слова человека: команда меняет настоящий счет, и защитные стопы после нее придется ставить заново.
Снять заявки бота и оставить позицию защищенной
Нужен список cid бота, то есть его журнал (Контракт, правило 1). Скрипт открывает клиента, сверяется с
журналом и снимает только то, что в журнале, живое и найдено у брокера. Стопы он не трогает, чужие заявки (foreign) тоже.
async def cancel_own_orders(client: brokapi.Client, ledger: Iterable[uuid.UUID]) -> list[uuid.UUID]:
"""Cancels the live orders of the bot (the cids of its journal). Stop orders and foreign orders stay.
Returns the cids whose outcome is not clear: look at them with `order_state`, do not cancel them again blindly."""
report = await client.reconcile(list(ledger)) # the gate of the commands is closed in a new process, `cancel` too
unclear = []
for order in report.confirmed: # the orders of the journal that are alive at the broker; `foreign` is not touched
try:
await client.cancel(order.cid)
except brokapi.Rejected:
pass # not found: it was filled or cancelled a moment ago. `order_state(cid)` tells which
except brokapi.RateLimited as limited:
await asyncio.sleep(limited.retry_after.total_seconds()) # nothing was sent: wait and try this one again
try:
await client.cancel(order.cid)
except brokapi.BrokapiError:
unclear.append(order.cid)
except (brokapi.Unknown, brokapi.Transport):
unclear.append(order.cid)
return unclear
Что здесь важно:
reconcileнужен и скрипту: в новом процессе шлюз торговых команд закрыт, иcancelбез него вернетNotReconciled.- Заявки в
confirmedвзяты из вашего журнала. Заявки вforeign(их нет в журнале) скрипт не снимает: это решение человека. Rejectedнаcancelзначит «брокер такой заявки не знает»: она уже исполнилась или снята. Что именно, скажетorder_state(cid).UnknownиTransportнаcancelзначат, что исход неясен. Повторять вслепую не нужно: спроситеorder_state(cid). Скрипт возвращает такиеcidсписком.- За время работы скрипта заявка могла частично исполниться. Позиция тогда выросла, а стоп остался прежним по размеру.
После снятия сверьте позиции и стопы и, если стопа не хватает, поставьте недостающий: Бот со стопом: старт и рестарт. Тот же рецепт делает бот при каждом запуске, поэтому после аварии достаточно запустить его заново.
Скрипт идет в паре с остановкой самого бота. В Rust порядок тот же (Запуск, рестарт и остановка): остановить поток решений, снять свои заявки по
журналу, shutdown, и только потом процесс.
Что делает человек
Бот и скрипт позицию не закрывают. Если позицию надо закрыть, это делается в приложении или кабинете брокера: библиотека для этого не предназначена, а команды brokapi
заявок не ставят (Команды brokapi).
Как проверить
Скрипт «снять заявки бота» проверен на имитациях всех четырех брокеров: две заявки бота и одна чужая стоят вместе с защитным стопом, бот закрывается, скрипт
в новом процессе снимает две, чужая и стоп остаются. Потерянный ответ на cancel попадает в список неясных. Для этих тестов нужен клон репозитория (в пакете pip
тестов и примеров нет):
git clone https://github.com/runinfra/brokapi && cd brokapi
pip install brokapi pytest pytest-asyncio
cd crates/python && pytest tests/test_guides.py -k emergency
Свой путь остановки проверьте так же: поставьте заявку и стоп на имитации или песочнице, остановите бота выбранным способом и посмотрите active_orders()
и active_stop_orders() (или brokapi orders и brokapi stops).
Карта: сущности и три круга
Эта страница - карта библиотеки: из чего она состоит, по каким кругам ходят данные и команды и на каких правилах это держится. Подробности каждого круга лежат на своей странице, здесь только то, что нужно, чтобы не потеряться. Термины в таблице ниже - словарь всей книги: везде они значат ровно это.
Сущности и связи
Бот сам поднимает коннекторы, глобального реестра в библиотеке нет. Коннекторов два вида: MD - рынок, один на
брокера, и UD - заявки и портфель, один на счет. Все пишут в один канал событий. Запускаются они одинаково:
start(конфиг, счетчик_лимитов, канал_событий).
На схеме: бот, библиотека с коннекторами, брокер, общий канал событий и счетчик лимитов.
flowchart TB
BR[Брокер<br>REST, WebSocket, gRPC]
subgraph lib[Библиотека brokapi]
MD[MD, рыночные<br>данные, один<br>на брокера]
UD1[UD, данные<br>счета 1: заявки,<br>портфель]
UD2[UD, данные<br>счета 2]
end
L[Счетчик лимитов<br>один на область квоты]
Q[(Канал событий<br>один на всех)]
Bot[Бот]
BR <--> lib
L -. квота .- lib
lib -- события в конверте --> Q
Q --> Bot
Bot -- команды --> lib
Из схемы следует три вещи, и у каждой есть причина:
- MD и UD разделены. Всплеск в потоке стакана не должен задерживать отмену заявки. Если бы команды и стакан шли через одно соединение, отмена стояла бы в очереди за стаканом. У MD и UD разные соединения и разные лимиты, они ничего не знают друг о друге и не делят сокет.
- Счетчик лимитов один на ту область, где брокер считает квоту. У Т-Банка это пользователь (все счета вместе), у БКС - токен, то есть счет (по документации). Дать двум коннекторам разные счетчики на одну квоту нельзя: вместе они превысят ее, и брокер начнет отказывать. Дать один счетчик там, где лимиты независимы, тоже плохо: коннекторы будут мешать друг другу без нужды. Как устроен счетчик: Лимиты запросов, какая область у какого брокера: Несколько счетов и брокеров.
- Канал событий один, коннектор не знает, кто его читает. Бот отдал канал при старте и может дать один всем коннекторам или по каналу каждому. Если читатель отстает, коннектор не блокируется: что тогда происходит, решает буфер подписки (Круг событий).
Словарь:
| Сущность | Что это |
|---|---|
| Коннектор MD | стаканы, сделки рынка, котировки, свечи. К счету не привязан |
| Коннектор UD | заявки, свои сделки, портфель. Ровно один счет |
| Счетчик лимитов | набор «ведер» с квотой. Считает запросы до отправки, при нехватке сразу RateLimited, в сеть ничего не уходит (Лимиты запросов) |
| Канал событий | очередь mpsc, которую бот отдал при старте |
Конверт Envelope | событие плюс метка коннектора source, номер подписки sub и время (События и возможности) |
cid | номер заявки. Придумывает бот, UUID, до отправки (Круг команд) |
| Подписка | поток событий одного вида. У каждой свой буфер (Круг событий) |
| Шлюз команд | замок на торговые команды (CommandGate): закрыт после старта, при перегрузке и при остановке (Круг жизни) |
| Сверка | reconcile: сравнение списка заявок бота со счетом, после нее шлюз открывается (Круг жизни) |
Слои
Крейты разложены так, чтобы логика брокера не знала про сеть, а сеть - про логику. Стрелка на схеме значит «пользуется». Что лежит в каждом крейте, до отдельного типа: Справочник API.
flowchart TB CONN[brokapi-bcs, brokapi-tbank, brokapi-finam,<br>brokapi-alor, brokapi-bybit<br>коннекторы: склеивают слои] PROTO[brokapi-bcs-protocol, brokapi-tbank-protocol,<br>brokapi-finam-protocol, brokapi-alor-protocol,<br>brokapi-bybit-protocol<br>клиент брокера: сеть] RT[brokapi-runtime<br>лимиты, буферы, шлюз,<br>очередь по бумаге, сверка] SANS[brokapi-sansio<br>ядро без сети: разбор,<br>машина состояний заявки] CORE[brokapi-core<br>общие типы и трейты:<br>то, что видит бот] CONN --> PROTO & RT & SANS RT --> SANS --> CORE MOCK[brokapi-mocks<br>имитации брокеров] -. для тестов .-> PROTO CONF[brokapi-conformance<br>общие сценарии] -. проверяет .-> CONN
Что из этого следует:
- Бот видит только
brokapi-core. Сменить брокера - поменять коннектор, код бота почти не меняется. - Ядро без сети проверяется записанными сообщениями брокера и виртуальным временем, быстро и без интернета.
- Внизу - тестовый контур: имитация брокера с настоящим протоколом и общие сценарии, которые гоняются против каждого коннектора.
Зачем ядро без сети
Ядро коннектора (sans-io) - чистая логика: «пришло такое сообщение брокера -> вот события для бота» и «чтобы
подписаться, нужно отправить вот такое сообщение». Оно не открывает сокеты, не держит таймеры, не знает про
асинхронность. Сеть и время ему подает внешний слой. Интерфейс ядра (BrokerCore) - пять действий:
on_frame (разобрать пришедшее сообщение), poll_transmit (что отправить брокеру), poll_timeout (когда
разбудить ядро), on_timeout (обработать наступивший срок), apply (привести подписки к нужному набору).
Зачем так:
- Ядро проверяется быстро и без сети. Тест кормит его записанными сообщениями брокера и проверяет события. Время в таких тестах виртуальное.
- Ошибки разбора и перевода ищутся отдельно от ошибок сети. Если смешать, сбой в сети похож на ошибку разбора, и причину ищут не там.
- Записанные настоящие сообщения (когда будут живые прогоны) становятся тестами без правок.
У Т-Банка и Финама запросы типизированы (gRPC), поэтому вместо poll_transmit у ядра Т-Банка есть свой метод
выдачи запросов. На работу бота это не влияет.
Три круга
Вся работа - три круга, которые крутятся одновременно и почти не мешают друг другу: события (брокер -> бот), команды (бот -> брокер) и жизнь коннектора (шлюз команд и сверка). Бот общается с коннекторами двумя путями: команды - вызовы методов, ответ приходит как результат вызова, события - все подписки всех коннекторов складываются в один канал.
Круг событий: брокер -> бот
Внутри коннектора сообщение брокера проходит три ступени. Ядро только разбирает и подсказывает, что отправить и когда его разбудить, а сокеты и таймеры держит внешний слой. На схеме: путь сообщения от брокера до бота и служебные сигналы ядра.
flowchart TB BR[Брокер] -- сообщение --> P[Протокол<br>сокет, поток gRPC] P -- кадр --> C[Ядро без сети<br>on_frame: разбор] C -- события в общих типах --> B[Буфер подписки<br>Conflate, DropNewest<br>или Lossless] B --> Q[(Канал событий)] Q --> Bot[Бот читает<br>отдельной задачей] C -. poll_transmit:<br>что отправить .-> P C -. poll_timeout:<br>когда разбудить .-> T[Таймер] T -. on_timeout .-> C
Поток брокера читается всегда и никогда не ждет бота. Если бот отстает, решает режим буфера:
Conflate- старый снимок заменяется свежим. Для стакана и котировок.DropNewest- лишнее отбрасывается со счетчиком. Для некритичного.Lossless- ничего не теряется. Для своих заявок и сделок. Переполнился -Overflowи закрытый шлюз. Оборвался и открылся заново поток заявок или исполнений -Reconnectedи тоже закрытый шлюз (круг жизни ниже).
Почему режимов три: стакану нужен только последний снимок, а своим сделкам нужны все, и один режим на все подписки либо потерял бы деньги, либо копил бы устаревший стакан. Подробно, с обрывами и восстановлением соединений: Круг событий.
Круг команд: бот -> брокер
Команда проходит три проверки без сети, и только потом уходит к брокеру. На схеме: путь place и все его
исходы.
flowchart TB
A[place с cid] --> G{Шлюз открыт}
G -- нет --> E1[NotReconciled, Degraded<br>или ShuttingDown]
G -- да --> V{Вход верный}
V -- нет --> E2[Rejected]
V -- да --> L{Квота есть}
L -- нет --> E3[RateLimited]
L -- да --> O[Очередь по бумаге:<br>ждет прежние команды]
O --> M[cid помечен использованным<br>повтор cid: Rejected]
M --> S[Отправка брокеру]
S --> R{Ответ}
R -- принял --> OK[OrderAck]
R -- отказал --> RJ[Rejected]
R -- нет ответа --> U[Unknown<br>без повтора]
Что из этого следует:
- Все, что выше «Отправки», до брокера не доходит. Заявки точно нет, а при отказе на первых трех проверках
cidне потрачен: после устранения причины его можно использовать снова.cidпомечается только после очереди, поэтому пока вызов ждет в очереди, номер еще чистый. Unknownне повторяется никогда. Заявка без ответа могла встать, повтор дал бы две заявки. Бот выясняет судьбу черезorder_state(cid): Если что-то пошло не так.- Очередь по бумаге: отмена не обгонит постановку той же бумаги. Разные бумаги идут параллельно. Отмена тратит отдельное ведро квоты, поэтому поток постановок ее не вытесняет (Лимиты запросов).
Исходы, коды отказов и порядок проверок подробно: Круг команд.
Круг жизни: шлюз торговли
У каждого UD есть шлюз торговых команд. Чтение и подписки работают всегда, закрывается только торговля. На схеме: три состояния шлюза и переходы между ними.
stateDiagram-v2 state "Не сверен<br>NotReconciled" as NR state "Торговля открыта" as OPEN state "Перегрузка<br>Degraded" as DEG state "Остановка<br>ShuttingDown" as SD [*] --> NR: start NR --> OPEN: reconcile успешен OPEN --> DEG: Overflow в Lossless-буфере<br>или Reconnected потока заявок DEG --> OPEN: бот дочитал канал и reconcile OPEN --> SD: shutdown NR --> SD: shutdown DEG --> SD: shutdown SD --> [*]
Из обоих закрытых состояний выход один - сверка. Это и есть ответ на вопрос «можно ли торговать»: только когда список заявок бота совпал со счетом и в данных о своих заявках нет дыры. Остановка необратима: коннектор закрывается, торговать дальше нельзя.
Сверка, ее результат и причины порядка действий: Круг жизни. Что делать по шагам: Запуск, рестарт и остановка бота.
Круг событий: подписки, буферы, обрывы
Страница описывает путь события от брокера к боту и то, что происходит, когда бот отстает или связь рвется. Схема
пути - на Карте, здесь ее подробности: подписки, режимы буфера,
переполнение, Resync, восстановление соединений и токены.
Подписка
Метод subscribe_* (стакан, сделки, котировки, свечи, статусы заявок, исполнения, портфель) возвращает
SubscriptionId. События этой подписки приходят в общий канал в конверте с этим номером. Снять подписку можно
методом unsubscribe(sub) (для рыночных подписок).
Что важно:
-
subscribe_*возвращается, когда поток открыт (у Финама есть оговорка, см. ниже). Событие, которое случилось после возврата, до бота дойдет: заявку можно ставить сразу послеsubscribe_order_stateиsubscribe_executions. Раньше нельзя: заявка, исполненная до открытия потока, оставила бы бота без события. Поток не открылся заsubscribe_ack_timeout(3 секунды, у Т-Банкаrequest_timeout) - ошибкаTransport, подписка снята, вызов можно повторить. Отказ брокера (нет такого инструмента, нет прав) -Rejected { subscription_refused }. Что считается «поток открыт», зависит от брокера:Брокер Признак БКС, заявки, исполнения, портфель сокет orders/events/wsилиportfolio/wsоткрыт (брокер присылает данные сам, подтверждения в протоколе нет)БКС, рынок кадр подтверждения брокера ( ...Success), либо первые данные подпискиТ-Банк ответ брокера о подписке в самом потоке ( Ok), для портфеля оба потокаФинам брокер открыл поток (заголовки ответа получены). Оговорка ниже АЛОР кадр 200на подписку (guidподписки)Bybit ответ на подписку с тем же req_idиsuccess: trueВызов может идти до этого срока. Если за него поток не открылся, это
Transport(сеть, медленный брокер), а не отказ брокера. У БКС и Финама брокер подтверждения не присылает: между открытием сокета или потока и моментом, когда брокер начинает слать события, остается промежуток на его стороне, и библиотека его не видит.Финам - исключение. Финам работает по gRPC, а gRPC-сервер может отправить заголовки ответа потока только вместе с первым сообщением. Поток статусов заявок молчит, пока заявок нет, поэтому здоровый поток может выглядеть «не открытым» до первой заявки. Поэтому у Финама
subscribe_*ждет открытия потока доsubscribe_ack_timeout, и если за это время поток не открылся, все равно возвращаетOk: поток дооткрывается в фоне, подписка не снимается, а гарантии «все после возврата дойдет» до открытия потока нет (состояние покажетsub_status). Отказ брокера внутри срока по-прежнему возвращается ошибкойRejected. -
Повторная подписка. У Т-Банка подписка на статусы заявок, исполнения и портфель одна на коннектор: повторный вызов вернет тот же
SubscriptionId. Стакан и котировки одного инструмента идут одной проводной подпиской, отписка от одной не рвет другую. -
Квота. У Финама открытие потока тратит квоту, как любой запрос. Число одновременных потоков ограничено (
subscription_quota).
Режимы буфера
Между потоком брокера и общим каналом стоит буфер на каждую подписку. Поток брокера читается в любом случае
и никогда не ждет, пока бот разберет канал. Если бот отстает, события копятся в буфере подписки, а режим решает, что
с ними будет. Режим бот выбирает при подписке (SubscribeOpts).
| Режим | Что происходит, когда бот отстает | Для чего |
|---|---|---|
Conflate | старый снимок заменяется новым, бот получает самый свежий. Данные не копятся. Держится последнее событие каждого вида (у Т-Банка на одной подписке портфеля идут портфель, позиции и остатки, свежий остаток не заменяет позиции) | стакан, котировки (полные снимки) |
DropNewest { cap } | пока в буфере меньше cap событий, все идет как обычно, при заполнении новые события отбрасываются, растет счетчик | некритичное: свечи, статистика |
Lossless { cap } | ничего не теряется. Если буфер заполнился, это переполнение: см. ниже | свои заявки, свои сделки |
Для статусов своих заявок и исполнений годится только Lossless: библиотека сама ставит этот режим, размер
буфера задается в конфиге UD (order_state_buffer, по умолчанию 1024). Для портфеля режим выбирает бот.
Получается два простых правила:
- Стакан, котировки -
Conflate. Старый снимок бесполезен, нужен последний. - Все, что нельзя потерять, -
Losslessс достаточнымcapи быстрое чтение канала ботом.
Переполнение: Overflow и Degraded
Если Lossless-буфер заполнился:
- Коннектор присылает
Event::Overflow { sub, lost_from }: этой подписке не хватило места, события начиная сlost_fromмогли быть потеряны. Событие приходит один раз на подписку, раньше обычных данных. - Новые события этой подписки отбрасываются, пока не будет сверки. Тихой потери нет: бот знает, что дыра образовалась.
- Коннектор переходит в состояние
Degraded: торговые команды возвращаютError::Degraded. Продолжать торговлю при дыре в данных о своих заявках нельзя.
На схеме: как медленное чтение канала доходит до закрытой торговли.
flowchart TB A[Бот читает канал медленно] --> B[Lossless-буфер заполнен] B --> C[Event::Overflow<br>и состояние Degraded] C --> D[Команды: Error::Degraded] D --> E[Выход: бот дочитывает канал<br>и зовет reconcile]
Почему торговля закрывается, а не продолжается с пометкой «возможна дыра»: бот видит свои заявки только по событиям, и потерянное событие о частичном исполнении или снятии значит, что бот торгует по неверной картине. Закрытый шлюз заставляет сначала сверить ее со счетом. Порядок действий боту: Если что-то пошло не так.
Чтобы переполнения не было: читайте канал отдельной задачей и не делайте в ней долгих операций. Размер буфера подберите с запасом на всплеск.
У всех брокеров механизм одинаковый. Буферы подписок у Т-Банка и БКС - один и тот
же код (Emitter из brokapi-runtime), у Финама свой, с теми же правилами.
Для рыночных подписок сверки нет (сверка - это про заявки), поэтому после Overflow подписку нужно снять
(unsubscribe) и оформить заново. Для рыночных данных обычно хватает Conflate, и переполнения нет.
Resync: стакан мог потерять данные
Стакан у всех брокеров приходит полным снимком, не приращениями. Поэтому следующий снимок сам заменяет
прежний. Но бывает, что прежнему верить уже нельзя. Тогда приходит Event::Resync { sub, instrument, reason }.
Когда:
| Причина | Когда | Брокер |
|---|---|---|
SubscriptionReset | поток переоткрыт после обрыва, подписка пересоздана | Т-Банк (стакан и котировки), БКС (стакан), Финам (стакан), АЛОР (стакан), Bybit (стакан) |
SequenceGap | в данных брокера обнаружен пропуск. Т-Банк помечает так стакан, который сам брокер счел несогласованным (такой стакан не публикуется). Финам помечает приращение без базового снимка и немедленно переоткрывает поток. Bybit: изменения стакана идут подряд по номеру u, не следующий номер сбрасывает стакан, коннектор просит у биржи свежий снимок (отписка и подписка) | Т-Банк, Финам, Bybit |
У БКС и АЛОРа SequenceGap не бывает: стакан приходит полным снимком, и сверять нечего. SubscriptionReset идет по одному разу на каждую
подписку на стакан, сразу после переподключения WebSocket и до снимков восстановленных подписок. Первое подключение
Resync не дает: терять нечего. Коннектор узнает о подключении от самой сессии WebSocket, отдельным служебным сообщением в
том же потоке, что и данные, поэтому Resync не может обогнать данные и не может опоздать за ними.
Что делать: выбросить стакан, который хранится у бота, не торговать по нему, дождаться следующего
Event::OrderBook. У Финама Resync идет вне очереди склейки. У Т-Банка и БКС он проходит через буфер подписки, и если бот сильно
отстает, его может заменить более свежий снимок (он тоже свежий, так что бот в любом случае получит актуальные данные).
Reconnected: поток заявок или исполнений оборвался
Потоки своих заявок (subscribe_order_state) и своих исполнений (subscribe_executions) тоже обрываются и переоткрываются
сами. Но в отличие от стакана, здесь за время обрыва могло произойти то, о чем бот не узнает из потока: заявка исполнилась,
снялась, была отклонена. Поэтому после того как поток открылся заново, приходит
Event::Reconnected { sub, gap_from } на подписку, чей поток оборвался.
sub- подписка, у которой оборвался поток. Приходит по одному событию на подписку заявок и на подписку исполнений (если подписаны на обе).gap_from- время (локальные часы, наносекунды), когда коннектор заметил обрыв. Дыра начинается в этот момент или раньше, поэтому историю читайте с запасом назад.
Что делает коннектор: закрывает торговый шлюз, как при Overflow. Команды получают Degraded и в сеть не уходят, пока не
пройдет reconcile. Шлюз закрывается раньше, чем событие дойдет до бота, поэтому команда после события уже отказана.
Что делать боту: то же, что после Overflow. Дочитать канал до конца (события до дыры нужны как обычно), прочитать
состояние у брокера (active_orders, order_state по своим cid, executions с момента чуть раньше gap_from), вызвать
reconcile. Успешная сверка открывает шлюз. Подробно: Если что-то пошло не так.
Для портфеля такого события нет: каждое сообщение потока портфеля и так целое состояние, следующее заменит прежнее. Для
рыночных данных вместо него Resync (выше).
| Т-Банк | БКС | Финам | АЛОР | Bybit | |
|---|---|---|---|---|---|
| Когда приходит | поток заявок или поток сделок открылся не первый раз | второе и каждое следующее подключение сокета orders/events/ws (он несет и статусы, и исполнения, поэтому событие идет на обе подписки) | поток заявок или поток сделок был открыт, оборвался и открылся снова | второе и каждое следующее подключение сокета подписок (он несет и заявки, и сделки, поэтому событие идет на обе подписки) | второе и каждое следующее подключение приватного сокета (события идут на подписки заявок и исполнений) |
gap_from | момент переоткрытия (обрыв сам коннектор не видит) | момент подключения | момент обрыва | момент подключения | момент подключения |
| Что еще делает коннектор | перечитывает активные заявки и присылает изменившиеся | ничего | ничего | ничего | ничего, подписки заказываются заново |
Восстановление соединений
Все коннекторы сами переоткрывают оборванные соединения и восстанавливают подписки. Бот ничего для этого не делает. На схеме: что происходит после обрыва, пока поток снова не заработает.
flowchart TB W[Поток работает] --> X[Обрыв: молчание,<br>ошибка, закрытие] X --> P[Пауза: 0,5 с, дальше<br>удваивается до 30 с] P --> R[Переподключение] R -- не вышло --> P R -- вышло --> S[Подписки восстановлены] S --> RS[Resync по стакану:<br>старый выбросить] RS --> W
Из схемы следует, что бот не видит обрыва как отдельного события: он видит Resync по стакану, когда поток вернулся, и
Reconnected по потокам заявок и исполнений. Стакан у всех брокеров приходит полным снимком, поэтому после Resync достаточно
дождаться следующего. А после Reconnected бот обязан прочитать состояние и позвать reconcile
(Если что-то пошло не так). Параметры по брокерам:
| Т-Банк | БКС | Финам | АЛОР | Bybit | |
|---|---|---|---|---|---|
| Пауза перед повтором | 0,5 с, удваивается до 30 с, сбрасывается после первого сообщения потока (не после открытия: поток, который открылся и сразу оборвался, не пробуется на полной скорости). Не настраивается | 0,5 с, удваивается до 30 с, сбрасывается, если соединение прожило 10 с. Настраивается: reconnect_min и reconnect_max в MarketDataConfig и UserDataConfig | 0,5 с, удваивается до 30 с, сбрасывается, если поток прожил 10 с. Не настраивается | 0,5 с, удваивается до 30 с, сбрасывается, если соединение прожило 10 с. Настраивается: reconnect_min и reconnect_max в MarketDataConfig и UserDataConfig | 0,5 с, удваивается до 30 с, сбрасывается, если соединение прожило 10 с. Настраивается: reconnect_min и reconnect_max в MarketDataConfig и UserDataConfig |
| Как замечает обрыв | молчание дольше трех интервалов пинга (по умолчанию 3 x 30 с) | пинг каждые 30 с, нет ответа 60 с - переподключение | ошибка потока, закрытие сервером | ошибка чтения, закрытие сервером, пинг каждые 30 с, ответа на пинг коннектор не требует | пинг каждые 20 с, как просит биржа, молчание дольше 50 с - переподключение |
| Подписки | восстанавливаются все | восстанавливаются все | поток каждой подписки открывается заново | восстанавливаются все, каждая со свежим токеном | восстанавливаются все, приватный сокет входит заново |
Resync стакана | да | да | да | да | да |
| Заявки после обрыва | Event::Reconnected и закрытый шлюз. Активные заявки перечитываются, изменения приходят событиями. Заявки, закрытые за время обрыва, находятся через order_state и executions | Event::Reconnected и закрытый шлюз. Не перечитываются | Event::Reconnected и закрытый шлюз. Не перечитываются | Event::Reconnected и закрытый шлюз. Не перечитываются | Event::Reconnected и закрытый шлюз. Не перечитываются |
| Статус подписки | нет | нет | sub_status(sub): Connecting, Active, Reconnecting, Failed, Erroring | нет | нет |
Почему паузы одинаковые. Ни в коде, ни в документации брокеров нет причины для разных диапазонов: ни один брокер не называет, как часто можно переподключаться. Поэтому у всех один диапазон. Начало в 0,5 с не бьет по серверу в первую секунду обрыва, а потолок в 30 с не оставляет бота без данных дольше половины минуты. У БКС, АЛОРа и Bybit границы можно поменять в настройке коннектора. Сколько подключений в секунду терпит БКС, в документации не названо. У Т-Банка и Финама паузы пока зашиты в код.
Особенности:
- Финам: подписка, которую сервер отверг (нет прав, нет инструмента), помечается
Failedи пробуется снова примерно раз в 30 секунд, на случай, если права появятся.subscribe_*вернет ошибку сразу, если отказ пришел при открытии. - Т-Банк: поток с истекшим сроком жизни (ошибка 80005) обрабатывается как обычный обрыв. Отказ открыть поток, который повтор не вылечит (токен не подошел, нет права, открыто максимум потоков, 80001),
останавливает поток:
subscribe_*возвращает ошибку, а у потока заявок или сделок, оборвавшегося позже, закрывается шлюз. Новая подписка пробует открыть поток заново. - БКС: при отказе 403 при подключении токен обновляется принудительно.
Токены и их обновление
| Брокер | Что дает вход | Как обновляется |
|---|---|---|
| БКС | refresh-токен из настройки. Из него коннектор получает токен доступа | Токен доступа берется при первом запросе и обновляется заранее (после 90% срока expires_in). После ответа 401 на чтение он обновляется, и запрос повторяется один раз. Команда (заявка, отмена, правка) после 401 не повторяется: токен обновляется для следующего вызова, а 401 возвращается ошибкой (Rejected с кодом http_401). При отказе 403 на WebSocket токен обновляется принудительно. Если Keycloak присылает новый refresh-токен, коннектор принимает его, но только в памяти: на диск он не пишется, после рестарта берется токен из настройки. Документация БКС не говорит, остается ли старый refresh-токен рабочим |
| Т-Банк | токен. Он статичный, обновлять нечего | Если токен отвергнут, это ошибка настройки (Config) |
| Финам | секрет Trade API. По нему коннектор получает JWT | Срок жизни JWT читается из ответа брокера. Обновление идет заранее (за пятую часть срока, от 5 до 120 секунд, но не позже середины срока), не дожидаясь ошибки. Неудачное обновление повторяется с паузой от 1 до 30 секунд, старый токен пока работает. У Auth и TokenDetails таймаут 10 секунд, срок токена это меньшее из TokenDetails и exp - iat. Ошибка UNAUTHENTICATED вызывает обновление сразу. Чтение после этого повторяется один раз, команда не повторяется никогда, но обновление запускается и на ней |
| АЛОР | refresh-токен с портала разработчика. Из него коннектор получает токен доступа | Токен доступа живет 30 минут и берется заново через 20 (или раньше, если его собственный срок короче). После ответа 401 на чтение токен обновляется и чтение повторяется один раз, команда после 401 не повторяется. По WebSocket токен идет в каждом сообщении: при переподключении подписки восстанавливаются со свежим токеном, а ответ 401 по сокету обновляет токен до переподключения |
| Bybit | пара ключ и секрет API | Обновлять нечего: ключ статичный, каждый запрос подписывается секретом. Время для подписи берется у биржи (сдвиг часов читается при старте). Ключ, который биржа отвергла, это ошибка настройки (Config) |
Один JWT Финама можно разделить между несколькими коннекторами одного секрета (shared_tokens): Несколько
счетов и брокеров.
Симптомы, которые видит бот, и что с ними делать: Ошибки и что с ними делать.
Круг команд: заявки
Страница объясняет, что происходит с заявкой от вызова place до исполнения и что бот делает в каждом случае.
Три правила, на которых держится вся библиотека, - в начале книги.
Схема самого круга - на Карте, здесь ее подробности. Исходы и коды
отказов собраны на странице Ошибки и что с ними делать, порядок действий после неясного исхода - на
странице Если что-то пошло не так.
cid: номер заявки
cid (ClientOrderId) - это UUID, который бот создает сам: ClientOrderId::new_v4(). Библиотека номера не
придумывает. Причины:
- Библиотека ничего не помнит между запусками. Она не пишет на диск. После рестарта бот приносит список своих
cid, и по нему заявки на счете делятся на свои и чужие (Круг жизни). - Номер нужен до отправки. Если ответа нет,
cid- единственная нить, по которой можно спросить брокера: «что с заявкой, которую я пытался поставить?». Если бы номер выдавала библиотека, он потерялся бы вместе с ответом. - Случайный UUID не требует согласования. Столкновение практически невозможно, ни один процесс ни с кем не координируется.
Правила для бота:
- Один
cid- одна заявка. Новая попытка - новыйcid. Старыйcidбиблиотека повторно не отправит: вернетRejectedс кодомduplicate_place, в сеть ничего не уйдет. Это касается и тех попыток, которые закончились отказом брокера. Зачем так жестко: после таймаута первая заявка могла дойти. Что брокер сделает с повтором того же номера, зависит от брокера (LabelOnly, см. ниже), и надеяться, что он отбросит дубль, нельзя. - Единственное исключение: вызов отвергнут до отправки, когда
cidеще не помечен использованным. Так бывает приNotReconciled,DegradedиShuttingDown, при ошибке входа (missing_price,invalid_qty, идентификатор инструмента другого брокера) и приRateLimited. Заявки нет,cidгоден, после устранения причины его можно использовать снова (у всех брокеров). Не уверены - берите новыйcid, это всегда безопасно. - Перед вызовом
placeзапишитеcidв свой журнал (состояние «отправляю»). Иначе после падения процесса между отправкой и записью вы потеряете заявку, о которой не знаете. - Для изменения заявки (
replace) нужен новыйcid: старый указывается какoriginal_cid.
Как cid уходит к брокеру
| Брокер | Куда кладем | Обратимо |
|---|---|---|
| БКС | поле clientOrderId, UUID с дефисами как есть | да |
| Т-Банк | поле order_id (это ключ идемпотентности, 36 символов), UUID с дефисами как есть. Брокер возвращает его же как order_request_id, по нему работают отмена и чтение статуса | да |
| Финам | поле client_order_id вмещает всего 20 символов, UUID туда не помещается. Поэтому полный номер кладется в комментарий заявки в виде bc1: и 32 символа, а в client_order_id идет короткая метка из 20 символов, получаемая из UUID. Метка по себе номер не восстанавливает | да, если Финам возвращает комментарий без изменений |
| АЛОР | поле comment заявки (до 100 символов), UUID с дефисами как есть, и тот же текст в заголовке X-REQID команды. Комментарий возвращается в списке заявок, в потоке и в сделках | да |
| Bybit | поле orderLinkId (до 36 символов), UUID с дефисами как есть. Повтор того же orderLinkId биржа отвергает (код 110072), но как долго она помнит закрытую заявку, в документации не названо | да |
У всех «защиту от повторной отправки той же заявки» библиотека пока считает отсутствующей (LabelOnly).
Что делает брокер с повтором, измерят только живые прогоны. Это не меняет правило: повторять заявку нельзя.
Что происходит в place
Порядок проверок у всех брокеров один, и он показан на схеме круга: Карта.
Сначала шлюз команд, потом проверка входа без сети, потом квота у счетчика лимитов, потом очередь по бумаге, потом шлюз
еще раз, и только после этого cid помечается использованным и команда уходит к брокеру. Почему именно так:
- Дешевые проверки первыми. Шлюз, вход и квота не требуют ни сети, ни ожидания. Отказ на любой из них ничего не
тратит: ни
cid, ни времени в очереди. - Квота до очереди. Отказ по квоте приходит сразу, а не после ожидания за другими командами по бумаге.
- Шлюз проверяется второй раз после очереди. Команда могла простоять в очереди за другой командой по той же бумаге, и
за это время шлюз закрылся: оборвался поток заявок или исполнений (
Event::Reconnected), переполнился буфер (Overflow), началась остановка. Закрытый шлюз значит, что в сеть ничего не уходит, и это верно и для команды, которая уже стоит в очереди: она получаетDegradedилиShuttingDown, а не уходит к брокеру. Дляcancelиcancel_stopтак же. cidпомечается как можно позже, прямо перед отправкой. Пока вызов стоит в очереди, номер чистый: если вызов отменили или он не дошел до отправки, номером можно воспользоваться снова. Пометка сразу после очереди и до сети: второйplaceс тем жеcidувидит ее и не уйдет.- Отказ
duplicate_placeприходит уже после квоты и очереди, поэтому единица квоты на него потрачена, хотя в сеть ничего не ушло.
Все, что находится выше отправки, не доходит до брокера: можно не сомневаться, что заявки нет. На схеме ниже
- тот же путь во времени: бот, UD-коннектор и брокер.
%%{init: {'sequence': {'width': 90, 'actorMargin': 40, 'mirrorActors': false}}}%%
sequenceDiagram
participant Bot as Бот
participant UD as UD-коннектор
participant Br as Брокер
Bot->>UD: place(cid, бумага, цена)
UD->>UD: шлюз, квота, очередь по бумаге
UD->>Br: заявка с cid
alt принята
Br-->>UD: номер брокера
UD-->>Bot: OrderAck
else отказ
Br-->>UD: ошибка
UD-->>Bot: Rejected
else нет ответа
UD-->>Bot: Unknown, без повтора
Bot->>UD: order_state(cid)
end
Br-->>UD: статусы и исполнения
UD-->>Bot: Event::Order, Event::Execution
Ответ на place и статусы приходят разными путями: ответ - результатом вызова, статусы и исполнения - событиями в
общем канале. Порядок между результатом вызова и событием не гарантирован: событие может прийти и после ответа, и раньше него. Поэтому после OrderAck заявка еще не исполнена, это только «брокер принял». Ждать исполнения нужно
по Event::Order и Event::Execution, а не по ответу.
Исходы команды
Любая команда (place, cancel, replace, стоп-команды) заканчивается одним из трех видов исхода, и они отвечают
на главный вопрос: дошла ли команда до брокера.
- Не дошла. Закрыт шлюз (
NotReconciled,Degraded,ShuttingDown), не прошла проверка входа илиUnsupported, не хватило квоты (RateLimited), неверная настройка (Config). Заявки нет, делать с ней нечего, исправляется причина. - Дошла и получила ответ.
Ok(OrderAck)- брокер принял.Rejected- брокер точно отказал, заявки нет. - Дошла, ответа нет.
Unknown(иTransportна команде, если брокер мог ее получить). Заявка могла встать. Это единственный исход, где нельзя ни повторять, ни считать, что заявки нет.
Таблица «исход -> что значит -> почему бывает -> что делать» лежит в справочнике ошибок, там же коды
Rejected. Особенности перевода ответов в исходы:
- БКС. Отказ может прийти не ошибкой HTTP, а внутри обычного ответа 200 (статус заявки 8,
rejectReason). Библиотека читает оба случая и возвращаетRejected. - Т-Банк. Брокер может принять запрос и ответить статусом «отклонено» внутри ответа. Это тоже
Rejected(кодREJECTED). - Проверка входа до отправки у всех одна. Количество: положительное, целое там, где брокер берет целые (БКС, Т-Банк),
и кратное лоту, если лот известен. Иначе
Rejectedс кодомinvalid_qty. Ничего не округляется: дробное количество там, где брокер берет целые, это отказ. Количество в единицах инструмента, не в лотах, у любого брокера (Деньги, цены и количества). Цену коннектор сверяет с шагом, если шаг ему передан (price_grid()): цена не на шаге -Rejected { price_off_step }до отправки, квота иcidне тратятся. Идентификатор инструмента другого брокера (напримерSymbolMicФинама у Т-Банка) этоConfig, без сети. Как округлить цену точно и как передать шаг - Деньги, цены и количества.
Как это выглядит в коде
Номер придумывает бот и записывает в журнал до вызова. Вкладка выбирает язык, выбор запоминается для всей книги.
let cid = request.cid; // made by the bot: ClientOrderId::new_v4()
journal.sending(cid)?; // on disk BEFORE the call
let result = ud.place(request).await;
cid = brokapi.new_cid() # made by the bot
journal.sending(cid) # on disk BEFORE the call
result = await client.place(cid=cid, instrument=instrument, side=brokapi.Side.BUY, qty=qty, price=price)
Исходы разбираются так, что «заявки точно нет» и «заявка могла встать» никогда не путаются. На Unknown и Transport повтора нет,
есть вопрос брокеру по тому же cid:
let cid = request.cid;
match ud.place(request).await {
Ok(_) | Err(Error::Rejected { .. }) => {}
Err(Error::Unknown | Error::Transport(_)) => {
// The order may exist. NEVER place again. Ask about THE SAME cid (see the recovery page for the loop).
let _state = ud.order_state(cid).await;
}
Err(other) => return Err(other.into()),
}
cid = brokapi.new_cid()
state = None
try:
await client.place(cid=cid, instrument=instrument, side=brokapi.Side.BUY, qty=qty, price=price)
except brokapi.Rejected:
pass # the broker said no: there is no order
except (brokapi.Unknown, brokapi.Transport):
# The order may exist. NEVER place again. Ask about THE SAME cid (see the recovery page for the loop).
state = await client.order_state(cid)
В Python это исключения brokapi.Rejected, brokapi.Unknown и brokapi.Transport из brokapi.errors: те же имена и тот же текст, что у
Error в Rust. Если задачу, которая ждет place, отменить в Python, команда все равно доходит до конца: после этого тоже
спрашивайте order_state.
Почему нет автоповтора
Заявка, которая ушла к брокеру и осталась без ответа, может находиться в трех состояниях: брокер ее не получил, получил и поставил, получил и отклонил. Из «нет ответа» этого не понять. Если отправить заявку еще раз, а первая все-таки встала, на счете окажутся две заявки. Для денег это худший исход, поэтому библиотека повторяет только то, что безопасно (чтение), и никогда не повторяет команды.
То же относится к тому, что библиотека не делает сама после ошибки: не пробует другую форму запроса, не меняет количество и цену, не ставит заявку заново.
У всех брокеров таймаут на place дает
Unknown, второй вызов с тем же cid не уходит в сеть (duplicate_place), а заявка, которую брокер все-таки принял,
находится через order_state.
Что делать вместо повтора: спросить брокера по cid, это описано в Если что-то пошло не так.
cancel
cancel(cid) снимает заявку по вашему cid. Исходы те же, что и у place. Особенности:
Ok(())значит: брокер подтвердил отмену. У БКС команда синхронная, и «отменено» говорится только тогда, когда брокер сам ответил, что заявка отменена.- Заявка уже исполнилась: БКС вернет
Rejectedс кодомalready_filled, остальные - отказ брокера. Unknown: отмену можно повторить вручную после проверкиorder_state. Повторная отмена новых заявок не создает. Библиотека сама ее не повторяет.- У БКС, если после синхронной отмены заявка по ответу еще жива, результат тоже
Unknown: «отменено» не объявляется без подтверждения. Так бывает со статусом 6 «Отменяется (в процессе отмены)»: заявка еще стоит в стакане и может исполниться раньше, чем отмена дойдет до конца. Такой статус приходит как состояниеUnknown, неCancelled.Cancelledбудет, когда брокер пришлет статус 4, а если заявка успела исполниться, придетFilled. - Отмена идет через отдельное ведро лимитов, постановка ее не вытесняет (Лимиты запросов).
replace: изменить заявку
replace(ReplaceOrderRequest { cid, original_cid, qty, price }) меняет количество и (или) цену. cid - новый
номер, original_cid - заявки, которую меняем. Количество и цена, которых вы не указали, берутся от исходной
заявки (коннектор для этого один раз читает ее у брокера).
| Брокер | Как | Особенности |
|---|---|---|
| БКС | POST /orders/edit/sync: брокер снимает исходную заявку и ставит новую под новым cid | У частично исполненной заявки количество нужно указать явно (qty_required). Что означает количество при правке (весь объем или остаток), в документации не сказано: укажите количество явно |
| Т-Банк | ReplaceOrder. Новый cid становится ключом идемпотентности | Биржевой номер после замены может измениться, ориентируйтесь на cid |
| Финам | нет. replace возвращает Unsupported("replace_order") сразу, без сети | В API Финама такого метода нет. Отмену и новую заявку библиотека не подменяет: они не атомарны (старая может исполниться между шагами, новую могут отклонить после отмены). Решение за ботом |
| АЛОР | PUT лимитной заявки по номеру: брокер снимает исходную и ставит новую под новым cid в комментарии | Рыночную заявку заменить нельзя (not_replaceable). У частично исполненной количество нужно указать явно (qty_required) |
| Bybit | нет. replace возвращает Unsupported сразу, без сети | Изменение заявки у биржи (POST /v5/order/amend) оставляет заявку и ее orderLinkId, а replace обещает новую заявку под новым cid. Одно за другое не выдается: снимите заявку и поставьте новую, либо измените через raw() и помните, что cid прежний |
Unknown на replace неприятнее, чем на place: непонятно, что с обеими заявками. Спрашивайте order_state по
обоим номерам, и по исходному, и по новому.
Машина состояний заявки
Состояние приходит в Event::Order (поле state в OrderSnapshot) и в ответе order_state. На схеме: состояния
заявки и переходы между ними. Каждый переход - событие Event::Order, и в каждом есть cid.
stateDiagram-v2 [*] --> InFlight: place ушел к брокеру InFlight --> Acked: брокер принял InFlight --> Конец: отказ, Rejected InFlight --> Unknown: таймаут или обрыв Unknown --> Acked: order_state или сверка Unknown --> Конец: выяснилось Acked --> PartiallyFilled: часть исполнена PartiallyFilled --> PartiallyFilled: еще исполнение Acked --> Конец PartiallyFilled --> Конец state "Конец: Filled,<br>Cancelled, Rejected" as Конец Конец --> [*]
Из Unknown библиотека сама не выходит: только запрос состояния или сверка. Это сделано намеренно: любое «само
выяснилось» было бы догадкой, а на догадке нельзя ставить вторую заявку. Из конечных состояний выхода нет, поэтому
событие о конце можно считать окончательным.
| Состояние | Значение |
|---|---|
InFlight | команда отправлена, ответа еще нет. Внутреннее состояние |
Unknown | таймаут или обрыв, а у БКС еще статус 6 «отменяется»: заявка может быть жива. Выход только через запрос состояния или сверку. Библиотека сама из него не выходит |
Acked | брокер принял, заявка стоит в стакане. Есть номер брокера |
PartiallyFilled | исполнена часть: известно, сколько исполнено (filled) и сколько осталось (left) |
Filled | исполнена целиком. Конечное |
Cancelled | снята (по вашей отмене, по замене, по сроку). Конечное |
Rejected | отклонена, с причиной. Конечное |
Статусы брокеров переводятся в эти состояния. Статус, который библиотека не знает, у Финама считается рабочей
заявкой, а неопределенный статус Т-Банка переводится в Unknown.
Порядок команд по бумаге
Команды по одному инструменту выполняются строго по очереди, в порядке вызова: отмена, отправленная после
постановки, не обгонит ее. Если бы обгоняла, отмена пришла бы к брокеру раньше заявки, ответила бы «такой нет», а
потом заявка встала бы и осталась на счете, хотя бот считает ее снятой. Реализовано блокировкой на инструмент
(InstrumentLocks). Особенности:
- Ключ блокировки -
symbol@mic, а не весь идентификатор. Один инструмент, названный тикером,uidилиfigi, - одна очередь. - Блокировка снимается при любом исходе: отказ, ошибка сети, отмена вызова.
- Отказ брокера бумагу не блокирует. Это проверяется отдельным сценарием (в БКС однажды ошибка оставляла бумагу занятой до перезапуска).
- Для
cancelбумага берется из заявок, которые поставил этот процесс. Если заявка осталась с прошлого запуска и бумага процессу неизвестна,cancelв очередь не встает. - Команды по разным бумагам идут параллельно.
Сколько ждать ответа
Время ожидания ответа на команду задается в настройке клиента брокера: у Т-Банка и БКС 30 секунд по умолчанию,
у Финама 10 секунд. По истечении ответ Unknown. Пока команда ждет, она занимает очередь по бумаге, поэтому
следующие команды по той же бумаге ждут за ней. Слишком короткий таймаут плодит Unknown на заявках, которые
брокер успел бы принять, слишком длинный держит очередь по бумаге.
Что бот хранит
Минимум для работы с заявками: журнал cid (что отправлено, что подтверждено, что закрыто), пометка «проверить» у
заявок с неясным исходом и номер брокера (broker_id), если он нужен для отмены стопов у Т-Банка
(Стоп-заявки). Что хранит бот, а что библиотека: Круг жизни.
Круг жизни: шлюз и сверка
После запуска библиотека не знает, какие заявки у бота были раньше: она ничего не хранит на диске. А на счете
могут стоять заявки с прошлого запуска, заявки, которые исполнились, пока бот был выключен, и заявки, поставленные
руками. Сверка (reconcile) разбирает это и только после нее открывается шлюз торговых команд. Это третий круг
механики: он решает, можно ли сейчас торговать. Схема трех кругов вместе - на Карте.
Шлюз команд
У каждого UD есть шлюз торговых команд (CommandGate). Чтение работает всегда: order_state, active_orders,
executions, portfolio, подписки. Закрывается только торговля: place, cancel, replace, place_stop,
cancel_stop возвращают ошибку и в сеть не уходят. На схеме: состояния шлюза и переходы между ними.
stateDiagram-v2 state "Не сверен<br>NotReconciled" as NR state "Торговля открыта" as OPEN state "Перегрузка<br>Degraded" as DEG state "Остановка<br>ShuttingDown" as SD [*] --> NR: start NR --> OPEN: reconcile успешен OPEN --> DEG: Overflow в Lossless-буфере DEG --> OPEN: бот дочитал канал и reconcile OPEN --> SD: shutdown NR --> SD: shutdown DEG --> SD: shutdown SD --> [*]
Шлюз закрыт в трех случаях, и у каждого своя причина:
NotReconciled: после запуска. Пока бот не знает, какие заявки уже стоят у брокера, новая команда может задвоить заявку или спорить с той, которую бот не видит.Degraded: переполнился буфер подписки без потерь. События о своих заявках могли пропасть, и локальная картина заявок с дырой. Торговать при дыре нельзя (Круг событий).ShuttingDown: идет остановка. Выхода нет, коннектор закрывается (Запуск, рестарт и остановка).
Если закрыто несколько условий одновременно, первым проверяется ShuttingDown, затем Degraded, затем
NotReconciled. Успешная сверка снимает NotReconciled и Degraded. Из обоих закрытых состояний выход один -
сверка. Что делает каждая ошибка шлюза и как из нее выйти: Ошибки и что с ними делать.
Что бот передает и что получает
Бот передает список cid заявок, которые он считает живыми (открытыми или с неизвестным исходом). Получает
ReconcileReport с тремя списками:
| Список | Что в нем | Что делать |
|---|---|---|
confirmed | заявки, которые есть и у бота, и у брокера. Пришли целиком (OrderSnapshot): состояние, исполнено, цена | Продолжать вести. Обновить в журнале состояние и исполненный объем |
closed_offline | cid из вашего списка, которых нет среди живых у брокера: заявка исполнилась, снята, отклонена или так и не была принята, пока бот был выключен | Выяснить итог: order_state(cid) и история executions(...). Занести итог в журнал |
foreign | заявки, которые есть у брокера, но нет в вашем списке: поставлены руками, из приложения или другим роботом | Не трогать. Не снимать и не менять. Решение, что с ними делать, принимает человек |
У чужих заявок cid подставной: брокер вашего номера не знает. Он стабильный (один и тот же для одной заявки) и
выглядит как UUID, но выдан библиотекой из номера брокера, а не вами. Почему foreign нельзя трогать: бот не
знает, зачем человек поставил эту заявку, и снятие может сломать чужую стратегию или защитный стоп.
В коде порядок такой. Подписка идет до сверки, сверка принимает список из журнала, три списка разбираются каждый по-своему:
// Second run, after a restart. The gate is closed after `start`: commands return NotReconciled.
let (ud, mut events) = start_ud(cfg).await?;
// Subscribe BEFORE the reconcile: an event between the snapshot and the subscription would be lost.
ud.subscribe_order_state().await?;
// The journal says which orders the bot thinks are alive.
let report = ud.reconcile(&journal).await?;
for order in &report.confirmed {
println!(
"confirmed {}: still at the broker, filled {}",
order.cid,
order.filled.decimal()
);
}
for cid in &report.closed_offline {
// Find out how it ended: order_state(cid) and executions(...), then write it to the journal.
println!("closed offline {cid}: ask order_state and write the result");
}
for order in &report.foreign {
// Not ours. Do not touch: a person decides.
println!("foreign {}: shown to a person", order.cid);
}
# After a restart the gate is closed: commands raise NotReconciled.
# Subscribe BEFORE the reconcile: an event between the snapshot and the subscription would be lost.
await client.subscribe_order_state()
# The journal says which orders the bot thinks are alive.
report = await client.reconcile(ledger)
for order in report.confirmed:
print(f"confirmed {order.cid}: still at the broker, filled {order.filled}")
for cid in report.closed_offline:
# Find out how it ended: order_state(cid) and executions(...), then write it to the journal.
print(f"closed offline {cid}: ask order_state and write the result")
for order in report.foreign:
# Not ours. Do not touch: a person decides.
print(f"foreign {order.cid}: shown to a person")
Первая торговая команда идет только после этого. Сначала подписка, потом сверка, потом заявка, а до сверки заявка получает
NotReconciled:
ud.subscribe_order_state().await?; // before the reconcile, or an event in between is lost
let report = ud.reconcile(&journal.live()).await?; // an empty slice for a bot without a journal
// report.confirmed, report.closed_offline, report.foreign: handle them (see the lifecycle page)
ud.place(request).await?; // before the reconcile this is Error::NotReconciled
await client.subscribe_order_state() # before the reconcile, or an event in between is lost
report = await client.reconcile(journal.live()) # an empty list for a bot without a journal
# report.confirmed, report.closed_offline, report.foreign: handle them (see the lifecycle page)
await client.place(cid=brokapi.new_cid(), instrument=instrument, side=brokapi.Side.BUY, qty=qty, price=price)
# Before the reconcile the line above raises brokapi.NotReconciled.
Порядок на схеме такой: бот поднимает UD (шлюз закрыт), подписывается на статусы, берет из журнала cid живых
заявок, зовет reconcile, получает три списка, шлюз открывается, итоги идут в журнал.
%%{init: {'sequence': {'width': 90, 'actorMargin': 30, 'mirrorActors': false}}}%%
sequenceDiagram
participant J as Журнал бота
participant Bot as Бот
participant UD as UD, коннектор счета
participant Br as Брокер
Bot->>UD: start, шлюз закрыт
Bot->>UD: подписка на заявки и сделки
J->>Bot: cid живых заявок
Bot->>UD: reconcile(cid)
UD->>Br: заявки счета
Br-->>UD: список
UD-->>Bot: три списка
Note over UD: шлюз открыт
Bot->>J: итоги в журнал
Если reconcile вернул ошибку (нет связи, лимит), он не открыл шлюз: подождите и вызовите еще раз. Ошибка при
этом ничего не говорит о заявках, сверка просто не состоялась. Пока шлюз закрыт, ставить заявки нельзя, и это
правильно: бот все еще не знает, что стоит на счете.
Почему порядок именно такой
Пошаговая инструкция - на странице Запуск, рестарт и остановка бота. Здесь причины:
- Подписка идет до сверки. Сверка делает снимок счета в одно мгновение. Событие, которое пришло между снимком и подпиской, иначе было бы потеряно: в снимке его еще нет, а подписка его уже не получит. Подписки при закрытом шлюзе разрешены именно поэтому.
- Список
cidберется из журнала бота. Только бот знает, какие заявки он считал живыми. Библиотека не пишет на диск, и без этого списка она не отличит ваши заявки от чужих. reconcileтолько читает, поэтому его можно вызывать в любой момент и сколько угодно раз. Он не снимает и не меняет заявок. Единственное, что он меняет, - состояние шлюза.- Сверка не открывает шлюз при ошибке. Если часть списка не прочиталась, результат нельзя считать полным.
Как сверка устроена у брокеров
| Брокер | Как | Нюансы |
|---|---|---|
| Т-Банк | один запрос списка живых заявок счета. cid из вашего списка, которого среди живых нет, идет в closed_offline | в списке нет заявок, закрытых за время обрыва, их находят через order_state и executions |
| БКС | решает ответ на GET по каждому cid вашего списка, не список заявок. Заявка жива (новая, частично исполнена, заменяется, отменяется): confirmed, со снимком из этого ответа. Заявка закончилась (исполнена, отменена, отклонена) или брокер не знает номер (not_found): closed_offline. Затем читается список активных заявок, из него берутся только foreign | пустой ответ без отчета и любая другая ошибка по одному из cid прерывают всю сверку: по неясному ответу заявку «закрытой» не объявляют, иначе бот поставит вторую. Что настоящий БКС отвечает на неизвестный cid именно not_found, проверить на живом счете |
| Финам | один запрос списка заявок счета, cid каждой заявки читается из ее комментария. Не найденный в списке cid и найденный, но не активный, идут в closed_offline | зависит от того, что Финам возвращает комментарий заявки без изменений и что отдает GetOrders для закрытых заявок |
| АЛОР | два запроса: список заявок дня и список стоп-заявок. cid каждой заявки читается из ее комментария. cid из вашего списка, которого нет в списках, и найденный закрытый идут в closed_offline, работающая стоп-заявка идет в confirmed | заявки прошлых дней, если они не живы, в списке дня не видны: такой cid попадает в closed_offline, итог закрытой заявки читает order_state или executions. Номер, которого брокер не знает, не запрашивается |
| Bybit | один запрос списка рабочих заявок счета, cid каждой читается из orderLinkId. cid из вашего списка: жив (в том числе ожидающая стоп-заявка) - confirmed, иначе поиск по orderLinkId в списке и в истории: закрыта или не найдена - closed_offline. Чужие рабочие заявки - foreign | отмененную заявку без исполнений биржа держит в истории 24 часа, дальше она не находится, и reconcile относит ее к closed_offline («не видел сегодня»), как у остальных брокеров |
Список БКС ограничен: в нем только заявки с 26 января 2026 года и не больше 1000 последних (ограничение документации
БКС). Поэтому живая заявка может в нем не оказаться, и именно поэтому судьбу заявки из вашего списка решает GET, а список
нужен только для foreign. Окно поиска списка заканчивается на сутки позже локальных часов: часы машины могут отставать от
брокерских, и свежая заявка не должна выпасть.
Стоп-заявки
Обычная сверка стоп-заявки не показывает: ни в confirmed, ни в foreign. cid стоп-заявок в ваш список для
reconcile класть не надо: они попадут в closed_offline, потому что среди обычных живых заявок их нет.
Для стопов после старта вызывайте active_stop_orders() (см. Стоп-заявки). У Т-Банка после
рестарта cid в этом списке пустой, снимать стоп можно только по номеру брокера.
Что хранит бот, а что нет
| Что | Где хранится |
|---|---|
Журнал заявок: cid, состояние, номер брокера, исполнено | бот |
| Какие заявки считались живыми на момент остановки | бот (из журнала) |
| Номера брокера стоп-заявок Т-Банка | бот, если нужна отмена после рестарта |
Связь cid и номера брокера, состояние заявок текущего запуска | библиотека, в памяти, после остановки исчезает |
| Подписки | библиотека, в памяти. После рестарта бот подписывается заново |
Библиотека не ведет журнал на диске и не восстанавливает состояние сама. Она дает основу для этого: полный
упорядоченный поток событий (каждый переход состояния заявки - событие), cid в каждом событии заявки и сверку,
которая сравнивает список бота со счетом.
Когда звать reconcile, кроме старта (после Overflow, после долгого обрыва, при сомнении в журнале): Если что-то
пошло не так.
Лимиты запросов
Брокеры ограничивают число запросов и на превышение отвечают отказом. Чтобы не получать отказы в самый неудобный момент, библиотека считает запросы сама и до отправки: если квоты нет, запрос не уходит в сеть вообще. Счетчик один на ту область, где брокер считает квоту (Карта), а область у каждого брокера своя: Несколько счетов и брокеров.
Как устроен счетчик
Счетчик (BrokerLimiter) - это набор «ведер» (token bucket). У каждого ведра три параметра: скорость пополнения
(rate запросов за per) и размер (burst). Ведро стартует полным и пополняется равномерно, а не шагами. Один
запрос тратит одну единицу. Тихий клиент может отправить burst запросов сразу, занятой удерживается на rate.
Каждый метод API привязан к одному или нескольким ведрам (маршрут). Например, у БКС и Финама:
PlaceOrder = ["global", "orders"]
CancelOrder = ["global", "cancel"]
Постановка тратит общее ведро global и ведро заявок, отмена - общее и свое. Ведро global общее для всех методов
брокера: оно же защищает от суммарного превышения, а отдельные ведра - от перекоса внутри.
Что происходит при вызове
- Коннектор берет из ведер маршрута по одной единице. Если одного ведра не хватило, уже взятые единицы возвращаются: частично потраченная квота не пропадает.
- Ведер хватило - запрос уходит к брокеру.
- Не хватило - вызов сразу возвращает
RateLimited { retry_after }, в сеть ничего не ушло.
Счетчик никогда не ждет сам, ждать должен бот. Ожидание внутри вызова скрыло бы задержку от бота и держало бы
очередь по бумаге (Круг команд), а решение «подождать, пропустить или отказаться»
принадлежит боту. Что делать с RateLimited: Ошибки и что с ними делать.
retry_after - подсказка, сколько ждать до следующей попытки. Это не обещание, что следующий вызов пройдет: другое
ведро маршрута может пополняться дольше.
Метод без маршрута (например, вызванный через raw(), см. raw) тратит общее ведро брокера (маршрут
"*"). Бесплатных вызовов нет: иначе один неучтенный метод мог бы исчерпать настоящую квоту брокера и вызвать отказы
у всех остальных.
Почему отмена в отдельном ведре
Отмена не должна ждать за потоком постановок: если заявка ушла не туда, снять ее нужно именно тогда, когда бот
ставит много. Поэтому у отмены свое ведро (cancel у БКС и Финама, order_cancel у Т-Банка), а ведро заявок (orders, у Т-Банка
order_post) меньше общего. Поток постановок упирается
в него раньше, чем исчерпает global, и для отмены остается место в обоих ведрах.
У этой защиты есть граница: отмена тоже тратит global. Если исчерпать его чтением (стаканы, справочник, история),
отказ получит и отмена. Поэтому читайте с запасом и не гоните массовые запросы справочника в то время, когда в рынке
стоят ваши заявки.
Свои числа: формат файла
Файл лимитов - TOML: ведра (rate запросов за per, запас burst), маршруты методов к ведрам и квоты соединений. Раздел
называется по брокеру ([bcs], [tbank], [finam], [alor], [bybit]) или как угодно, если читать общей функцией.
[bcs.buckets.orders]
rate = 10 # запросов за период
per = "1s"
burst = 3 # сколько можно потратить сразу
[bcs.route]
"*" = ["orders"] # метод без своего маршрута (см. выше)
PlaceOrder = ["orders"]
[bcs.conns]
max_conns = 4
max_subs_per_conn = 50
supports_multiplex = false
Читают его brokapi::bcs::limits::limits_from_toml_str (и limits_from_toml_path, так же у АЛОРа и Bybit, у Т-Банка brokapi::tbank::limits, у Финама
brokapi::finam::limits::parse_limits), либо общая BrokerLimits::from_toml_str из brokapi::runtime, которая берет единственный
раздел файла. Дальше BrokerLimiter::new(&limits): он возвращает Arc<BrokerLimiter>, делится он через Arc (.clone() у Arc), а не копией.
Полный разбираемый файл, который дает «первая заявка прошла, вторая получила RateLimited». Лишние ключи разбор пропускает: старый файл с dedicated_command_conns читается, ключ ничего не делает (его убрали, потому что ни один коннектор его не читал).
retry_after показывает, сколько ведру нужно до одного токена (при per = "2s" порядка 1,8 секунды сразу после траты):
[bcs.buckets.orders]
rate = 1
per = "2s"
burst = 1
[bcs.route]
"*" = ["orders"]
PlaceOrder = ["orders"]
[bcs.conns]
max_conns = 4
max_subs_per_conn = 50
supports_multiplex = false
Рабочий пример малого бюджета, чтобы поймать RateLimited, - small_budget в examples/research.rs. burst = 0 не пропускает
ничего никогда, а retry_after в этом случае все равно показывает период: ведро не пополнится само.
Чтобы получить «один раз отказали, потом прошло»: burst = 1 и длинный per, первый вызов проходит, второй сразу получает RateLimited.
Откуда числа
Из файлов limits.toml в каталогах крейтов (crates/bcs, crates/tbank, crates/finam, crates/alor, crates/bybit). Каждый файл встроен в свой
крейт и доступен как limits::default_limits() (у всех одинаково). Свои числа можно загрузить из строки или
файла (limits_from_toml_str, limits_from_toml_path, у Финама parse_limits).
Это осторожные значения, и они разной надежности:
- Т-Банк публикует свои лимиты, они взяты из документации (в ней же сказано, что реальные значения динамические и
их возвращает
GetUserTariff). - Финам называет одну цифру на весь API: 200 запросов в минуту. Остальное выбрано ниже этой цифры.
- АЛОР публикует 100 HTTP-запросов в секунду на пользователя, 10 соединений WebSocket, 5000 подписок на соединение и
5000 необработанных сообщений в буфере соединения. Ведро на 100 в секунду для всех методов и мягкое ведро
placeна 60 для постановки, замены и стоп-заявки (запас для отмены). Защиту «социальный рейтинг» (баллы за действия, блокировка логина на 2 часа) брокер описывает без порогов, коннектор их не считает. - БКС публикует таблицу: 10 запросов в секунду на каждый сервис, 3 для неторговых операций (история операций), для
- Bybit публикует лимиты на пользователя и метод (10 запросов в секунду на создание, изменение, отмену и «снять все», 50 на чтения) и на адрес (600 запросов за 5 секунд, сверх этого HTTP 403 и бан адреса на 10 минут). Библиотека держит ведро на каждый вид команды и общее ведро адреса на 100 в секунду. Заголовки остатка лимита (
X-Bapi-Limit-Status) она не читает, счет ведется у нее самой. сервиса заявок еще 200000 запросов в сутки. Ведро на сервис, всплеск 10 (одна секунда), для постановки заявки еще мягкие ворота на 8 в секунду (не лимит брокера: поток заявок не съест весь бюджет сервиса и отмене остается место), суточное ведро считает только то, что отправил этот процесс. Как БКС считает секунду и сутки (окно или скольжение, календарные сутки или 24 часа), в документации не сказано.
Занижать безопаснее, чем завышать: лишнее ожидание стоит секунд, а отказ брокера приходит на боевом счете в произвольный момент.
Квоты соединений
Счетчик хранит и квоты соединений (ConnQuota): сколько соединений и подписок на соединение допускает брокер. Он их
только хранит. Коннектор решает, что с ними делать. Сейчас это использует Финам: число одновременных потоков ограничено,
и при превышении подписка получает Rejected с кодом subscription_quota.
Деньги, цены и количества
Цена, количество, лот, баланс и комиссия в библиотеке - точные десятичные числа. f64 для них нигде не используется.
Страница объясняет, почему так, как создавать числа, как округлять цену до шага и что делает библиотека при переводе
форматов брокеров.
Python. Цена, количество и деньги - decimal.Decimal. В вызов можно передать Decimal, int или строку, float отвергается с TypeError. Округление до шага - brokapi.round_to_step(цена, шаг, RoundMode.DOWN), правила те же, что у round_to_step в Rust. Время - datetime с часовым поясом.
Три типа
| Тип | Что это | Где встречается |
|---|---|---|
Price | цена одной бумаги | заявки, стакан, сделки, свечи, stop_price, price_step |
Qty | количество в единицах инструмента, размер лота | заявки, стакан, сделки, исполнения, позиции, объем свечи, lot_size, min_qty |
Money | сумма денег | баланс (available, blocked), комиссия, price_step_value |
Внутри у каждого Decimal (крейт rust_decimal): целое на 96 бит и до 28 знаков после запятой. Типы разные нарочно:
количество нельзя подставить вместо цены, а сумму в рублях вместо количества. Библиотека отдает Decimal и макрос dec!
из brokapi_core.
Почему не f64
Брокеры присылают цены десятичными числами, и сетка биржи тоже десятичная. В двоичной дроби 244.3 не лежит ровно:
assert_ne!(0.1_f64 + 0.2, 0.3); // 0.30000000000000004
assert_ne!(244.3_f64 % 0.01, 0.0); // остаток 6.3e-15: цена «не на шаге»
assert_ne!((109.32_f64 / 0.01).fract(), 0.0); // 10931.999999999998 шагов
Для заявки это значит: цена, которая на бирже верная, у вас «не кратна шагу», а округление до шага уезжает на одну
копейку. Суммы портфеля набегают копейками. С Decimal те же числа точные: 0.1 + 0.2 == 0.3, 244.3 кратно 0.01.
Эталон, NautilusTrader, хранит цену, количество и деньги целыми числами с фиксированным масштабом. Мы взяли другое:
Decimal со своим масштабом у каждого числа. Брокеры дают разное число знаков (Т-Банк до девяти, БКС до восьми у цены,
Финам строку любой длины), и один общий масштаб либо режет цену, либо сужает диапазон. Скорость целых нам не нужна: это
слой подключения, а не движок, между числом и биржей сетевая задержка в миллисекунды.
Как создать число
use brokapi::core::{Decimal, Price, Qty, dec};
let a = Price::new(dec!(250.15)); // из литерала
let b: Price = "250.15".parse()?; // из текста: файл, форма, переменная окружения
let c = Price::from(250); // из целого
let d = Price::from_scaled(25015, 2); // из мантиссы и числа знаков: 250.15
assert_eq!(a, b);
let lots = Qty::from(10);
let sum: Qty = [Qty::from(1), Qty::from(2)].into_iter().sum();
- Запятая не разделитель:
"12,5"- ошибка разбора (NumberError::Parse). - Числа сравниваются по значению:
Price244.30равно244.3. А текст сохраняет запись:244.30печатается244.30. Price * DecimalдаетPrice(так считается «на 1% ниже»:bid * Decimal::new(99, 2)). Масштаб произведения больше:244.30 * 0.99печатается241.8570. Послеround_to_stepдо шага 0.01 значение241.85, а печать может быть241.8500: значение то же, масштаб записи не важен.- Из
f64число делается только явно и с округлением до заданного числа знаков:Price::from_f64_lossy(244.3, 2). Имя говорит, что точность потеряна:f64уже принес двоичный шум, округление его только прячет. Обратно -to_f64_lossy(), для графика или строки лога, но не для цены заявки. - В JSON число - строка:
"244.30". Так его не округлит ни один читатель.
Как округлить до шага цены
Шаг цены и размер лота лежат в TradingParams (Справочник API). Округление точное, сетка начинается с нуля:
use brokapi::core::{Price, RoundMode, dec};
let step: Price = "0.05".parse()?;
let p: Price = "101.03".parse()?;
assert_eq!(p.round_to_step(step, RoundMode::Down)?, "101.00".parse()?); // покупка: не дороже расчета
assert_eq!(p.round_to_step(step, RoundMode::Up)?, "101.05".parse()?); // продажа: не дешевле расчета
assert_eq!(p.round_to_step(step, RoundMode::Nearest)?, "101.05".parse()?); // ближайшая, половина - от нуля
assert!(p.round_to_step(step, RoundMode::Up)?.is_on_step(step));
Если параметры инструмента уже прочитаны (trading_params), короче так: params.round_price(price, mode) и
params.round_qty(qty, mode). Нулевой или отрицательный шаг - ошибка NumberError::StepNotPositive.
Проверка цены коннектором
Коннектор не знает шаг сам: он узнает его от вас. У каждого коннектора заявок есть price_grid(). Заполните его один раз
на инструмент:
let params = md.trading_params(&sber).await?;
ud.price_grid().learn(¶ms);
Дальше place, replace и place_stop проверяют цену заявки (и stop_price, limit_price). Цена не на шаге -
Rejected с кодом price_off_step, до отправки: квота не тратится, cid остается свободным. Если шаг инструменту
не передан, цена не проверяется, решает брокер. Таблицу можно задать и при создании: поле price_grid конфига
UserDataConfig, одну на несколько коннекторов (копии таблицы - один и тот же список).
Количество проверяется так же и по той же таблице: price_grid().learn(¶ms) запоминает и шаг цены, и размер лота. Количество,
которое не является целым числом лотов, это Rejected с кодом invalid_qty, до отправки. Если размер лота инструменту не
передан, количество с лотом не сверяется, решает брокер (у Т-Банка лот коннектор читает сам, см. ниже). Нуль, отрицательное и
дробное количество отказывают всегда, где брокер берет только целые. Ничего не округляется: округленная заявка купила бы не то,
что вы просили.
Количество: единицы инструмента, не лоты
Qty везде значит число единиц инструмента: штук акций, облигаций, единиц валюты, контрактов. Одно и то же в заявке, в
уровне стакана, в сделке рынка, в исполнении, в позиции и в объеме свечи, у любого брокера. Так же считает
NautilusTrader и сама биржа. Брокер, который считает лотами (Т-Банк), переводится коннектором в обе стороны: ваш код лотов не видит.
Что такое лот и единица на разных рынках:
| Рынок | Единица (Qty = 1) | Лот (TradingParams::lot_size) | Пример |
|---|---|---|---|
| Акции | одна акция | число акций в лоте на бирже: 1, 10, 100 | у SBER лот 10: заявка на 30 это три лота |
| Облигации | одна облигация | обычно 1 | цена в процентах от номинала, а не в рублях: Price 98,5 значит 98,5% номинала. Сколько это в деньгах, считает бот: номинал, процент и накопленный купон |
| Валюта на бирже | одна единица валюты | обычно 1000 | у пары с лотом 1000 заявка на 3000 это три лота |
| Фьючерсы и опционы | один контракт | 1 | цена в пунктах, стоимость шага в деньгах - price_step_value. Сколько базового актива в контракте, библиотека не передает |
Правила, одинаковые для всех:
- Количество заявки положительное и кратное лоту. Иначе
Rejectedс кодомinvalid_qty, ничего не отправлено,cidне потрачен. - Лот узнается из
TradingParams::lot_size(в единицах), минимальное количествоmin_qtyравно одному лоту. - Позиция, сделка и объем свечи приходят в единицах так, как их присылает брокер, коннектор пересчитывает только то, что брокер считает лотами.
Что делает каждый коннектор:
| Брокер | Что считает API | Что делает коннектор |
|---|---|---|
| БКС | orderQuantity в штуках (документация: «Количество в заявке (шт.)»), целое не меньше 1 | передает число как есть. Дробное и нуль: invalid_qty. Кратность лоту проверяется, если лот передан в price_grid() |
| Т-Банк | заявки, стакан, сделки рынка и свечи в лотах, свои сделки и позиции в штуках | узнает лот инструмента один раз (GetInstrumentBy, запрос к квоте) и переводит: заявку делит на лот, стакан, сделки рынка, свечи и статусы заявок умножает. Не кратно лоту: invalid_qty до отправки |
| Финам | штуки, число десятичное | передает как есть. Кратность лоту проверяется, если лот передан в price_grid() |
| АЛОР | заявка в лотах, ответы заявок, сделок и позиций и в лотах, и в штуках, а уровни стакана, лента и свечи - как пришло (единицу объема в них документация не называет); объем лучших цен в котировках - в лотах | читает размер лота один раз (lotsize справочника), делит заявку на лот и читает штуки из ответов. Некратное лоту: invalid_qty, ничего не округляется. Объем котировки умножает на лот |
| Bybit | количество в единицах базовой монеты, десятичное, у каждого контракта свой шаг (qtyStep) и минимум (minOrderQty) | читает правила контракта один раз и до отправки отказывает: не кратное шагу или меньше минимума invalid_qty, цена не на шаге price_off_step. Минимальную стоимость заявки и предельное количество не проверяет: ответит биржа |
Как числа переводятся из формата брокера
Ни один путь не идет через f64. Что делает каждый коннектор:
| Брокер | Формат | В библиотеку | Из библиотеки |
|---|---|---|---|
| Т-Банк | Quotation и MoneyValue: целая часть units и миллиардные доли nano, у обеих частей знак числа | units + nano / 10^9, точно | на части: до 9 знаков, иначе Rejected { invalid_price }, молчаливого округления нет |
| Финам | google.type.Decimal (строка, допускает знак и порядок) и google.type.Money (units, nanos) | строка разбирается как есть | строка из числа без порядка, запись сохраняется: 244.30 уходит как "244.30" |
| БКС | числа JSON | сразу в Decimal из текста ответа (serde_json с arbitrary_precision) | JSON-числом с теми же знаками |
| Bybit | числа в строках JSON ("0.073547") | сразу в Decimal из текста ответа | строкой с теми же знаками |
Что именно сохраняется: 244.30 не теряет нуль на конце, 0.0001 остается 0.0001, число с 19 знаками не
обрезается. Это проверено тестами на всех форматах, в том числе случайными числами туда и обратно.
Проценты в моделях БКС (доля в портфеле, доходность, изменение в процентах, ставка купона) - это
отношения, а не деньги, цены или количества: поэтому они f64. Деньги и цены в brokapi-bcs-protocol - Decimal.
Брокеры
Общий интерфейс одинаков для всех брокеров, но сами брокеры отличаются. Эта страница - сводка различий, дальше по странице на каждого: БКС, Т-Банк, Финам, АЛОР, Bybit. Bybit - зарубежный сервис, остальные - российские брокеры.
| БКС | Т-Банк | Финам | АЛОР | Bybit | |
|---|---|---|---|---|---|
| Протокол | REST и WebSocket | gRPC | gRPC | REST и WebSocket | REST и WebSocket |
| Вход | refresh-токен, из него токен доступа | один статичный токен | секрет, из него JWT, обновляется сам | refresh-токен с портала разработчика, из него токен доступа (30 минут), обновляется сам | пара ключ и секрет, каждый запрос подписывается (HMAC-SHA256) |
| Идентификатор инструмента | тикер и класс (SBER, TQBR) | uid (его отдает справочник), figi или тикер и класс | SBER@MISX (тикер и MIC) | тикер и режим торгов (SBER, TQBR). Биржа (MOEX или SPBX) берется из режима торгов | тикер контракта и категория (BTCUSDT@linear) |
| Что считает API в заявке | целое число штук (по документации) | лоты, целое. Коннектор переводит | штуки, число десятичное | лоты, целое. Коннектор переводит | количество базовой монеты, десятичное |
Qty в библиотеке | штуки, одинаково у всех. Не целое, нуль, некратное лоту (если лот передан): invalid_qty | штуки. Коннектор делит на лот и умножает обратно | штуки. Некратное лоту (если лот передан): invalid_qty | штуки. Коннектор читает размер лота из справочника, делит и умножает обратно. Некратное лоту: invalid_qty | единицы базовой монеты, одинаково у всех. Не кратное шагу qtyStep или меньше minOrderQty: invalid_qty |
| Глубина стакана | до 20 | 1, 10, 20, 30, 40, 50 (запрошенное округляется вверх) | до 50 (режем на своей стороне) | до 50 (больший запрос режется) | 1, 50, 200, 1000 (ближайшая не меньше запрошенной, лишнее отрезается) |
| Изменить заявку | да (синхронный метод БКС) | да | нет (метода нет в API) | да (лимитную, рыночную нельзя) | нет (изменение у биржи оставляет заявку и ее orderLinkId, это не новая заявка), Unsupported. Через raw() есть |
| Поток своих сделок | из потока статусов заявок | отдельный поток сделок | отдельный поток сделок | отдельная подписка на сделки портфеля | отдельный поток исполнений |
| Свечи | все, кроме 10-минуток (Unsupported) | все | все, 10-минутки собираются из 5-минуток | все, 10-минутки есть у брокера | все, кроме 10-минуток (Unsupported) |
| Стоп-заявки | условные заявки | отдельный сервис | обычная заявка типа стоп и метод SL/TP | стоп-заявка (рыночная при срабатывании) и стоп-лимит | обычная заявка с ценой срабатывания, рыночная или лимитная. Только «до отмены» |
| Песочница | нет | да | нет | нет, но есть отдельный тестовый контур | да, демо-счет с виртуальными деньгами (env=demo) |
| Срок обычной заявки | выбирает БКС | выбирает Т-Банк | всегда «на день» | «на день» | лимитная «до отмены», рыночная исполняется сразу или отменяется |
| Где лимиты | на токен (по документации) | на пользователя | на пользователя (по документации) | на логин (100 HTTP-запросов в секунду) | на пользователя и метод (10 команд и 50 чтений в секунду) и на адрес (600 за 5 секунд) |
| Пауза перед переподключением | 0,5 с до 30 с (настраивается) | 0,5 с до 30 с | 0,5 с до 30 с | 0,5 с до 30 с (настраивается) | 0,5 с до 30 с (настраивается) |
Что общее у всех
- Два коннектора: MD и UD. UD на один счет (у АЛОРа на один портфель, у Bybit на один счет или субсчет ключа).
cidбота и правила заявок одинаковы (Круг команд). Меняется только то, кудаcidкладется.- Все ответы на неясные команды -
Unknown, повторов нет. - Стакан приходит полным снимком (у Bybit коннектор сам собирает его из снимка и изменений и отдает целиком).
- Шлюз команд после запуска закрыт до
reconcile, и закрывается снова приOverflowи приReconnected(оборвался поток своих заявок или исполнений), и команда, которая стояла в очереди, проверяется шлюзом еще раз. - Ошибки одного вида: отказ токена это
Config, “не найдено” этоRejectedс кодомnot_found, идентификатор инструмента другого брокера этоConfigбез сети. raw()у каждого коннектора.
Что у брокеров устроено по-разному
- Счета. У БКС счет задает токен, у Т-Банка и Финама -
account_id, у АЛОРа - портфель и биржа в настройке, у Bybit счет определяет ключ. - Лимиты. У каждого брокера своя область, см. Несколько счетов и брокеров.
- Единицы количества. Одинаковые:
Qtyэто число единиц инструмента (штуки, контракты), лотов бот не видит ни у одного брокера. Т-Банк и АЛОР считают лотами, коннектор переводит. Размер лота даетTradingParams::lot_size, заявка кратна ему (Деньги, цены и количества). - Что не поддерживает брокер. Помечено на странице брокера. Такие вызовы возвращают
Unsupportedсразу, без сети.
БКС
Крейты: brokapi-bcs-protocol (REST и WebSocket клиент) и brokapi-bcs (коннекторы).
Вход
| Что | Как |
|---|---|
| Токен | refresh-токен БКС (BCS_REFRESH_TOKEN). Из него коннектор получает токен доступа. Счет определяет сам токен: account_id в настройке нет |
| Чтение и запись | токены бывают на чтение и на запись. Для торговли нужен токен на запись и BCS_IS_WRITE=true (в коде BcsConfig::with_write(true)). Без этого брокер торговые команды не примет |
| Песочница | нет. Торговля идет на настоящем счете |
| Обновление | токен доступа обновляется заранее и после ответа 401 на чтение (чтение повторяется один раз). Команда после 401 не повторяется. Подробно: Токены |
| Лимиты | brokapi::bcs::limits::default_limits(): встроенный файл limits.toml крейта, как у Т-Банка и Финама. Свой файл: limits_from_toml_str и limits_from_toml_path. Числа из таблицы ограничений брокера (10 запросов в секунду на сервис, 3 для неторговых операций, 200000 заявок в сутки) |
Где взять токен
Токен выпускает человек, чей это счет (правило 0). Нужен хотя бы один брокерский счет в БКС Мир инвестиций. Шаги по документации БКС:
- Войти в веб-версию БКС Мир инвестиций (
lk.bcs.ru). - Открыть «Профиль», под заголовком «Счета и тарифы» нажать на брокерский счет, к которому нужен доступ.
- В настройках счета выбрать «Токены API», нажать «Выпустить токен» и следовать инструкциям.
- Скопировать токен и сохранить сразу: брокер показывает его один раз.
Какой токен выбрать. Токены бывают двух видов: «для торговли и чтения данных» и «только для чтения». Для первого запуска возьмите «только для чтения»:
им работают портфель, стакан и свечи, а заявку брокер не примет. Токен для торговли нужен, когда человек решил поставить первую боевую заявку, и тогда же
включается BCS_IS_WRITE=true.
Сколько живет и как отозвать. Refresh-токен действует 90 суток, токен доступа из него 24 часа (его получает коннектор). Токен привязан к одному счету, одновременно действующих токенов можно выпустить сколько угодно. Удалить токен можно в тех же «Токенах API». После удаления токена для торговли его токен доступа перестает работать сразу, у токена «только для чтения» может работать до 24 часов. Номер счета в настройках не нужен: счет определяет токен.
Идентификатор инструмента
NativeId::TickerClass { ticker, class_code }, например SBER и TQBR. Ключ библиотеки - SBER@TQBR. Любой другой
вид идентификатора даст ошибку настройки (Config): справочник для БКС не нужен, идентификатор собирается сам без
сети. Расписание торгов у БКС по инструменту: в schedule передается "SBER@TQBR", иначе Config.
Единицы и стакан
| Что | Как |
|---|---|
| Числа | цены, количества и суммы читаются из JSON в Decimal по тексту ответа, 244.30 остается 244.30. |
| Количество | штуки, целое не меньше 1: в документации БКС orderQuantity это «Количество в заявке (шт.)». Коннектор передает число как есть. Дробное, нулевое и отрицательное это Rejected { invalid_qty } до отправки, ничего не округляется. Кратность лоту проверяется, если лот передан в price_grid(). Лот и шаг цены коннектор сам не знает: price_grid().learn(¶ms). Что orderQuantity в штуках, сказано в документации БКС |
| Глубина стакана | до 20. Больший запрос молча режется до 20 |
| Стакан | полный снимок. Поток идет по WebSocket, снимок по REST |
| Котировки | отдельный поток WebSocket |
| Свечи | таймфреймы M1 M5 M15 M30 H1 H4 D W MN. D1 и W1 библиотека шлет как D и W. 10-минуток нет, Unsupported |
| Лента сделок | last_trades отдает сделки текущей торговой сессии, оставляя новейшие limit штук |
Как устроены команды
БКС работает синхронными методами: POST /orders/sync, /orders/cancel/sync, /orders/edit/sync. Они ждут
решения брокера и отвечают состоянием заявки. Из этого следует:
- Отказ известен сразу как
Rejected, а не через минуту из потока. Отказ может прийти кодом HTTP 4xx или внутри ответа 200 со статусом заявки 8 и причиной. Оба случая даютRejected. - В
OrderAckприходит настоящий номер брокера (orderId), тот же, что в списке заявок и в потоке. - Отмена объявляется успешной, только если брокер ответил, что заявка отменена (статус 4). Заявка исполнилась раньше -
Rejected { already_filled }. Ответ оставил заявку живой -Unknown. Статус 6 «Отменяется (в процессе отмены)» это не отмена: заявка еще в стакане и может исполниться. В потоке и в ответах он приходит как состояниеUnknown(неCancelled),cancel()возвращаетUnknown, сверка считает такую заявку живой.Cancelledпридет со статусом 4,Filled- если заявка исполнилась. - Прежние формы запросов (с номером заявки в пути) коннектор не вызывает. Они остались в
brokapi-bcs-protocolи доступны черезraw().
Как cid уходит к брокеру
В поле clientOrderId как UUID с дефисами. Обратимо. В списке активных заявок БКС поля clientOrderId нет, только
номер брокера. Поэтому active_orders и reconcile определяют cid так: по тому, что процесс видел сам, иначе
одним запросом на каждую неизвестную заявку (по ее номеру), иначе дают заявке подставной cid. Если на счете много
чужих заявок, сверка тратит много запросов, учитывайте квоту.
Порядок как у Т-Банка и Финама: шлюз команд, проверка входа, квота, очередь по бумаге, шлюз еще раз и только потом cid помечается использованным.
Поэтому после RateLimited (и после ошибки входа вроде missing_price) тот же cid годен: заявка никуда не ушла. То же для
place_stop. Общее правило: Круг команд.
Сообщение канала orders/events/ws несет два идентификатора: clientOrderId (по документации идентификатор заявки, в примере
для исполнения он пустой) и originalClientOrderId (идентификатор измененной заявки). cid берется так: если один из них
не UUID, то другой. Если оба UUID и сообщение об отмене, замене или отмене в процессе (статусы 4, 5, 6), то originalClientOrderId,
иначе clientOrderId. Так сообщение об отмене, где clientOrderId это номер запроса на отмену, попадает на ту заявку, которую
отменили, а не на чужой cid. Какой идентификатор в каком поле приходит для каждого вида сообщения, взято из документации БКС. Исправление (executionType 13) не создает новое исполнение: оно пересказывает прежнее сообщение, настоящую
поправку сделок дает история (executions). Ошибка подписки на рыночные данные не содержит подписки, только код и текст: она
валит ту подписку, чей тикер назван в тексте, а если подписка одна в ожидании, то ее. Если по тексту не понять, ошибка валит
все ожидающие.
Изменение заявки
Есть: POST /orders/edit/sync, брокер снимает исходную заявку и ставит новую под новым cid. Количество и цена,
которых вы не указали, берутся от исходной (один запрос). Если заявка исполнена частично, количество нужно указать
явно, иначе Rejected { qty_required }.
Свои сделки, портфель
| Что | Как |
|---|---|
| Поток исполнений | из единого канала статусов orders/events/ws: сообщения с lastQuantity > 0, каждый executionId один раз. Режим Lossless. Цена исполнения считается по приросту накопленных величин: сообщения несут итоги по заявке (executedQuantity, executionValue, averagePrice), а не числа одного исполнения. Цена = (стоимость после минус стоимость до) / (количество после минус количество до) между двумя сообщениями той же заявки. Два исполнения по разным ценам дают две разные цены, не среднюю. Если предыдущего сообщения коннектор не видел (подключился посреди заявки), цена это averagePrice заявки: верно для одного исполнения, приблизительно для нескольких. Точная цена каждой сделки в истории (executions) |
| История | POST /trades/search, все страницы: по 100 записей (size = 100), пока не прочитан totalPages, каждая страница тратит квоту SearchTrades. Список БКС содержит не больше 1000 последних сделок: если ответ говорит о 1000 и больше (totalRecords), это Rejected { range_too_long }, а не список, который выглядит полным. Запись плоская, без cid и без комиссии (cid пуст, комиссия 0) |
| Портфель | снимок REST. Поток по WebSocket. Деньги и позиции берутся из одного ответа |
| Список активных заявок | POST /orders/search, только заявки с 26 января 2026 года и не больше 1000 последних (ограничение документации) |
Стоп-заявки
Условные заявки (orderType = 5). Принимается только срок «до отмены» (срок жизни условной заявки документация
не описывает), поле срока не отправляется. Направление срабатывания выбирает брокер. Пара «стоп-лосс плюс
тейк-профит» не создается. Потока статусов нет. Подробно: Стоп-заявки.
Чего БКС не поддерживает или делает иначе
| Что | Как |
|---|---|
| Свечи 10 минут | Unsupported("candles_m10") |
Справочник по виду: опционы и Other | Unsupported. Остальные виды работают, список читается целиком: страницами по 100, пока страница полная. Каждая страница тратит квоту, а список длиннее 200 страниц - ошибка, не обрезок |
| Поток стоп-заявок | Unsupported |
| Песочница | нет |
| Кадры между WebSocket и разбором | между сессией WebSocket и задачей разбора стоит канал на 512 кадров. Если он заполнится (задача разбора встала), кадр отбрасывается со счетчиком и записью в лог, без Overflow. |
| Перечитывание заявок после обрыва | нет. Но обрыв сокета orders/events/ws виден боту: Event::Reconnected на подписки заявок и исполнений и закрытый шлюз до reconcile (Круг событий) |
Лимиты
crates/bcs/limits.toml, по таблице «Ограничения» из документации брокера: у каждого сервиса свое ведро на 10 запросов
в секунду (портфель, лимиты, справочник, заявки, статус заявки, сделки, дисконты, рыночные данные), у неторговых операций
(история операций) 3 в секунду, у заявок еще суточное ведро на 200000. Заявка, отмена, замена, стоп-заявка и список заявок
тратят общее ведро сервиса заявок. До 4 соединений (канал заявок). Всплеск 10, то есть одна секунда. Постановка, замена и стоп-заявка проходят еще через ведро place на 8 в секунду
(всплеск 4): это не лимит брокера, а запас, чтобы поток заявок не занял весь бюджет сервиса и отмене осталось место. Как БКС считает секунду (фиксированное окно или скользящее) и сутки (календарные или 24 часа), в документации не сказано:
если упретесь в RateLimited раньше времени, уменьшите значения в limits.toml. Суточное ведро считает только этот процесс: на одном счете из двух процессов оно не видит общего числа.
Что учесть
- Количество в уровнях стакана и ленты в документации БКС не названо. Для расчета объема сверяйтесь с количеством в своей заявке.
- Если на запрос заявки по неизвестному
cidБКС ответит неNOT_FOUND, сверка отнесет заявку к неопределенным. Проверьте это на минимальной заявке. - Суточный лимит на заявки считает только ваш процесс. Два процесса на одном счете его не видят совместно.
Т-Банк
Крейты: brokapi-tbank-protocol (gRPC клиент, собранный из официальных .proto) и brokapi-tbank (коннекторы).
С песочницы Т-Банка рекомендуется начинать: Перед боевым счетом.
Вход
| Что | Как |
|---|---|
| Токен | один статичный токен (TBANK_TOKEN). Обновлять нечего. Отвергнутый токен - ошибка настройки (Config). По документации Т-Банка новый токен нужно начать использовать в течение 7 дней после выпуска, иначе его отзовут. Выпускается только на сайте tbank.ru |
| Счет | account_id в настройке (TBANK_ACCOUNT_ID). Не задан: берется единственный открытый счет токена, а при нескольких счетах коннектор не запустится |
| Песочница | да. TBANK_SANDBOX=true выбирает адрес песочницы, caps().sandbox показывает это |
| Адрес | боевой и песочницы заданы, можно подменить TBANK_ENDPOINT (только https://, адрес имитации задается в коде). Свой корневой сертификат (например, национальный) добавляется TbankConfig::with_root_certificate_pem, встроенные корни остаются. HTTP/2 keep-alive идет раз в 5 минут и только пока открыт вызов или поток |
| Имя приложения | TBANK_APP_NAME (заголовок x-app-name, по соглашению Т-Банка <ник>.<репозиторий>) |
| Лимиты | brokapi::tbank::limits::default_limits(), один счетчик на пользователя |
Где взять токен и счет
Токен выпускает человек, чей это счет (правило 0). Шаги по документации Т-Банка:
- Открыть настройки профиля Т-Инвестиции на компьютере (с телефона страница может переадресовать в приложение, где раздела токенов нет)
и войти. Выпустить токен можно только на сайте
tbank.ru. Если страница не открывается, нужны сертификаты НУЦ Минцифры или Яндекс Браузер. - Убедиться, что «Подтверждение сделок кодом» выключено: по документации Т-Банка оно должно быть отключено. Сделки на сумму больше 30 000 000 рублей требуют подтверждения и через API недоступны.
- Выпустить токен T-Invest API для биржи и/или для песочницы. Система может попросить войти еще раз.
- Скопировать токен сразу: его показывают один раз, позже получить его нельзя.
Какой токен выбрать. Виды: только чтение, полный доступ, токен для переводов между счетами (его не берите), токен с доступом к одному счету (права чтения или полного доступа настраиваются) и токен песочницы. Для первого запуска возьмите токен песочницы (быстрый старт на Python). Для чтения боевого счета берите токен «только чтение». Токен с полным доступом нужен, когда человек решил поставить первую боевую заявку, и лучше с доступом к одному счету. Токен песочницы к боевому адресу не подойдет.
Номер счета. Если токен выпущен для одного счета, TBANK_ACCOUNT_ID задавать не нужно: берется его единственный открытый счет. Если токен на все счета и счетов
несколько, коннектор не запустится и просит указать счет. Для Инвесткопилки, Счета под ключ и Смарт-счета токен «на один счет» выпустить нельзя, у них только токен на все
счета. Где в кабинете смотреть номер счета, документация Т-Банка не говорит: в Rust список счетов дает TbankClient::list_accounts.
Счет песочницы. Токен песочницы сам счета не открывает. Счет создает вызов OpenSandboxAccount, пополняет SandboxPayIn (только рубли, до 30 000 000 рублей), счета
живут 3 месяца с последнего использования. В Rust это методы sandbox()-клиента (crates/tbank/tests/live_sandbox.rs), в Python и Gateway готовых команд нет, остается
raw или любой другой клиент T-Invest API.
Срок. Новый токен надо начать использовать в течение 7 дней после выпуска, иначе его отзовут. Токен живет три месяца с даты последнего использования. Ответ 40003
значит, что токен неактуален. Посмотреть и отозвать токены: настройки безопасности, блок «Приложения, у которых есть доступ к данным для входа».
Идентификатор инструмента
Коннектор принимает любую из трех форм:
| Форма | Когда использовать |
|---|---|
NativeId::Uid | основная. Ее отдает справочник, и в событиях брокер сам называет инструмент так |
NativeId::Figi | если есть figi |
NativeId::TickerClass | тикер и класс (SBER, TQBR), уходит брокеру как SBER_TQBR |
Ключ библиотеки - SBER@TQBR. Найти инструмент и его uid: md.find(InstrumentQuery::ByTicker { .. }). Событие
потока несет тот InstrumentId, который вы указали при подписке.
Единицы и стакан
| Что | Как |
|---|---|
| Количество | в библиотеке штуки (единицы инструмента), в API Т-Банка лоты для заявок, стакана, сделок рынка и свечей. Коннектор переводит в обе стороны: заявку делит на лот, стакан, сделки, свечи, статусы заявок и стоп-заявки умножает. Своих сделок (OrderTrade.quantity, история операций) и позиций это не касается: брокер отдает их штуками, они идут как есть. Лот инструмента (Instrument.lot, он же lot_size сообщения потока заявок) коннектор узнает одним запросом GetInstrumentBy при первой заявке, подписке или чтении по инструменту и запоминает. Заявка, не кратная лоту, дробная, нулевая или отрицательная - Rejected { invalid_qty } до отправки. Если лот прочитать нельзя, чтение дает Transport, а событие потока заявок с неизвестным лотом считается потерянным: придет Overflow и шлюз закроется до reconcile |
| Цена | за одну бумагу. Quotation и MoneyValue (units и nano) переводятся в Decimal и обратно точно. Цена с больше чем 9 знаками - Rejected { invalid_price } до отправки |
| Глубина стакана | 1, 10, 20, 30, 40, 50. Запрошенная глубина округляется вверх до допустимой, события режутся до нужной. Больше 50 - 50 |
| Стакан | полный снимок. Если брокер пометил стакан несогласованным (is_consistent = false), он не публикуется, вместо него Resync { SequenceGap } |
| Котировки | отдельного потока нет: котировка - лучший уровень стакана. Стакан и котировки одного инструмента идут одной подпиской брокера |
| Сделки рынка | сделка без направления в MarketTrade не помещается (сторона обязательна) и пропускается |
| Свечи | все таймфреймы. close_ts = открытие + длина таймфрейма (месяц считается как 30 дней) |
last_trades | сделки за последний час, новейшие limit штук |
Как cid уходит к брокеру
В поле order_id запроса PostOrder. По документации Т-Банка это ключ идемпотентности «в формате UID, до 36
символов». UUID с дефисами занимает ровно 36, поэтому влезает без потерь. Тот же ключ брокер возвращает как
order_request_id в ответе и потоке, а CancelOrder и GetOrderState принимают его с типом идентификатора «запрос».
Идемпотентность не используется: что сервер делает с повторным ключом, никто не измерял (LabelOnly, вопрос 1 ниже).
Библиотека place сама не повторяет в любом случае.
Заявка, поставленная не вами (с телефона, другим роботом), приходит без order_request_id. Чтобы она могла попасть в
foreign, коннектор выдает ей подставной стабильный cid, вычисленный из номера заявки на бирже.
Ответы брокера
Номер ошибки Т-Банка (пятизначный, из таблицы ошибок)
коннектор читает из текста статуса, а если там пусто, из начала заголовка message. Статус RESOURCE_EXHAUSTED у
Т-Банка не всегда означает лимит запросов: так же отвечает про лимит стоп-заявок по инструменту. Поэтому его различают
по номеру. Номер, которого нет в таблице, остается лимитом запросов.
| Ответ | Результат |
|---|---|
RESOURCE_EXHAUSTED с кодом 80002, 80003, 80006 или без известного кода | RateLimited. Время ожидания берется из заголовка x-ratelimit-reset, иначе 1 секунда (для 80003 и 80006, которые брокер считает в минуту, 60 секунд) |
RESOURCE_EXHAUSTED с кодом 80007 | Rejected { code: "stop_orders_limit" }: по инструменту набрано максимум стоп-заявок. Повтор не поможет, снимите одну из них |
RESOURCE_EXHAUSTED с кодом 80001 (открыто максимум потоков) | Rejected { code: "streams_limit" } |
RESOURCE_EXHAUSTED с кодом 80004 или 80005 (потоки) | Rejected с кодом no_subscriptions или stream_lifetime |
INVALID_ARGUMENT с кодом 30261 (слишком много стоп-заявок в одном ответе) | Rejected { code: "stop_orders_response_limit" }, см. «Стоп-заявки» ниже |
NOT_FOUND | Rejected с кодом not_found (как у остальных брокеров) |
INVALID_ARGUMENT, PERMISSION_DENIED, FAILED_PRECONDITION, ALREADY_EXISTS и подобные | Rejected с кодом статуса |
UNAUTHENTICATED | Config (токен не подошел) |
Таймаут, UNAVAILABLE, INTERNAL и все остальное на команде | Unknown (брокер мог выполнить) |
| Внутри обычного ответа статус «отклонено» | Rejected { code: "REJECTED" } |
Изменение заявки
ReplaceOrder. Нужны биржевой номер и количество исходной заявки: берутся из того, что коннектор уже знает, иначе
один запрос GetOrderState. Цена не указана - берется цена исходной. Новый cid становится ключом идемпотентности.
Биржевой номер после замены может измениться, ориентируйтесь на cid.
Свои сделки, портфель
| Что | Как |
|---|---|
| Поток исполнений | TradesStream. Комиссии в нем нет (0), cid определяется по номеру заявки. Сделка может прийти раньше ответа на PostOrder: пока заявка в пути, такая сделка ждет ответа и уходит с cid (или без него, если ее заявку никто не заявил) |
| История | GetOperationsByCursor: исполненные покупки и продажи, в том числе маржинальные (margin-call) и оплаченные картой, по сделке на Execution. Комиссия операции делится на количество, broker_order_id - номер операции, cid нет. Читается не более 20 страниц по 1000 операций: период, в котором их больше, дает ошибку Rejected с кодом range_too_long, а не обрезанный список |
| Портфель | два запроса: деньги из GetPositions, позиции из GetPortfolio. Поток из двух потоков (PortfolioStream, PositionsStream) под одним номером подписки |
| Заявки после обрыва потока | бот получает Event::Reconnected на подписки заявок и исполнений, шлюз закрывается до reconcile. Коннектор перечитывает активные заявки и присылает изменившиеся. Закрытые за время обрыва находите через order_state и executions |
Справочник
findпо тикеру и ISIN: быстрый поиск (запись короткая, полеcurrencyпустое, нужную валюту берите черезget).find(ByKind): списки акций, облигаций, ETF, фьючерсов, опционов, валют (базовый список, торгуемое через API). Запросы тяжелые, у них свое ведро лимитов (15 в минуту), кеша нет. Для опционов используется метод, помеченный как устаревший: если Т-Банк его уберет, станетUnsupported.MutualFundиOther:Unsupported.price_step_valueравен шагу цены. Для фьючерсов стоимость шага в деньгах не вычисляется.
Стоп-заявки
Отдельный сервис стоп-заявок: PostStopOrder, GetStopOrders, CancelStopOrder. Срок: «до отмены» и «до даты».
«До конца дня» нет. Количество в штуках, как у заявок (брокеру уходят лоты). Брокер не возвращает cid стоп-заявки. Связь с номером хранится в памяти
процесса, после рестарта в списке cid пустой и снимать стоп нужно по номеру брокера. Потока статусов в API нет.
Длинный список. Брокер не отдает слишком много стоп-заявок в одном ответе: ошибка 30261 «ограничьте запрос
параметрами from и to». active_stop_orders сначала просит весь список, а на 30261 делит период от 1970 до 2099
года пополам, пока каждый кусок не поместится, и склеивает ответы (повтор заявки на стыке отбрасывается по номеру).
Это тратит запросы GetStopOrders (60 в минуту, всплеск 10 в crates/tbank/limits.toml): если квота кончилась
посреди чтения, будет RateLimited, а читать придется заново. При длинном списке поднимите burst ведра
stop_orders_get. Не больше 40 запросов за чтение, дальше Rejected { code: "stop_orders_response_limit" }: снимите
лишние стоп-заявки. По какому времени заявки (создания, срока) брокер отбирает период и сколько заявок помещается
в один ответ, документация не говорит.
Потерянный ответ. Если place_stop кончился Unknown, а стоп у брокера есть, cid в списке не вернется сам: брокер его не отдает. Коннектор запоминает,
что просили (бумага, сторона, вид, лоты, цена активации, цена заявки) и когда отправил, и при следующем active_stop_orders дает стопу этот cid, если подходит
ровно один стоп без cid и создан он, пока запрос был в пути (плюс-минус 10 секунд на расхождение часов). Два одинаковых ожидания или два подходящих стопа (один может
быть чужой) - cid не назначается. Это опирается на create_date стопа в списке брокера: как Т-Банк его пишет, вживую не проверено. Если cid не вернулся,
стоп с cid: None на той же бумаге и в ту же сторону не считайте отсутствующим. Повтор запроса с тем же order_id библиотека не использует: ключ ли это идемпотентности для стопов, неизвестно.
Подробно: Стоп-заявки.
Чего Т-Банк не поддерживает или делает иначе
| Что | Как |
|---|---|
| Поток статусов стоп-заявок | в API нет, Unsupported |
Справочник MutualFund, Other | Unsupported |
| Серверный стрим рыночных данных, торговые статусы в потоке, остальное API | общего трейта нет, доступно через raw() |
| Подтверждение отписки в потоках заявок | у серверных потоков нет сообщения отписки, закрывается сам вызов |
Лимиты
Т-Банк считает лимиты на пользователя: на все счета и все токены вместе. Поэтому счетчик один на программу.
В crates/tbank/limits.toml значения из документации брокера, отмена вынесена в отдельное ведро. Реальные значения
отдает GetUserTariff и заголовки x-ratelimit-*: их можно проверить через raw(). До 11 одновременных соединений,
до 300 подписок в одном потоке рыночных данных.
Что учесть
- Повтор
order_id: что сделает Т-Банк (вернет прежний отчет, ошибку или примет вторую заявку), в документации не сказано. Коннектор повторов не делает, поэтому на вас это не влияет, но свой код на повторorder_idне закладывайте. - Единицы количества в потоке своих сделок - штуки (по описанию протокола).
- Номер биржевой заявки после
ReplaceOrderможет измениться. Ориентируйтесь наcid.
Финам
Крейты: brokapi-finam-protocol (gRPC клиент по официальным .proto и менеджер токенов) и brokapi-finam (коннекторы).
Песочницы у Trade API нет: любая проверка идет на настоящем счете, см. Перед боевым счетом.
Вход
| Что | Как |
|---|---|
| Секрет | секрет Trade API (FINAM_SECRET). По нему коннектор получает JWT |
| Счет | account_id обязателен для UD (FINAM_ACCOUNT_ID). При старте проверяется, что счет принадлежит секрету (список счетов приходит вместе с токеном). Нет в списке - Config |
Загрузка из .env | api_config_from_env() сам .env не читает, загрузите его заранее (dotenvy::dotenv()) |
| Адрес | по умолчанию боевой, можно подменить FINAM_API_URL (только https://, адрес имитации задается в коде) |
| Заголовок | JWT передается как есть. Официальные SDK шлют так, часть примеров шлет с Bearer: если сервер откажет, включите FinamApiConfig::bearer. |
| Лимиты | brokapi::finam::limits::default_limits(), один счетчик на пользователя |
| Обновление токена | заранее и в фоне, подробно в Токенах. Один JWT можно отдать нескольким коннекторам (shared_tokens) |
Где взять секрет и номер счета
Секрет выпускает человек, у которого счет в Финаме (правило 0). Секрет Trade API и номер счета берутся в кабинете Финама, в разделе про Trade API (токены или ключи доступа). Пошагового описания кабинета в документации Trade API, на которую мы опираемся, нет, поэтому порядок кнопок здесь не приводится: ищите раздел API в кабинете. Что известно из самой документации:
- Секрет обменивается на короткоживущий JWT (метод
Auth), коннектор делает это сам. МетодTokenDetailsпоказывает срок токена, доступ к рыночным данным и список счетов секрета. Номер счета дляFINAM_ACCOUNT_IDберется из этого списка, в примерах документации он выглядит какA12345. Счет не из списка -Configпри старте. - В
TokenDetailsесть признакreadonly: сессия и счета секрета помечаются «только чтение». Если кабинет позволяет выпустить такой секрет, для первого запуска берите его: портфель, стакан и свечи работают, заявку брокер не примет. Секрет с правом торговли нужен, когда человек решил поставить первую боевую заявку. - Песочницы нет, поэтому после чтения идет первый день на боевом счете.
Первый день на Финаме
У Финама нет песочницы, и любая проверка идет на настоящем счете. План на первый день, от безопасного к боевому. Каждый шаг начинается, когда прошел предыдущий, а шаги 3 и 4 - только со слова владельца счета.
- Имитация. Весь код сначала проходит на
brokapi.testing.mock_finam(): быстрый старт на Python, тамfirst_orderработает с клиентом любого брокера. - Чтение боевого счета.
FINAM_SECRET(лучше только для чтения) иFINAM_ACCOUNT_ID, затемconnect("finam"),portfolio(),order_book(...). Неверный секрет или чужой счет видны наconnect(Config). Заявок на этом шаге нет. - Минимальная заявка далеко от рынка и отмена. Один лот (
trading_params(...).lot_size), цена в 90% от лучшей покупки, округленная вниз до шага. Если биржа отвергла цену как выходящую из допустимого коридора, подвиньте ее ближе к рынку на 5% и повторите с новымcid. - Проверка, что комментарий вернулся. Библиотека кладет полный
cidв комментарий заявки, а Финам должен вернуть его без изменений (Какcidуходит к брокеру). От этого зависитreconcile: если комментарий искажен, ваши заявки придут как чужие, а бот не узнает свою заявку после рестарта. Проверка: поставить заявку, закрыть клиента (заявка остается на бирже), открыть нового и сверить его с журналом из одногоcid. Заявка должна оказаться вconfirmed, аforeignостаться пустым.
async def first_day(connect: Callable[[], Awaitable[brokapi.Client]]) -> bool:
"""Places one lot far below the market, 'restarts' the bot and checks that the broker gave the order back as ours.
`connect` makes a client of the account. Returns True when the comment of the order came back and the order was
cancelled. Anything else is a reason to stop: do not trade on this account until it is clear."""
first = await connect()
sber = await first.instrument("SBER", "MISX")
params = await first.trading_params(sber)
book = await first.order_book(sber, depth=1)
price = brokapi.round_to_step(book.bids[0].price * Decimal("0.9"), params.price_step, brokapi.RoundMode.DOWN)
await first.reconcile([])
cid = brokapi.new_cid() # in a real bot it is in the journal before the next line
await first.place(cid=cid, instrument=sber, side=brokapi.Side.BUY, qty=params.lot_size, price=price)
await first.close() # the process "dies": the order stays at the exchange
# The second run knows only the journal: the cid. If Finam gave the comment back untouched, the order is recognised.
second = await connect()
try:
report = await second.reconcile([cid])
recognised = [order.cid for order in report.confirmed] == [cid]
print(f"the order {'came back as ours' if recognised else 'was NOT recognised'}: {len(report.foreign)} foreign")
for order in report.confirmed:
await second.cancel(order.cid)
print(f"orders left after the cancel: {len(await second.active_orders())}")
return recognised and not report.foreign
finally:
await second.close()
Программа печатает the order came back as ours: 0 foreign и orders left after the cancel: 0. Любой другой результат - повод остановиться и не торговать на этом
счете, пока причина не ясна: сообщите о нем через «Сообщить об ошибке» со строкой вывода (без секрета).
Заявка у Финама всегда «на день», replace нет. Если заявка осталась на счете после остановки бота, ее снимает бот: Аварийная остановка бота.
Идентификатор инструмента
NativeId::SymbolMic, строка SBER@MISX (тикер и код биржи MIC). Ключ библиотеки тот же. Построить идентификатор
без сети: brokapi::finam::util::instrument_from_symbol("SBER@MISX"). Любой другой вид идентификатора - Config.
Единицы и стакан
| Что | Как |
|---|---|
| Количество | штуки (не лоты), так отдает API. Кратность лоту проверяется на нашей стороне, если лот передан в price_grid(): некратное количество - Rejected { invalid_qty } до отправки. |
| Лот для торговли | trading_params: лот из GetAsset (lot_size). Если у токена есть счет, вторым запросом GetAssetParams (по первому счету токена) берется trade_lot_size, «размер лота для торговых операций». Он больше нуля - заменяет lot_size и min_qty. Ноль («значения нет»), отказ брокера на этот вызов - остается лот из GetAsset. Квота: два запроса справочника вместо одного. |
| Глубина стакана | до 50. Финам глубину не документирует: сервер присылает, сколько сочтет нужным, а коннектор режет до запрошенной (по умолчанию и максимум 50) |
| Стакан | Финам присылает строки с действиями «добавить, обновить, удалить» и признаком снимка. Коннектор ведет локальный стакан и всегда отдает боту полный снимок. Поэтому Conflate безопасен |
| Разрыв стакана | приращение без базового снимка: Resync { SequenceGap } и немедленное переоткрытие потока. Поток переоткрыт после обрыва: Resync { SubscriptionReset } |
| Котировка | нулевая покупка или продажа - None |
| Сделки рынка | повторы после переподключения отсекаются по номеру сделки. Сделка без стороны отбрасывается |
| Свечи | M1 M5 M15 M30 H1 H4 D1 W1 MN. 10 минут у Финама нет, коннектор собирает их из 5-минуток: история склейкой пар по границам 10 минут, поток по подписке на M5. Неполная пара отдается как есть |
| Статус торгов | вычисляется из расписания (прямого вызова нет) |
| Расписание | по инструменту: в exchange нужно передать SYMBOL@MIC, иначе Config |
Как cid уходит к брокеру
Поле Финама client_order_id вмещает 20 символов, UUID (128 бит) туда не помещается. Поэтому:
- полный номер кладется в комментарий заявки (до 128 символов) в виде
bc1:и 32 символа. Финам возвращает заявку целиком в состоянии и в сделках, и номер читается оттуда. - в
client_order_idидет метка из 20 символов, полученная из UUID. Обратно в UUID она не превращается, это запасной ключ на случай, если комментарий в ответе пропал.
На старте коннектор проверяет, что оба формата вмещаются в поля брокера: иначе это ошибка настройки, а не поломка на
ходу. Заявка, поставленная не коннектором (из приложения), получает подставной стабильный cid, и такие заявки
попадают в foreign.
Механизм держится на том, что Финам возвращает комментарий заявки без изменений, как сказано в его документации. Если комментарий придет искаженным, сверка не узнает ваши заявки: проверьте это на минимальной заявке до боевого счета.
Ответы брокера
| Ответ | Результат |
|---|---|
RESOURCE_EXHAUSTED | RateLimited (ждать предлагается 1 секунду, брокер срок не называет) |
UNAUTHENTICATED и отказанный вход по секрету | Config (как у остальных брокеров: исправить секрет, повтор не поможет) |
NOT_FOUND | Rejected с кодом not_found |
INVALID_ARGUMENT, PERMISSION_DENIED, FAILED_PRECONDITION и подобные | Rejected |
Таймаут, UNAVAILABLE, INTERNAL и все остальное на команде | Unknown |
| Статус «отклонено», «ошибка», «отказ брокера» внутри ответа | Rejected с описанием |
Чтения один раз повторяются после обновления JWT. Команды не повторяются никогда, в том числе после UNAUTHENTICATED: это Config, и новую попытку бот делает сам с новым cid. Обновление JWT при этом запускается, следующий вызов встретит свежий токен.
Заявки
- Срок обычной заявки всегда «на день» (
TIME_IN_FORCE_DAY). - Изменение заявки отсутствует.
replaceвозвращаетUnsupported("replace_order")сразу и без сети (caps().supports_replace = false). Отмена и новая заявка не атомарны, поэтому библиотека не подменяет одно другим. - Отмена и чтение заявки идут по номеру брокера. Номер коннектор запоминает после постановки, иначе ищет по списку заявок счета.
- Статусы брокера переводятся в общие: новая и ожидающие -
Acked, частично исполненная -PartiallyFilled, исполненная -Filled, снятая и истекшая -Cancelled, отклоненная -Rejected. Неизвестный статус считается рабочей заявкой.
Свои сделки, портфель
| Что | Как |
|---|---|
| Поток исполнений | SubscribeTrades, режим Lossless. cid у исполнения чужой заявки (поставленной руками, не этим процессом) остается None: придуманный cid из потока заявок исполнению не передается |
| История | Trades страницами: один ответ вмещает не больше executions_limit сделок (по умолчанию 1000). Полный ответ значит, что период может содержать больше: коннектор спрашивает остаток периода (от времени последней сделки в сторону, куда отсортирован ответ), склеивает ответы и берет каждую сделку один раз по trade_id. Каждая страница тратит квоту Trades. Если период не помещается (больше сделок с одной временной меткой, чем executions_limit, или больше 100 страниц), это Rejected { range_too_long }, а не обрезанный список. Постраничное чтение работает при любой сортировке ответа. cid читается из комментария |
| Портфель | GetAccount. Позиции включают теоретические: брокер добавляет к открытым позициям те, что создали бы активные заявки, и отличить их нельзя (в ответе нет признака). Покупка на 10 штук, которых нет на счете, может показаться позицией в 10 штук. Объем следующей заявки по этому списку при работающих заявках не считайте, берите позицию из своих исполнений. Деньги по валютам, позиции со знаком. Блокированных денег Финам по валютам не отдает (blocked = 0). Поток SubscribeAccount, каждое сообщение - полное состояние |
| Заявки в сверке | список заявок счета, стоп-заявки отфильтрованы. Из потока обычных заявок они тоже отфильтрованы |
Стоп-заявки
Стоп-лосс и стоп-лимит - обычной заявкой типа стоп (PlaceOrder). Тейк-профит - отдельным методом PlaceSLTPOrder.
Особенности: у тейк-профита нельзя задать цену после срабатывания (там защитный спред, вернется
Rejected { unsupported_limit_price }), срок «до даты» только у тейк-профита, стоп-заявки не показываются в
active_orders и reconcile, cid читается из комментария. Если у чужой SL/TP-заявки единица объема - процент от позиции (sl_qty_measure или tp_qty_measure равны PERCENT), количество в штуках неизвестно и в StopOrderSnapshot::qty стоит 0, а не процент. Свои тейк-профиты коннектор ставит в штуках. Подробно: Стоп-заявки.
Подписки и потоки
Один поток gRPC на подписку, поверх одного соединения HTTP/2 на коннектор. После обрыва поток переоткрывается с
паузой от 0,5 до 30 секунд. Подписка, которую сервер отверг (нет прав, нет инструмента), помечается Failed и
пробуется снова примерно раз в 30 секунд. Открытие потока тратит квоту, как любой запрос. sub_status(sub) показывает
состояние подписки (Connecting, Active, Reconnecting, Failed, Erroring). Когда поток заявок или поток сделок был открыт, оборвался и открылся снова, бот получает Event::Reconnected на эту подписку, и шлюз закрывается до reconcile. Поток счета (портфель) такого события не дает: каждое его сообщение целое состояние.
Чего Финам не поддерживает или делает иначе
| Что | Как |
|---|---|
| Изменение заявки | нет в API, Unsupported |
| Свечи 10 минут | нет у брокера, собираются из 5-минуток |
| Поток стоп-заявок | отдельного нет, Unsupported |
| Песочница | нет |
| Тейк-профит с ценой после срабатывания | нет, защитный спред |
| Числа | google.type.Decimal разбирается из строки как есть, уходит строкой с теми же знаками. Шаг цены - min_step и decimals целыми, без деления с плавающей точкой |
| Стоимость шага цены | не отдается: price_step_value равен шагу цены (верно для акций, не для фьючерсов) |
| Заблокированные деньги по валютам | не отдаются |
Лимиты
Единственная цифра, которую публикует Финам: 200 запросов в минуту. Отдельных лимитов на заявки и на потоки нет. Область
лимита (на секрет, пользователя или счет) брокер словами не называет. Считаем, что на пользователя, поэтому счетчик один
на программу. Значения в crates/finam/limits.toml заниженные, отмена вынесена в отдельное ведро. Если упретесь в RateLimited раньше времени, уменьшите значения.
Что учесть
- Комментарий заявки: Финам должен возвращать его без изменений (
GetOrders,GetOrder,SubscribeOrders, сделки). От этого зависитreconcile. - Повтор
client_order_id: что сделает Финам, в документации не сказано. Коннектор повторов не делает. - Заголовок JWT уходит как есть. Если сервер откажет, включите
FinamApiConfig::bearer.
АЛОР Брокер
Крейты: brokapi-alor-protocol (клиент HTTP и WebSocket: все методы описания API брокера и оба канала WebSocket) и
brokapi-alor (коннекторы).
Вход
| Что | Как |
|---|---|
| Токен | refresh-токен с портала разработчика (ALOR_REFRESH_TOKEN). Из него коннектор получает токен доступа (POST /refresh сервера авторизации). Refresh-токен живет год в боевом контуре и месяц в тестовом, токен доступа - 30 минут |
| Портфель и биржа | ALOR_PORTFOLIO (например D12345) и ALOR_EXCHANGE (MOEX по умолчанию или SPBX). Если портфель не назван, коннектор берет единственный портфель из токена, а если их несколько, не стартует и просит назвать. Один коннектор работает с одной биржей: заявка по бумаге другой биржи это Config без сети |
| Контуры | боевой (api.alor.ru) и тестовый (apidev.alor.ru, нужен тестовый счет, который выдает брокер). Тестовый включается ALOR_ENV=test (в коде AlorConfig::with_env(AlorEnv::Test)), в Python и Gateway - ALOR_TEST=true (test=True в connect). Контуры изолированы, токен одного не годится для другого. Песочницы в смысле Т-Банка нет: тестовый контур это отдельный контур со своими данными |
| Обновление | токен доступа берется заново через 20 минут (или раньше, если его собственный срок короче) и после ответа 401 на чтение (чтение повторяется один раз). Команда после 401 не повторяется. По WebSocket токен идет в каждом сообщении, при переподключении подписки восстанавливаются со свежим токеном |
| Лимиты | brokapi::alor::limits::default_limits(): встроенный файл limits.toml крейта. Свой файл: limits_from_toml_str и limits_from_toml_path. Подробно ниже |
| Рынки | Московская биржа (фондовый, срочный и валютный рынки) и СПБ Биржа. Какие рынки открыты вашему счету, определяет брокер: смотрите в кабинете |
Где взять токен и портфель
Токен выпускает человек, чей это счет (правило 0). Права у refresh-токена АЛОР не выбираются: по документации брокера он равносилен логину и паролю торгового аккаунта, то есть дает и чтение, и торговлю. Токена «только для чтения» в документации нет, поэтому для первого запуска безопаснее тестовый контур, а на боевом контуре токен хранят как пароль. Шаги по документации АЛОР:
Боевой контур (нужен брокерский счет в АЛОР Брокер):
- Зарегистрировать учетную запись разработчика на портале
alor.dev(кнопка «Зарегистрироваться» на странице входа, подтвердить почту). - Открыть страницу «Токены для доступа к API» (
alor.dev/open-api-tokens), ввести логин и пароль торгового аккаунта и нажать «Привязать». К одной учетной записи разработчика привязывается один торговый аккаунт. - Нажать «Выписать токен», затем «Показать» у нового токена в списке. Он действует год. Токены общие для всех разработчиков, у которых привязан этот аккаунт: отзыв одним отзывает для всех.
Тестовый контур (ALOR_TEST=true):
- Учетная запись разработчика та же, что выше.
- Тестовый торговый аккаунт выдает брокер: заявка на
support@alor.ruили через личный кабинетlk.alor.ru. К учетной записи разработчика он не привязывается. - Refresh-токен тестового контура выписывается со страницы
alor.dev/oauth-authorizationпо ссылке «Begin OAuth authorization flow», вход логином и паролем тестового аккаунта. Одновременно существует один такой токен, он действует месяц.
Портфель. ALOR_PORTFOLIO - номер портфеля вида D12345 (по примерам в документации). Если у токена один портфель, переменную можно не задавать. Если портфелей несколько,
коннектор не запустится и просит назвать нужный. Где в кабинете АЛОР смотреть номера портфелей, мы документацией не подтверждаем: ищите в личном кабинете брокера.
Идентификатор инструмента
NativeId::TickerClass { ticker, class_code }, например SBER и TQBR: класс это режим торгов (борд). Ключ библиотеки -
SBER@TQBR. Биржа берется из режима торгов: SPBX это биржа SPBX, любой другой режим это MOEX. Любой другой вид
идентификатора даст ошибку настройки (Config): справочник для АЛОРа не нужен, идентификатор собирается сам без сети.
Единицы и стакан
| Что | Как |
|---|---|
| Числа | цены, количества и суммы читаются из JSON в Decimal по тексту ответа, 244.30 остается 244.30. Формат ответов всегда Simple (оригинальные имена полей) |
| Количество | в библиотеке штуки. Брокер считает заявку в лотах: коннектор один раз читает размер лота из справочника (lotsize), переводит штуки в лоты и обратно. Количество не кратное лоту это Rejected { invalid_qty } до отправки, ничего не округляется. Заявки, исполнения и позиции брокер отдает и в лотах, и в штуках, коннектор читает штуки |
| Стакан | до 50 уровней с каждой стороны (больший запрос режется), всегда полный снимок: брокер присылает стакан целиком, incremental_order_book = false. Объем уровня отдается как пришел |
| Котировки | отдельный поток. Объем лучших цены покупки и продажи брокер дает в лотах, коннектор переводит в штуки по размеру лота из той же котировки |
| Свечи | все таймфреймы, в том числе 10 минут. Брокер принимает любое число секунд, коннектор шлет 60, 300, 600, 900, 1800, 3600, 14400, D, W, M. Закрытие свечи считается как открытие плюс длина таймфрейма |
| Поток свечей | только текущая свеча и следующие: история не приходит, ее читает candles |
| Максимум на запрос свечей | в описании API не назван, candles_max_span не знает предела, и длинная история читается одним запросом, если не задать окно |
| Лента сделок | last_trades отдает сделки текущего дня, новейшие limit. Поток ленты не повторяет сделки, пришедшие с историей подписки |
| Статус торгов | из кода tradingStatus инструмента: 17 и торговые периоды и аукционы это Open, 2 это Break, 18 это Closed |
Как устроены команды
Команды идут по HTTP, по одному запросу, без повтора: заявка, замена и отмена. У брокера есть те же команды по WebSocket
(канал /cws), но выигрыш в скорости по его словам до 5 мс, а HTTP проще и надежнее разбирается при обрыве. Канал /cws
доступен через raw().connect_cws(...).
- Отказ известен сразу:
Rejectedс кодомhttp_<статус>и текстом брокера. - В
OrderAckприходит номер заявки брокера (orderNumber). - Нет ответа, обрыв, таймаут, 5xx и ответ без номера заявки это
Unknown: повторов нет, выход из неизвестности -order_stateиreconcile(Круг команд). - Отмена (
DELETE) успешна, когда брокер принял запрос. Брокер отвечает до того, как биржа подтвердила отмену: подтверждение приходит событием статусаCancelled(подпискаsubscribe_order_state) или чтениемorder_state.
Как cid уходит к брокеру
Дважды: в заголовке X-REQID команды и в поле comment заявки, как UUID с дефисами (36 знаков, комментарий вмещает 100).
Обратимо. Комментарий возвращается в списке заявок, в потоке заявок и в сделках, так cid находится в ответах.
Брокер хранит номер заявки, а не cid, и читает заявку по номеру. Поэтому коннектор помнит пары cid и номер, а для
заявки, которую не ставил этот процесс, ищет cid в комментарии списка заявок дня. Заявка с чужим комментарием получает
подставной cid (стабильный: тот же номер дает тот же cid), по нему ее можно снять.
Документация брокера говорит, что повторный X-REQID получает копию первого ответа и второй заявки не создает.
Библиотека на это не опирается: place_idempotency = LabelOnly, а повтор того же cid она сама не отправляет
(duplicate_place).
Порядок как у остальных брокеров: шлюз команд, проверка входа, размер лота (чтение справочника, если лот еще неизвестен), квота,
очередь по бумаге, шлюз еще раз и только потом cid помечается использованным. После RateLimited и после ошибки входа
тот же cid годен. Общее правило: Круг команд.
Изменение заявки
Есть: PUT лимитной заявки по номеру. Брокер снимает исходную заявку и ставит новую, ответ несет номер новой, под новым
cid в комментарии. Количество и цена, которых вы не указали, берутся от исходной (одно чтение). Если заявка исполнена
частично, количество нужно указать явно, иначе Rejected { qty_required }. Рыночную заявку заменить нельзя:
Rejected { not_replaceable }.
Свои сделки, портфель
| Что | Как |
|---|---|
| Поток исполнений | подписка на сделки портфеля: каждая сделка один раз (повторы по номеру отбрасываются), с cid заявки, если он известен. Комиссия берется из сделки (у срочного рынка брокер ее не отдает: 0) |
| История | GET .../history/trades с даты начала диапазона и сделки сегодняшнего дня. Один ответ брокера вмещает до 1000 сделок: если диапазон заполняет страницу, это Rejected { range_too_long }, а не список, который выглядит полным |
| Портфель | позиции одним запросом. Деньги это позиции валют (isCurrency), коннектор отдает их балансами. Позиция брокера не называет режим торгов, коннектор один раз на бумагу читает основной режим из справочника. Закрытые позиции (нуль) не показываются. Средняя цена нуль это None |
| Поток портфеля | подписка на позиции: сначала текущие позиции, потом изменения. События Positions и Balances |
| Список активных заявок | заявки дня со статусом «на исполнении» |
Стоп-заявки
У брокера два вида: стоп-заявка (при срабатывании выставляется рыночная) и стоп-лимит (лимитная со своей ценой).
| Запрос | У АЛОРа |
|---|---|
StopLoss, TakeProfit без limit_price | стоп-заявка. Условие срабатывания по стороне и виду: стоп-лосс на продажу ждет падения до цены, тейк-профит на продажу ждет роста |
StopLoss, TakeProfit с limit_price, StopLimit | стоп-лимит (StopLimit без limit_price это Rejected { missing_price }) |
Срок: «до отмены» уходит как год вперед (брокер требует время окончания), «до конца дня» это конец дня по Москве,
«до даты» это дата. При чтении стоп-заявка получает вид StopLoss или TakeProfit (по стороне и условию), а стоп-лимит всегда
StopLimit: брокер не хранит, какой из видов имел в виду бот. Потока стоп-заявок нет (subscribe_stop_orders отвечает
Unsupported), список читает active_stop_orders. Подробно: Стоп-заявки.
Чего АЛОР не поддерживает или делает иначе
| Что | Как |
|---|---|
| Расписание торгов | Unsupported("schedule"): у брокера есть только состояние рынка сейчас и код статуса инструмента, расписания сессий нет |
| Справочник по ISIN | Unsupported("find_by_isin") |
Справочник по виду: Other | Unsupported. Остальные виды работают, список читается целиком: страницами по 100, пока страница полная. Каждая страница тратит квоту, а список длиннее 200 страниц это ошибка, не обрезок |
| Поток стоп-заявок | Unsupported |
| Замена рыночной заявки | Rejected { not_replaceable } |
| Айсберг-заявки, группы заявок, оценка заявки, остальные подписки (риски, сводка, инструменты) | через raw() |
| История своих сделок длиннее 1000 | Rejected { range_too_long }: читайте короче |
| Обрыв сокета подписок | виден боту: Event::Reconnected на подписки заявок и исполнений и закрытый шлюз до reconcile, Resync на стаканы (Круг событий) |
Подписки
Все подписки идут по одному WebSocket (/ws), у каждой свой guid, и ответные кадры несут тот же guid, поэтому
кадр всегда относится к своей подписке. subscribe_* возвращается, когда брокер подтвердил подписку: события заявки,
поставленной сразу после возврата, не потеряются. Отказ брокера внутри subscribe_ack_timeout (3 секунды по умолчанию) возвращается как
Rejected { subscription_refused }, а если подтверждения нет за это время - Transport, подписка снята и вызов можно повторить. Пауза перед переподключением от 0,5 с до 30 с. Брокер разрешает до 10 соединений на пользователя и до 5000
подписок на соединение.
Лимиты
crates/alor/limits.toml: брокер называет 100 HTTP-запросов в секунду на пользователя, 10 соединений WebSocket,
5000 подписок на соединение и 5000 необработанных сообщений в буфере соединения. Библиотека держит ведро на 100 запросов в
секунду для всех методов и меньшее ведро place (60 в секунду, всплеск 30) для постановки, замены и стоп-заявки: это не лимит брокера,
а запас, чтобы поток заявок не занял весь бюджет и отмене осталось место. У брокера есть и непубликуемая защита
(«социальный рейтинг» с блокировкой логина на 2 часа, например за запрос несуществующей заявки или некорректный JSON): ее порогов
в документации нет и коннектор их не считает. Не слать лишнего: чтение заявки по cid идет через список заявок дня, а не через
запрос несуществующего номера. Лимит общий на логин, поэтому все коннекторы одного логина берут один BrokerLimiter.
Что учесть
- Единицы объема в стакане, ленте и свечах документация АЛОРа не называет (у котировок - лоты). Количество в этих данных берите как пришло.
- Повтор
X-REQID: что сделает брокер, в документации не сказано. Коннектор команды не повторяет. - Срок стоп-заявки без времени окончания и регистр значений (
condition,side,timeInForce) брокер описывает неоднозначно. Задавайте срок явно.
Bybit: подключение к API
Эта страница о техническом подключении к публичному API стороннего сервиса. Она не является рекламой, предложением услуг или рекомендацией совершать сделки с цифровой валютой. Законность таких операций зависит от вашей юрисдикции. Для резидентов РФ действует 282-ФЗ «О цифровых валютах и цифровых правах», с 1 июля 2027 года сделки с цифровой валютой допускаются только через российских организаторов обращения. Проверьте требования закона до использования.
Крейты: brokapi-bybit-protocol (клиент HTTP и WebSocket: подпись запросов, методы API V5 и оба канала WebSocket) и brokapi-bybit (коннекторы).
Bybit - зарубежный сервис, не российский брокер: у него нет торговых сессий и лотов, и книга отмечает отличия там, где они меняют код.
Что поддержано. Контракты с расчетом в USDT (категория linear API V5). Спота пока нет, как и инверсных контрактов и опционов:
категория входит в идентификатор инструмента, поэтому спот добавится рядом, не ломая существующий код.
Вход
| Что | Как |
|---|---|
| Ключ | пара из ключа и секрета API: BYBIT_API_KEY и BYBIT_API_SECRET. Каждый запрос подписывается секретом (HMAC-SHA256), секрет по сети не уходит. Время для подписи берется у биржи: при старте коннектор читает ее часы и запоминает сдвиг, окно приема запроса 5000 мс (recv_window_ms) |
| Права ключа | для бота нужны чтение и торговля по деривативам (Unified Trading) и чтение кошелька. Вывод средств и переводы не включайте: бот им не пользуется. Привязка ключа к IP-адресу сервера - хорошая защита, на демо-счете ее можно не ставить |
| Счет | выбора счета нет: ключ принадлежит одному счету (или субсчету). Нужен единый торговый счет (Unified Trading Account) |
| Контуры | основной (api.bybit.com, по умолчанию), демо-счет (api-demo.bybit.com, виртуальные деньги, круглосуточно) и тестовая сеть (api-testnet.bybit.com). Контур выбирает BYBIT_ENV (mainnet, demo, testnet), в коде BybitConfig::with_env(BybitEnv::Demo), в Python env="demo", в Gateway sandbox = true значит демо-счет |
| Ключ демо-счета | у него своя пара переменных, BYBIT_DEMO_API_KEY и BYBIT_DEMO_API_SECRET: ключ демо-счета на основном адресе не действует, поэтому обе пары могут лежать в одном .env, а контур выбирает BYBIT_ENV |
| Лимиты | brokapi::bybit::limits::default_limits(): встроенный файл limits.toml крейта. Подробно ниже |
Где взять ключ
Ключ выпускает человек, чей это счет (правило 0). Шаги по документации Bybit:
Демо-счет (виртуальные деньги, с него начинайте):
- Войти на сайт Bybit и включить режим «Demo Trading».
- В этом режиме открыть страницу API и создать ключ с правами на чтение и торговлю по деривативам (Unified Trading) и чтение кошелька.
- Положить ключ и секрет в
BYBIT_DEMO_API_KEYиBYBIT_DEMO_API_SECRET. Демо-счет пустой: бот пополняет его методомPOST /v5/account/demo-apply-money(черезraw, не чаще раза в минуту, до 100 000 USDT за запрос).
Основной контур: ключ выпускают для отдельного субсчета, без права вывода и с привязкой к IP-адресу. Ключ основного счета программе не давайте.
Первый запуск
План от безопасного к боевому. Каждый шаг начинается, когда прошел предыдущий.
- Имитация. Код сначала проходит на
brokapi.testing.mock_bybit()(тестовый инструментMOCKUSDT): быстрый старт на Python. - Демо-счет. Тот же код с
env="demo": деньги виртуальные, поэтому проверка идет вживую и без риска, но с настоящей подписью, настоящими потоками и настоящими отказами биржи. - Основной контур - только с минимальной заявкой далеко от рынка и сразу с отменой, на субсчете.
Программа делает на любом контуре одно и то же: читает контракт и его правила, сверяется со счетом, ставит самую малую заявку вдвое ниже лучшей покупки, видит ее в потоке, снимает, ставит стоп-заявку и тоже снимает. Ничего не может исполниться: обе цены далеко от рынка.
async def order_event(client: brokapi.Client, cid: uuid.UUID) -> brokapi.OrderSnapshot:
"""The first event of the stream that is a state of the order with this cid."""
async for event in client.events:
if isinstance(event.payload, brokapi.OrderSnapshot) and event.payload.cid == cid:
return event.payload
raise RuntimeError("the stream ended")
async def first_day(client: brokapi.Client, symbol: str) -> None:
"""Reads the contract, puts the smallest order at half of the best bid, sees it in the stream, takes it off, and
puts a stop order that is also taken off. Nothing here can fill: both prices are far from the market."""
# 1. The contract and its rules: the quantity step, the smallest order and the price step. They come from the exchange.
perp = await client.instrument(symbol) # "BTCUSDT" becomes "BTCUSDT@linear": the category is part of the id
params = await client.trading_params(perp)
book = await client.order_book(perp, depth=1)
bid = book.bids[0].price
print(f"{perp}: bid {bid}, ask {book.asks[0].price}, smallest order {params.min_qty}, quantity step {params.lot_size}")
# 2. Subscribe to the orders, and only then reconcile: no event is lost between the two.
await client.subscribe_order_state()
report = await client.reconcile([]) # a new bot has an empty ledger
print(f"reconcile: {len(report.foreign)} foreign order(s) on the account")
# 3. The order: half of the best bid, rounded down to the price step. The exchange refuses a price too far from the
# market with a code of its own, then try a nearer one with a NEW cid.
price = brokapi.round_to_step(bid / 2, params.price_step, brokapi.RoundMode.DOWN)
cid = brokapi.new_cid() # a real bot writes the cid to its journal here, before `place`
ack = await client.place(cid=cid, instrument=perp, side=brokapi.Side.BUY, qty=params.min_qty, price=price)
print(f"placed: order {ack.broker_id} at {price}")
seen = await asyncio.wait_for(order_event(client, cid), timeout=10) # waits for the event, not for a pause
print(f"in the stream: {seen.state.kind.value}")
# 4. Take it off and ask the exchange what became of it. The answer of `order_state` is the proof.
await client.cancel(cid)
state = await client.order_state(cid)
print(f"after cancel: {state.state.kind.value}, orders alive: {len(await client.active_orders())}")
# 5. A stop order is a conditional order of Bybit. It waits apart from the ordinary orders: `active_stop_orders`.
stop_cid = brokapi.new_cid()
await client.place_stop(
cid=stop_cid,
instrument=perp,
side=brokapi.Side.SELL,
kind=brokapi.StopOrderKind.STOP_LOSS,
qty=params.min_qty,
stop_price=brokapi.round_to_step(bid / 2, params.price_step, brokapi.RoundMode.DOWN),
)
print(f"stop orders waiting: {len(await client.active_stop_orders())}, ordinary orders: {len(await client.active_orders())}")
await client.cancel_stop(stop_cid)
print(f"stop orders after cancel: {len(await client.active_stop_orders())}")
Запуск на имитации (сеть и деньги не нужны) и на демо-счете:
async def on_the_imitation() -> None:
async with await mock_bybit() as mock:
client = await mock.connect()
await first_day(client, "MOCKUSDT")
await client.close()
async def on_the_demo_account() -> None:
# BYBIT_DEMO_API_KEY and BYBIT_DEMO_API_SECRET from the environment: a key of the demo account is not valid on the
# main host, so the contour is chosen by the name, not by the address.
client = await brokapi.connect("bybit", env="demo")
try:
await first_day(client, "BTCUSDT")
finally:
await client.close()
На имитации программа печатает MOCKUSDT@linear: bid 100.00, ask 100.01, ..., in the stream: Acked, after cancel: Cancelled, orders alive: 0 и stop orders after cancel: 0.
Если биржа отвергла цену как слишком далекую от рынка (окно допустимых цен у контрактов разное), подвиньте цену ближе и повторите с новым cid.
Идентификатор инструмента
NativeId::SymbolMic, ключ библиотеки BTCUSDT@linear: тикер контракта и категория Bybit (у Bybit нет кода рынка MIC, его место занимает категория). Построить идентификатор
без сети: brokapi::bybit::util::instrument_id("BTCUSDT"). В Python await client.instrument("BTCUSDT") или "BTCUSDT@linear". Идентификатор другого брокера или другой категории - Config до сети.
Неизвестный тикер биржа отвергает кодом 10001 (symbol invalid), коннектор отдает это как Rejected { code: "not_found" }.
У контрактов Bybit два вида: бессрочный (InstrumentKind::Perpetual, без срока, с ставкой финансирования) и срочный фьючерс (InstrumentKind::Future, с датой исполнения, например BTC-26DEC26).
Поиск по виду Other дает Unsupported, остальные виды вне категории linear - пустой список.
Единицы и стакан
| Что | Как |
|---|---|
| Числа | цены, количества и суммы читаются из JSON в Decimal по тексту ответа |
| Количество | в библиотеке единицы базовой монеты: 0.001 для BTC, целые для мелких монет. Шаг количества (qtyStep) и самое малое количество (minOrderQty) биржа называет у каждого контракта, коннектор читает их один раз и отказывает до отправки: invalid_qty (не кратно шагу или меньше минимума), price_off_step (цена не кратна шагу tickSize), missing_price (лимитная заявка без цены). TradingParams: lot_size это шаг количества, min_qty это самое малое количество |
| Минимальная стоимость заявки | у биржи есть и минимум по сумме (minNotionalValue, обычно 5 USDT) и предел количества (maxOrderQty). Коннектор их не проверяет: ответит биржа, отказ придет как Rejected { code: "bybit_<код>" }. Самую малую заявку считайте сами: количество на цену не меньше минимальной стоимости, с запасом |
| Стакан | до 1000 уровней, коннектор подписывается на глубину 1, 50, 200 или 1000 (ближайшую не меньше запрошенной, лишнее отрезает). Биржа присылает снимок и изменения, коннектор держит свой стакан и после каждого изменения отдает целый (incremental_order_book = false). Изменения идут подряд по номеру u: пропуск номера сбрасывает стакан, бот получает Resync { SequenceGap }, коннектор просит свежий снимок |
| Котировки | лучшая покупка и продажа из стакана глубины 1 (снимки до 100 раз в секунду) |
| Свечи | интервалы 1, 5, 15, 30, 60, 240 минут, день, неделя, месяц. 10 минут нет: Unsupported. Один запрос дает до 1000 свечей (candles_max_span), период длиннее - Rejected { range_too_long }, длинную историю читает candle_pages или load_candles |
| Лента сделок | last_trades и поток публичных сделок |
| Статус торгов | Trading это Open, PreLaunch, PendingOpen, Delivering и Closed это Closed. Расписания у сервиса нет: schedule отвечает Unsupported |
Как устроены команды
Команды идут по HTTP, по одному запросу, без повтора: заявка, отмена, стоп-заявка. Канал команд по WebSocket (/v5/trade) коннектор не использует, на демо-счете его нет.
- Отказ известен сразу:
Rejectedс кодомbybit_<код биржи>и текстом. - В
OrderAckприходит номер заявки биржи (orderId). - Нет ответа, обрыв, таймаут, 5xx и коды 10000 и 10016 это
Unknown: повторов нет, выход из неизвестности -order_stateиreconcile(Круг команд). - Отмена успешна, когда биржа приняла запрос. Подтверждение - событие статуса
Cancelledили чтениеorder_state. - Рыночная заявка у Bybit исполняется сразу или отменяется (IOC), лимитная держится до отмены (GTC). Других сроков и условий (post-only, FOK) в общем интерфейсе нет, они доступны через
raw().
Как cid уходит к бирже
В поле orderLinkId заявки, как UUID с дефисами (36 знаков из 36 допустимых). Обратимо: orderLinkId возвращается в списке заявок, в потоке и в сделках, так cid находится в ответах.
Повтор orderLinkId биржа отвергает (код 110072), это видно как Rejected { code: "bybit_110072" }. Как долго она помнит закрытую заявку, в документации не названо, поэтому
place_idempotency = LabelOnly, а повтор того же cid библиотека сама не отправляет. Заявка без нашего orderLinkId (из приложения, чужая программа) получает подставной cid
(стабильный: тот же номер дает тот же cid), по нему ее можно снять.
Порядок как у остальных брокеров: шлюз команд, проверка правил контракта, квота, очередь по инструменту, шлюз еще раз и только потом cid помечается использованным. После RateLimited тот же cid годен.
Общее правило: Круг команд.
Изменение заявки
Общего replace у Bybit нет (supports_replace = false, ответ Unsupported). Биржа меняет заявку на месте: номер и orderLinkId остаются, а общий replace обещает новую заявку под новым cid
и отмену старой. Выдавать одно за другое нельзя: после перезапуска бота сверка искала бы заявку по новому cid и сказала бы «закрыта». Поменять цену или количество можно двумя способами:
снять заявку и поставить новую под новым cid (так надежнее) или вызвать POST /v5/order/amend через raw() и помнить, что cid остается прежним.
Свои сделки, портфель
| Что | Как |
|---|---|
| Поток исполнений | поток execution: каждая сделка с cid заявки и полем execFee (как есть, знак сохраняется). Записи, что не сделки заявки (например, выплата финансирования), пропускаются |
| История | исполнения читаются окнами по 7 дней (ограничение биржи), страницами по курсору, от старого к новому. Биржа хранит 2 года. Больше 40 страниц в окне - Rejected { range_too_long } |
| Портфель | деньги по монетам: blocked это удерживаемое (в заявках и под позиции), available это капитал за вычетом blocked, не меньше нуля. Это наше чтение полей кошелька, биржа дает свободную сумму только по счету в долларах. Позиции: количество со знаком (продажа - минус), средняя цена, цена маркировки |
| Поток портфеля | потоки position и wallet присылают только изменения, без снимка на старте, поэтому subscribe_portfolio сначала отдает текущее состояние (чтение по HTTP), потом изменения. Поток позиции шлет сообщение на каждую заявку, даже без изменений |
| Список активных заявок | рабочие заявки категории. Ожидающие стоп-заявки в него не входят, они в active_stop_orders |
| Режим позиции | только односторонний. На счете с хеджированием биржа откажет в заявке, отказ виден как Rejected |
| Плечо и маржа | библиотека плечо не меняет. Установить его можно через raw() (POST /v5/position/set-leverage) до первой заявки |
Стоп-заявки
Стоп-заявка Bybit - обычная заявка с ценой срабатывания (triggerPrice). StopLoss и TakeProfit при срабатывании ставят рыночную заявку, StopLimit - лимитную по limit_price
(без нее Rejected { missing_price }). Направление срабатывания (рост или падение цены) коннектор выводит из вида и стороны.
| Что | Как |
|---|---|
| Срок | только «до отмены» (StopExpiry::GoodTillCancel): срок у условной заявки Bybit не задается. Остальное - Rejected { code: "unsupported_expiry" } |
| Список | ожидающая стоп-заявка видна в active_stop_orders. После срабатывания она приходит обычной заявкой в потоке заявок под тем же cid |
| Поток | потока стоп-заявок нет (subscribe_stop_orders отвечает Unsupported), список читает active_stop_orders |
| Закрытие позиции | reduceOnly и closeOnTrigger коннектор не ставит: стоп без позиции может открыть встречную. Размер стопа задавайте равным позиции |
| TP и SL позиции | POST /v5/position/trading-stop коннектор не создает (им нужна позиция, cid они не несут), но видит их в списке с придуманным cid |
Подробно: Стоп-заявки, для Python Стоп-заявки, Python.
Чего Bybit не поддерживает или делает иначе
| Что | Как |
|---|---|
| Спот, инверсные контракты, опционы | нет. Категория linear только |
Замена заявки общим replace | Unsupported, см. выше |
| Свечи по 10 минут | Unsupported("candles_m10") |
| Расписание торгов | Unsupported("schedule"): торги круглосуточные |
| Справочник по ISIN | Unsupported("find_by_isin") |
| Поток стоп-заявок | Unsupported |
Пакетные методы (create-batch, amend-batch, cancel-batch), trading-stop, set-leverage, switch-mode, поток tickers, команды по WebSocket | через raw() или совсем нет. Таблица ниже |
| Обрыв сокета подписок | виден боту: Event::Reconnected на подписки заявок и исполнений и закрытый шлюз до reconcile, Resync на стаканы (Круг событий). Публичный сокет пингуется каждые 20 секунд, молчание дольше 50 секунд - переподключение с восстановлением подписок |
| Отмененная заявка без исполнений | биржа держит ее в истории 24 часа, дальше она не находится: reconcile видит ее как closed_offline, как у остальных брокеров «не видел сегодня» |
Что закрыто общим интерфейсом, что через raw()
| Метод API | Общий интерфейс |
|---|---|
GET /v5/market/time, instruments-info, orderbook, recent-trade, kline | часы, справочник и правила, стакан, лента, свечи |
POST /v5/order/create, cancel | place, place_stop, cancel, cancel_stop |
GET /v5/order/realtime, history | order_state, active_orders, active_stop_orders, reconcile |
GET /v5/execution/list | executions |
GET /v5/position/list, account/wallet-balance | portfolio, positions, balances |
POST /v5/order/amend, cancel-all, create-batch, amend-batch, cancel-batch | raw() (cancel-all есть и командой brokapi cancel-all) |
GET /v5/market/tickers, POST /v5/position/trading-stop, set-leverage, switch-mode, POST /v5/account/demo-apply-money | raw() |
Публичные потоки orderbook, publicTrade, kline и приватные order, execution, position, wallet | стакан, сделки, свечи, котировки, заявки, исполнения, портфель |
Через raw() годится любой метод по глаголу и пути: "GET /v5/market/time", "POST /v5/position/set-leverage". Параметры GET идут в query, тело POST в body. Пути /v5/market/... читаются без подписи,
остальные подписываются ключом. Каждый вызов тратит квоту и идет через общий счетчик лимитов.
Ошибки
| Что пришло от биржи | Что вернет библиотека |
|---|---|
| HTTP 401 и коды 10003, 10004, 10005, 10007, 10010, 33004 (ключ или подпись неверны, нет прав, IP не в списке, ключ другого контура, ключ истек) | Config. При старте это ошибка запуска, поэтому неверный ключ виден сразу |
| HTTP 429 и код 10006 (слишком частые запросы) | RateLimited |
| HTTP 403 | в чтении Transport с пояснением: превышен лимит запросов с адреса (бан на 10 минут) или регион запрещен. В команде Rejected { code: "http_403" }. Другие 4xx команды - Rejected { code: "http_<статус>" } |
| Код 10002 (время запроса вне окна приема) | Rejected. Часы сервера ушли далеко от биржи: сдвиг при старте с этим справляется, дальнейший уход часов нет |
| Код 110001 (нет такой заявки) | Rejected { code: "not_found" } |
| Код 10001 (неизвестный символ) | Rejected { code: "not_found" } |
| Остальные коды команды | Rejected { code: "bybit_<код>" } с текстом биржи |
| Нет ответа, 5xx, коды 10000 и 10016 | Unknown для команды: не повторять, выяснить через order_state и reconcile |
Общая таблица ошибок: Ошибки.
Лимиты
crates/bybit/limits.toml: по документации Bybit 10 запросов в секунду на создание, изменение, отмену и «снять все» (по пользователю и методу), 50 в секунду на чтения, 600 запросов за 5 секунд на адрес
(сверх этого HTTP 403 и бан адреса на 10 минут). Библиотека держит по ведру на каждый вид команды, чтобы поток заявок не занял бюджет отмен, и общее ведро адреса (100 в секунду, всплеск 100):
оно держит адрес в пределах 600 за 5 секунд, даже если рядом работает другая программа. Заголовки X-Bapi-Limit-Status, которые биржа присылает с каждым ответом, библиотека не читает, счет ведется у нее самой.
Лимит общий на логин (ключ), поэтому все коннекторы одного ключа берут один BrokerLimiter. Демо-счет по документации считает так же, вживую это не сверяли.
Что проверено вживую
Проверено на настоящем сервисе, не на имитации:
- Демо-счет, сценарий службы и гейтвея, 12 шагов из 12, с первого раза. Подпись принята, приватные потоки подписываются, пополнение демо-счета работает. Гейтвей читает котировки и стакан, бот видит счет и ставит лимитную заявку на 30% ниже рынка
(принята), получает событие,
order_stateи отмену. Лимитная и стоп-заявка оставлены стоять, процесс гейтвея убит, служба подняла его, журнал помнит заявку, бот переподключился, сверка подтвердила обе заявки, обе сняты. - Основной контур, отдельный субсчет, минимальные заявки. Лимитная заявка далеко от рынка принята, событие пришло в потоке заявок, отмена подтверждена. Рыночная заявка и обратная ей исполнены, исполнения пришли с
cidи полемexecFee, позиций и заявок на счете не осталось. - Публичные данные: справочник, стакан, лента, свечи и публичные потоки на основном контуре и на демо.
Не проверено вживую, учитывайте в боте:
| Что | Что делать |
|---|---|
| Срабатывание стоп-заявки. Стоп ставился и снимался (на демо-счете), но цена до него не доходила | Первый настоящий стоп проверьте на минимальной позиции и дождитесь срабатывания, прежде чем на него полагаться |
| Стоп-заявки на основном контуре | Те же шаги, что для демо-счета, но на минимальной позиции |
| Обрывы потоков без убийства гейтвея: переподключение приватного потока по истечении срока его жизни, потеря событий заявки за время обрыва | После Reconnected делайте reconcile |
| Лимиты: бан по адресу (HTTP 403), совпадение счета лимитов демо-счета и основного | Не превышайте 10 команд в секунду, при раннем RateLimited уменьшите значения в limits.toml |
| Режим позиции с хеджированием, смена плеча и режима маржи, портфельная маржа | Держите односторонний режим, плечо ставьте до первой заявки через raw() |
Формула available и blocked для нескольких монет в залоге | Для сумм, от которых зависит бот, читайте кошелек через raw() |
| Курсор страниц при более чем 50 заявках, история исполнений длиннее одного окна | Читайте историю короткими периодами |
Как долго биржа помнит закрытый orderLinkId | Не повторяйте cid никогда |
Поток Execution и поле execFee на заявках, поставленных не этой программой | Сверяйтесь по reconcile |
Прогон на демо-счете повторяется: tools/bybit-demo-run (служба, гейтвей, бот, убитый гейтвей и сверка, ключ только демо-счета, без ключей и сети сценарий проверяет себя на имитации: --self-test). Допущения, на которые опирается код, собраны в Допущениях по документации брокеров.
Что учесть
- Контур выбирается именем, а не адресом:
BYBIT_ENV=demoберет паруBYBIT_DEMO_API_KEYиBYBIT_DEMO_API_SECRET. ЗабытаяBYBIT_ENVоставит основной контур и основной ключ. - Количество в базовой монете и с шагом контракта (
lot_size). Заявка меньшеmin_qtyили дешевле минимальной стоимости (около 5 USDT) уйдет к бирже и вернетсяRejected. - Замены нет: меняйте заявку отменой и новой заявкой под новым
cid. - Стоп-заявки не входят в
active_orders: проверяйте оба списка при сверке и в скрипте аварийной остановки (Аварийная остановка бота). - Рыночная заявка исполняется как IOC: если ликвидности не хватает, остаток отменяется, проверяйте
filledвorder_state. - Один ключ - один
BrokerLimiter: все коннекторы и боты одного ключа берут общий счетчик.
brokapi на Rust
Ядро brokapi - библиотека на Rust. Стадия: открытая альфа.
Раздел для тех, кто пишет бота на Rust. Устройство, правила и особенности брокеров общие для всех путей и лежат в общих частях книги. Здесь то, что зависит от языка: как подключить крейт и как решить конкретную задачу кодом.
С чего начать
Один крейт brokapi, брокеры включаются флагами bcs, tbank, finam, alor, bybit. Команда - cargo add brokapi --features bcs,
самая свежая версия из репозитория - cargo add --git https://github.com/runinfra/brokapi brokapi --features bcs. Подробности: Установка. Первый
шаг быстрого старта - получить библиотеку, дальше переменные .env: Быстрый старт на Rust.
Инструкции по задачам
| Что нужно сделать | Страница |
|---|---|
| Пройти путь от нуля до первой заявки | Быстрый старт на Rust |
| Написать торгового бота на событиях | Торговый бот на событиях, Rust |
| Держать несколько брокеров и счетов в одном процессе | Несколько брокеров и счетов: инфраструктура |
| Выгрузить историю и проиграть поток событий | Исследовательский движок |
| Правильно запустить, перезапустить и остановить бота | Запуск, рестарт и остановка |
Выйти из Unknown, переполнения и обрыва связи | Если что-то пошло не так |
| Понять, как делить счета и лимиты | Несколько счетов и брокеров |
| Поставить стоп-лосс или тейк-профит | Стоп-заявки |
| Вызвать метод брокера, которого нет в общем интерфейсе | Любой метод брокера: raw |
| Проверить свой код без сети и денег | Тесты против имитаций, Rust |
Общее, что нужно прочитать
- Контракт для агентов: обязательные правила на одной странице, нужны и человеку.
- Карта и три круга: события, команды, жизнь коннектора.
- Ошибки и что с ними делать, события и возможности, Брокеры.
- Справочник по типам и методам, собранный из кода: Справочник API: Rust.
Каждый пример в этом разделе - тест против имитаций брокеров, он собирается и запускается при каждой проверке репозитория.
Быстрый старт на Rust
Цель страницы: за один заход получить работающую программу, которая подключается к брокеру, смотрит стакан,
ставит одну заявку далеко от рынка, читает события и снимает заявку. Для каждого брокера свой короткий main,
а общая часть одна.
На настоящем счете БКС, Т-Банка, Финама и АЛОРа библиотеку пока не запускали (Bybit проверен вживую). Начинайте с песочницы Т-Банка. Для БКС и Финама песочницы нет, заявка уйдет на настоящий счет (у АЛОРа есть тестовый контур со своим счетом, у Bybit демо-счет с виртуальными деньгами): ставьте минимальное количество и цену заведомо ниже рынка, кратную шагу цены. Подробнее: Перед боевым счетом.
Шаг 1. Получить библиотеку
Нужен Rust 1.88 или новее (редакция 2024). Подключается один крейт brokapi, брокеры включаются флагами, как у tokio
(tbank, bcs, finam, alor, bybit, можно несколько):
cargo add brokapi --features tbank
cargo add tokio --features full
cargo add dotenvy
Самая свежая версия из репозитория - вместо первой строки (подробности и проверка - на странице Установка):
cargo add --git https://github.com/runinfra/brokapi brokapi --features tbank
Должно получиться в Cargo.toml:
[dependencies]
brokapi = { git = "https://github.com/runinfra/brokapi", version = "0.1.0-alpha.5", features = ["tbank"] } # после беты без git
tokio = { version = "1", features = ["full"] }
dotenvy = "0.15"
Из клона репозитория подключают по пути: brokapi = { path = "../brokapi", features = ["tbank"] }. В коде все лежит под
brokapi::: общее в brokapi::core и brokapi::runtime, брокеры в brokapi::bcs, brokapi::tbank, brokapi::finam, brokapi::alor, brokapi::bybit, их
клиенты нижнего слоя в brokapi::bcs::protocol и так далее. cargo build должен собрать проект без ошибок. Не собрал:
таблица «Если что-то пошло не так» на странице Установка.
Что делает каждый крейт: Карта.
Сначала без токена: на имитации
Токен и счет нужны не сразу. В клоне репозитория (git clone https://github.com/runinfra/brokapi) пример БКС запускается на имитации, которая
живет в вашем процессе и говорит на протоколе БКС: без сети, без счета, без денег.
cargo run --example quickstart_bcs # имитация: токен не нужен
BROKAPI_LIVE=1 cargo run --example quickstart_bcs # настоящий БКС: токен на запись и слово владельца счета
Пример читает портфель, стакан и свечи, ставит заявку далеко от рынка и снимает ее: ровно то, что делают программы ниже. Цена в имитации стоит на
месте, заявка сама не исполнится, ее исполняет тест (server.fill). Имитации всех четырех брокеров, рычаги и ограничения описаны на странице
Тесты против имитаций, Rust. Крейт brokapi-mocks в crates.io не публикуется, поэтому в своем проекте его подключают по пути к клону.
Токены и счет для следующих шагов выдает человек, чей это счет: правило 0.
Шаг 2. Переменные .env
Файл .env лежит рядом с программой, в git не попадает. Токены не печатайте в логи и не кладите в коммиты.
| Брокер | Переменная | Для чего |
|---|---|---|
| Т-Банк | TBANK_TOKEN | токен (обязательно). Для первого запуска - токен песочницы |
TBANK_SANDBOX | true или 1 - песочница. Без нее адрес боевой | |
TBANK_ACCOUNT_ID | счет. Если не задан, берется единственный открытый счет токена, а при нескольких счетах коннектор не запустится | |
TBANK_ENDPOINT, TBANK_APP_NAME | необязательно: свой адрес, имя приложения в заголовке | |
| БКС | BCS_REFRESH_TOKEN | refresh-токен (обязательно). Счет определяет сам токен |
BCS_IS_WRITE | true - токен на запись. Без нее торговые команды брокер не примет | |
| Финам | FINAM_SECRET | секрет Trade API (обязательно) |
FINAM_ACCOUNT_ID | счет (обязательно для UD) | |
FINAM_API_URL | необязательно: другой адрес | |
| АЛОР | ALOR_REFRESH_TOKEN | refresh-токен с портала разработчика (обязательно) |
ALOR_PORTFOLIO | портфель (обязательно, если токен называет несколько) | |
ALOR_EXCHANGE, ALOR_ENV | необязательно: биржа MOEX (по умолчанию) или SPBX, ALOR_ENV=test - тестовый контур | |
| Bybit | BYBIT_API_KEY, BYBIT_API_SECRET | пара ключ и секрет API (обязательно) |
BYBIT_ENV | необязательно: mainnet (по умолчанию), demo (демо-счет с виртуальными деньгами) или testnet. Для demo читаются BYBIT_DEMO_API_KEY и BYBIT_DEMO_API_SECRET: ключ демо-счета на основном адресе не действует | |
| Пример | EXAMPLE_PRICE | цена лимитной заявки в примерах ниже. Это переменная примера, библиотека ее не читает |
Счет Т-Банка в песочнице нужно открыть. Список своих счетов: TbankClient::new(&cfg)?.list_accounts().await
(brokapi::tbank::protocol::TbankClient). Открывает счет песочницы метод open_sandbox_account клиента sandbox(),
пример в crates/tbank/tests/live_sandbox.rs.
Шаг 3. Общая часть
Эта функция одинакова для всех брокеров, потому что работает через общие трейты OrderBookSource и
OrderExecutor. Положите ее в тот же файл, что и main нужного брокера.
use std::time::Duration;
use brokapi::core::{
Backpressure, ClientOrderId, Envelope, Error, Event, InstrumentId, OrderBook, OrderBookSource,
OrderExecutor, OrderType, PlaceOrderRequest, Price, Qty, Side, SubscribeOpts,
};
use tokio::sync::mpsc;
use tokio::time::timeout;
/// Читает события из общего канала заданное время и печатает самое интересное.
async fn show_events(events: &mut mpsc::Receiver<Envelope>, how_long: Duration) {
let _ = timeout(how_long, async {
while let Some(env) = events.recv().await {
match env.payload {
Event::OrderBook(OrderBook::Snapshot { bids, asks, .. }) => println!(
"[{} sub {:?}] стакан: покупка {:?}, продажа {:?}",
env.source,
env.sub,
bids.first().map(|l| l.price),
asks.first().map(|l| l.price),
),
Event::Order(order) => println!("заявка {}: {:?}", order.cid, order.state),
Event::Execution(fill) => {
println!("исполнение {}: {} по {}", fill.trade_id, fill.qty, fill.price)
}
Event::Resync { .. } => println!("стакан надо брать заново"),
Event::Overflow { .. } => println!("буфер переполнился, нужна сверка"),
_ => {}
}
}
})
.await;
}
/// Сверка, подписки, одна заявка, события, отмена.
async fn trade_once<M, U>(
md: &M,
ud: &U,
instrument: InstrumentId,
qty: Qty,
price: Price,
events: &mut mpsc::Receiver<Envelope>,
) -> Result<(), Box<dyn std::error::Error>>
where
M: OrderBookSource,
U: OrderExecutor,
{
// 1. Подписки, ДО сверки: событие между снимком сверки и подпиской иначе теряется. Стакан можно
// склеивать: важен только свежий. Статусы своих заявок всегда идут без потерь, режим выбирает
// сама библиотека.
let opts = SubscribeOpts { backpressure: Backpressure::Conflate, depth: Some(10) };
let book = md.subscribe_order_book(&instrument, opts).await?;
ud.subscribe_order_state().await?;
// 2. Сверка. Пока она не прошла, UD отвечает NotReconciled на любую торговую команду.
// Журнал бота пуст, поэтому список своих заявок пустой.
let report = ud.reconcile(&[]).await?;
println!("чужих заявок на счете: {}", report.foreign.len());
// 3. Одна заявка. Номер cid придумывает бот, не библиотека.
let cid = ClientOrderId::new_v4();
let request = PlaceOrderRequest {
cid,
instrument: instrument.clone(),
side: Side::Buy,
order_type: OrderType::Limit,
qty,
price: Some(price),
};
match ud.place(request).await {
Ok(ack) => println!("заявка принята, номер брокера {}", ack.broker_id.as_str()),
Err(Error::Rejected { code, message }) => {
println!("брокер отказал: {code}, {message}");
return Ok(());
}
Err(Error::Unknown) => {
// Неизвестно, дошла ли заявка. Повторять нельзя: спрашиваем брокера.
let snapshot = ud.order_state(cid).await?;
println!("итог был неясен, брокер говорит: {:?}", snapshot.state);
}
Err(other) => return Err(other.into()),
}
// 4. События: стакан, статус заявки.
show_events(events, Duration::from_secs(5)).await;
// 5. Снимаем заявку, дочитываем события, отписываемся.
ud.cancel(cid).await?;
show_events(events, Duration::from_secs(2)).await;
md.unsubscribe(book).await?;
Ok(())
}
Разбор шагов: сверка - Круг жизни, заявка и ее исходы - Круг команд, режимы подписки - Круг событий, события и их поля - События и возможности.
Шаг 4. Программа для Т-Банка
Количество в штуках, как у любого брокера в библиотеке: у Т-Банка API считает лотами, но коннектор переводит сам, лот должен
укладываться в заявку целое число раз (лот SBER 10, поэтому ниже заявка на одну бумагу была бы отказана invalid_qty).
Цена за одну бумагу. Инструмент ищем через справочник: он отдает uid, которым пользуется брокер.
use brokapi::core::{InstrumentQuery, InstrumentSource, ShutdownMode};
use brokapi::runtime::BrokerLimiter;
use brokapi::tbank::limits::default_limits;
use brokapi::tbank::{MarketDataConfig, MarketDataConnector, UserDataConfig, UserDataConnector};
use brokapi::tbank::protocol::TbankConfig;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// TBANK_TOKEN, TBANK_SANDBOX, TBANK_ACCOUNT_ID берутся из .env
let cfg = TbankConfig::from_env()?;
let price: Price = std::env::var("EXAMPLE_PRICE")?.parse()?;
// Один счетчик лимитов на пользователя: Т-Банк считает лимит на все счета и токены сразу.
let limiter = BrokerLimiter::new(&default_limits());
// Один канал событий на оба коннектора. Кто прислал, видно по полю source.
let (tx, mut events) = mpsc::channel::<Envelope>(1024);
let md = MarketDataConnector::start(
MarketDataConfig::new(cfg.clone(), "tbank-md"),
limiter.clone(),
tx.clone(),
)
.await?;
let ud = UserDataConnector::start(UserDataConfig::new(cfg, "tbank-ud"), limiter, tx).await?;
let sber = md
.find(InstrumentQuery::ByTicker { ticker: "SBER".into(), exchange: Some("TQBR".into()) })
.await?
.into_iter()
.next()
.ok_or("SBER не найден")?
.id;
// один лот SBER: 10 штук
trade_once(&md, &ud, sber, Qty::from(10), price, &mut events).await?;
let stop = ShutdownMode::Graceful { deadline: Duration::from_secs(5) };
ud.shutdown(stop).await;
md.shutdown(stop).await;
Ok(())
}
Что важно у Т-Банка: страница Т-Банка.
Шаг 4. Программа для БКС
Счет определяет токен, поэтому account_id нет. Лимиты встроены в крейт: brokapi::bcs::limits::default_limits(), как у двух
других брокеров (числа из таблицы ограничений брокера). Инструмент БКС задается тикером и классом (SBER, TQBR),
справочник для этого не нужен.
use brokapi::bcs::protocol::config::BcsConfig;
use brokapi::bcs::{MarketDataConfig, MarketDataConnector, UserDataConfig, UserDataConnector};
use brokapi::bcs::limits::default_limits;
use brokapi::core::{InstrumentKey, NativeId, ShutdownMode};
use brokapi::runtime::BrokerLimiter;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// BCS_REFRESH_TOKEN и BCS_IS_WRITE берутся из .env
let cfg = BcsConfig::from_env()?;
let price: Price = std::env::var("EXAMPLE_PRICE")?.parse()?;
// Один счетчик лимитов на токен: оба коннектора пользуются одним.
let limiter = BrokerLimiter::new(&default_limits());
let (tx, mut events) = mpsc::channel::<Envelope>(1024);
let md = MarketDataConnector::start(
MarketDataConfig::new(cfg.clone(), "bcs-md"),
limiter.clone(),
tx.clone(),
)
.await?;
let ud = UserDataConnector::start(UserDataConfig::new(cfg, "bcs-ud"), limiter, tx).await?;
let sber = InstrumentId::new(
InstrumentKey::new("SBER", "TQBR"),
NativeId::TickerClass { ticker: "SBER".into(), class_code: "TQBR".into() },
);
trade_once(&md, &ud, sber, Qty::from(1), price, &mut events).await?;
let stop = ShutdownMode::Graceful { deadline: Duration::from_secs(5) };
ud.shutdown(stop).await;
md.shutdown(stop).await;
Ok(())
}
У БКС заявка идет в штуках (так в документации брокера): страница БКС. Лот инструмента передайте
коннектору (ud.price_grid().learn(¶ms)), тогда некратное количество будет отказано до отправки.
Шаг 4. Программа для Финама
Количество в штуках. Инструмент задается строкой SBER@MISX. Чтобы MD и UD не авторизовались дважды,
создаем один менеджер токенов и отдаем его обоим.
use brokapi::core::ShutdownMode;
use brokapi::finam::limits::default_limits;
use brokapi::finam::util::instrument_from_symbol;
use brokapi::finam::{api_config_from_env, MarketDataConfig, MarketDataConnector, UserDataConfig, UserDataConnector};
use brokapi::runtime::BrokerLimiter;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// api_config_from_env сам .env не читает, поэтому загружаем его вручную
dotenvy::dotenv().ok();
let (api, account) = api_config_from_env()?;
let account = account.ok_or("FINAM_ACCOUNT_ID не задан")?;
let price: Price = std::env::var("EXAMPLE_PRICE")?.parse()?;
// Один JWT на оба коннектора
let tokens = api.shared_tokens().await?;
let api = api.with_tokens(tokens);
let limiter = BrokerLimiter::new(&default_limits());
let (tx, mut events) = mpsc::channel::<Envelope>(1024);
let md = MarketDataConnector::start(
MarketDataConfig::new(api.clone(), "finam-md"),
limiter.clone(),
tx.clone(),
)
.await?;
let ud = UserDataConnector::start(UserDataConfig::new(api, account, "finam-ud"), limiter, tx).await?;
let sber = instrument_from_symbol("SBER@MISX");
trade_once(&md, &ud, sber, Qty::from(1), price, &mut events).await?;
let stop = ShutdownMode::Graceful { deadline: Duration::from_secs(5) };
ud.shutdown(stop).await;
md.shutdown(stop).await;
Ok(())
}
Что важно у Финама: страница Финама.
Что произошло по шагам
startподнял коннектор. У БКС и Финама он сразу проходит авторизацию и возвращает ошибку, если токен не подошел. У Т-Банка при заданном счетеstartв сеть не ходит, о неверном токене скажет первый запрос.- Подписки дали
SubscriptionId. Поsourceиsubв конверте видно, чье это событие. Они сделаны до сверки нарочно. reconcile(&[])открыл шлюз команд. До негоplaceвернул быNotReconciled.placeвернулOrderAck(или один из исходов, разобранных в Заявках).- События идут в канал сами. Программа их читает, как только они появились.
cancelснял заявку,shutdownзакрыл подписки. Заявки на бирже при остановке не снимаются: еслиcancelне прошел, заявка останется.
Как написать торгового бота на событиях (Rust)
Инструкция. Цель: бот, который читает стакан, по сигналу ставит заявку, следит за ее статусами, выходит из неясного исхода
(Unknown) и аккуратно останавливается. Правила, на которых он стоит, собраны в Контракте для агентов,
причины - в Круге команд и Круге жизни. Код ниже - куски программы trading_bot, весь ее текст в конце страницы.
Она запускается тестом против имитаций БКС и Т-Банка, поэтому текст и проверенный код не расходятся.
Стратегия в примере заглушка: когда пришел стакан и своей заявки нет, купить минимальное количество на 1% ниже лучшей цены
покупки. Все остальное (журнал, сверка, исходы каждой команды, Resync, Overflow, остановка) нужно любому настоящему боту.
Что получится
Бот написан на трейтах brokapi::core (OrderBookSource, InstrumentSource, OrderExecutor, ExecutionSource), поэтому
один и тот же TradingBot работает с любым брокером. На схеме: откуда идут события, где решает бот и что делает с
каждым исходом place.
flowchart TB BR[Брокер] --> MD[MD: стакан] BR --> UD[UD: статусы и сделки] MD --> Q[(Канал событий)] UD --> Q Q --> BOT[TradingBot.on_event] BOT -- стакан, заявки нет --> P[place через UD, новый cid,<br>cid в журнал до вызова] P -- Ok --> W[Working: ждем<br>событий заявки] P -- Rejected --> D[Done: отказ] P -- RateLimited<br>или шлюз закрыт --> N[Не отправлено: позже<br>новая попытка с новым cid] P -- Unknown --> U[order_state<br>по тому же cid] U -- заявка есть --> W U -- брокер не знает --> A[подождать<br>и спросить снова] A -- так и нет --> D2[Done: не ставилась]
Из схемы следует: ответ на place и статусы приходят разными путями (результат вызова и события), Unknown разбирается
запросом, а не повтором, и каждая развилка заканчивается записью в журнал.
Подключение
[package]
name = "my-bot"
edition = "2024" # нужен Rust 1.88 или новее: в коде книги цепочки let
[dependencies]
brokapi = { version = "0.1.0-alpha.5", features = ["bcs"] } # или "tbank", "finam", "alor", "bybit"
tokio = { version = "1", features = ["full"] }
Из клона репозитория его подключают по пути: brokapi = { path = "../brokapi", features = ["bcs"] }.
use brokapi::core::{
ClientOrderId, Decimal, Envelope, Error, Event, ExecutionSource, InstrumentId, InstrumentQuery,
InstrumentSource, OrderBook, OrderBookSource, OrderExecutor, OrderSnapshot, OrderState,
OrderType, PlaceOrderRequest, Price, Qty, RoundMode, ShutdownMode, Side, SubscribeOpts,
SubscriptionId, TradingParams,
};
use tokio::sync::mpsc;
Журнал
Журнал - память бота. Библиотека не пишет на диск ничего. В примере он в памяти, чтобы код был короче. Настоящий бот пишет каждое изменение в файл или базу до того, как идти дальше: готовый журнал в файле - в инструкции про несколько брокеров и счетов.
/// What the bot knows about one of its orders. The journal is the bot's memory: the library keeps nothing
/// on disk. A real bot writes every change to a file or a database before it goes on (see the infrastructure
/// example). Here the journal is in memory, to keep the example short.
#[derive(Debug, Clone, PartialEq)]
pub enum Entry {
/// Written BEFORE `place` is called. If the process dies now, the order may or may not exist.
Sending,
/// The broker accepted the order, or the stream says it works.
Working,
/// The outcome of the command is not known. The bot asks the broker with `order_state`.
Check,
/// Final. The text says how it ended.
Done(String),
}
#[derive(Debug, Default)]
pub struct Journal {
pub entries: Vec<(ClientOrderId, Entry)>,
}
impl Journal {
pub fn set(&mut self, cid: ClientOrderId, entry: Entry) {
match self.entries.iter_mut().find(|(c, _)| *c == cid) {
Some(slot) => slot.1 = entry,
None => self.entries.push((cid, entry)),
}
}
pub fn get(&self, cid: ClientOrderId) -> Option<&Entry> {
self.entries.iter().find(|(c, _)| *c == cid).map(|(_, e)| e)
}
/// The cids the bot believes are alive: the ledger it gives to `reconcile`.
pub fn live(&self) -> Vec<ClientOrderId> {
self.entries
.iter()
.filter(|(_, e)| !matches!(e, Entry::Done(_)))
.map(|(c, _)| *c)
.collect()
}
}
Подключение коннекторов
Один счетчик лимитов на токен (БКС считает лимит на токен) и один канал для событий обоих коннекторов. Канал читает отдельная
маленькая задача (forward, ниже): она сразу переносит конверты в очередь бота. Это нужно, потому что бот в ходе работы ждет
ответы брокера (place до 30 секунд), а коннектор не умеет ждать читателя: если канал не читается, буфер заполняется и приходит
Overflow (Контракт, правило 5).
/// How far below the best bid the bot buys: 99%. (`Decimal::new` is not `const`, `from_parts` is.)
const BUY_AT: Decimal = Decimal::from_parts(99, 0, 0, false, 2);
/// The top of the book the bot trades from. Dropped on `Resync`.
#[derive(Debug, Clone, Copy)]
pub struct Top {
pub bid: Price,
pub ask: Option<Price>,
}
/// BCS names an instrument by ticker and board.
fn sber_bcs() -> InstrumentId {
use brokapi::core::{InstrumentKey, NativeId};
InstrumentId::new(
InstrumentKey::new("SBER", "TQBR"),
NativeId::TickerClass {
ticker: "SBER".into(),
class_code: "TQBR".into(),
},
)
}
/// The connectors write into a bounded channel and never wait for it. A small task moves every envelope into
/// the bot's own queue at once, so the channel is always empty and the bot may take its time (a command can wait
/// seconds for the broker). The queue is unbounded: watch its length in a real bot.
fn forward(mut events: mpsc::Receiver<Envelope>) -> mpsc::UnboundedReceiver<Envelope> {
let (queue_tx, queue) = mpsc::unbounded_channel();
tokio::spawn(async move {
while let Some(env) = events.recv().await {
if queue_tx.send(env).is_err() {
break;
}
}
});
queue
}
Константы и вспомогательные функции программы: покупка по 99% лучшей цены (Price * Decimal дает Price), идентификатор
инструмента у БКС и читатель канала.
use brokapi::bcs::limits::default_limits;
use brokapi::bcs::protocol::config::BcsConfig;
use brokapi::bcs::{MarketDataConfig, MarketDataConnector, UserDataConfig, UserDataConnector};
use brokapi::runtime::BrokerLimiter;
let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
let cfg = BcsConfig::for_mock("mock-refresh-token", &server.http_base).with_write(true);
// One limiter for the token (BCS counts limits per token), one channel for all events.
let limiter = BrokerLimiter::new(&default_limits());
let (tx, events) = mpsc::channel::<Envelope>(1024);
let mut queue = forward(events);
let md = MarketDataConnector::start(
MarketDataConfig::new(cfg.clone(), "bcs-md"),
limiter.clone(),
tx.clone(),
)
.await?;
let ud = UserDataConnector::start(UserDataConfig::new(cfg, "bcs-ud"), limiter, tx).await?;
let grid = ud.price_grid().clone();
let mut bot =
TradingBot::start(&md, &ud, sber_bcs(), Journal::default(), &grid, "bcs-md").await?;
Старт бота: подписка, сверка, правила инструмента
TradingBot::start делает все по порядку: подписка на статусы и сделки, reconcile со списком из журнала, правила
инструмента (шаг цены, минимальное количество), подписка на стакан. Пока start не вернулся, бот не торгует.
/// The bot. `M` is the market data connector, `U` the connector of one account.
pub struct TradingBot<'a, M, U> {
md: &'a M,
ud: &'a U,
instrument: InstrumentId,
params: TradingParams,
pub journal: Journal,
/// The latest book. `None` after `Resync`: do not trade from a book that may be stale.
top: Option<Top>,
/// The order that works now. One at a time in this bot.
pub working: Option<ClientOrderId>,
/// The bot is stopping: no new orders.
stopping: bool,
/// A command found the gate closed, or `Overflow` came: reconcile before the next command.
need_reconcile: bool,
/// Do not place before this moment (a rate limit asked to wait).
pause_until: Option<tokio::time::Instant>,
/// What each of OUR orders has filled so far, from the order snapshots.
fills: HashMap<ClientOrderId, Qty>,
/// Which connector the book comes from, and the id of the book subscription: an `Overflow` of THAT
/// subscription needs a new subscription, an `Overflow` of the order stream needs a reconcile.
md_label: Arc<str>,
book_sub: Option<SubscriptionId>,
/// How many more orders the bot may place. The stand-in strategy buys once.
pub budget: u32,
/// The order that `stop` could not take off. A real bot alerts a person.
pub left_working: Option<ClientOrderId>,
/// The price of the last order that was accepted.
pub placed_price: Option<Price>,
}
impl<'a, M, U> TradingBot<'a, M, U>
where
M: OrderBookSource + InstrumentSource,
U: OrderExecutor + ExecutionSource,
{
/// Start-up in the order the library asks for: subscribe, reconcile, only then trade.
pub async fn start(
md: &'a M,
ud: &'a U,
instrument: InstrumentId,
mut journal: Journal,
price_grid: &brokapi::runtime::PriceGrid,
md_label: &str,
) -> Result<TradingBot<'a, M, U>, Error> {
// 1. Statuses and fills first: an event between the snapshot of the reconcile and the
// subscription would be lost otherwise.
ud.subscribe_order_state().await?;
ud.subscribe_executions().await?;
// 2. The ledger comes from the journal. A new bot has an empty one.
let report = ud.reconcile(&journal.live()).await?;
for order in &report.confirmed {
journal.set(order.cid, Entry::Working);
}
for cid in &report.closed_offline {
// Not alive at the broker any more. Find out how it ended and write it down.
let how = match ud.order_state(*cid).await {
Ok(order) => format!("{:?}", order.state),
Err(_) => "the broker does not know it".to_string(),
};
journal.set(*cid, Entry::Done(how));
}
// report.foreign: somebody else's orders. Look, do not touch.
// 3. The rules of the instrument. The price step goes to the connector too, so a price off the
// step is refused before it is sent.
let params = md.trading_params(&instrument).await?;
price_grid.learn(¶ms);
// 4. The order book. Only the latest book matters: Conflate.
let book_sub = md
.subscribe_order_book(
&instrument,
SubscribeOpts {
depth: Some(5),
..SubscribeOpts::conflate()
},
)
.await?;
Ok(Self {
md,
ud,
instrument,
params,
journal,
top: None,
working: report.confirmed.first().map(|o| o.cid),
stopping: false,
need_reconcile: false,
pause_until: None,
fills: HashMap::new(),
md_label: Arc::from(md_label),
book_sub: Some(book_sub),
budget: 1,
left_working: None,
placed_price: None,
})
}
/// One event from the shared channel.
pub async fn on_event(&mut self, env: Envelope) -> Result<(), Error> {
let source = env.source.clone();
match env.payload {
Event::OrderBook(OrderBook::Snapshot { bids, asks, .. }) => {
self.top = bids.first().map(|b| Top {
bid: b.price,
ask: asks.first().map(|a| a.price),
});
if self.working.is_none() && !self.stopping && self.budget > 0 {
self.try_buy().await?;
}
}
// The book may have lost data: forget it until the next snapshot.
Event::Resync { .. } => self.top = None,
Event::Order(order) => self.on_order(&order),
// The fills are counted from the order snapshots (`filled`), which a reconcile restores after a hole.
Event::Execution(_) => {}
// The book subscription lost events: only the book is void, subscribe again. Own events lost: trading
// is closed until a reconcile.
Event::Overflow { sub, .. }
if Some(sub) == self.book_sub && *source == *self.md_label =>
{
self.top = None;
self.md.unsubscribe(sub).await?;
self.book_sub = Some(
self.md
.subscribe_order_book(&self.instrument, SubscribeOpts::conflate())
.await?,
);
}
Event::Overflow { .. } => self.need_reconcile = true,
_ => {}
}
Ok(())
}
/// The statuses of the stream drive the journal. Final states free the bot to trade again.
fn on_order(&mut self, order: &OrderSnapshot) {
// The stream carries every order of the account, also those placed by hand. Only the journal's are ours.
if self.journal.get(order.cid).is_none() {
return;
}
self.fills.insert(order.cid, order.filled);
let entry = match &order.state {
OrderState::Acked { .. } | OrderState::PartiallyFilled { .. } => Entry::Working,
OrderState::Filled { .. } => Entry::Done("filled".into()),
OrderState::Cancelled { .. } => Entry::Done("cancelled".into()),
OrderState::Rejected { reason, .. } => Entry::Done(format!("rejected: {reason}")),
// `InFlight`, `Unknown`, and a state a later version adds: look at the broker.
_ => Entry::Check,
};
let is_final = matches!(entry, Entry::Done(_));
self.journal.set(order.cid, entry);
if is_final && self.working == Some(order.cid) {
self.working = None;
}
}
/// How much our orders have filled.
pub fn filled(&self) -> Qty {
self.fills.values().copied().sum()
}
/// A timer, not only the book, drives a retry: after `RateLimited` or a closed gate a quiet book would
/// never fire the signal again.
pub async fn on_tick(&mut self) -> Result<(), Error> {
if self.working.is_none() && !self.stopping && self.budget > 0 && self.top.is_some() {
self.try_buy().await?;
}
Ok(())
}
Что видно в коде:
- Событие
Resyncобнуляет сохраненный стакан (self.top = None). Следующий снимок его заменит. Overflowне обрабатывается на месте, а ставит флаг: сверку и дочитывание канала делает цикл, у которого есть канал.- Состояние заявки берется из потока статусов (
on_order), а не из ответа наplace. Ответ значит «брокер принял». - В поток статусов попадают все заявки счета, в том числе поставленные руками.
on_orderпропускает те, которых нет в журнале: чужую заявку бот в свой журнал не вносит (Контракт, правило 11). - Сколько исполнено, бот считает по полю
filledснимков заявок, а не складывает событияExecution. Тогда послеOverflowсверка сама возвращает правильное число (reconcileиorder_stateдают снимки). Overflowбывает двух видов. Если переполнилась подписка на стакан (ееsubи метка коннектора известны боту), стакан объявляется недействительным и подписка оформляется заново. Если переполнился поток своих заявок, нужна сверка.
Сигнал и заявка со всеми исходами
/// The signal, and the order with every outcome handled.
async fn try_buy(&mut self) -> Result<(), Error> {
if self.need_reconcile {
self.reconcile_now().await?;
}
if let Some(until) = self.pause_until {
if tokio::time::Instant::now() < until {
return Ok(());
}
self.pause_until = None;
}
let Some(top) = self.top else { return Ok(()) };
// Exact decimal arithmetic. Round DOWN for a buy so the price never exceeds the plan, and to the step.
let price = (top.bid * BUY_AT)
.round_to_step(self.params.price_step, RoundMode::Down)
.map_err(|e| Error::Config(brokapi::core::ConfigError::Message(e.to_string())))?;
// The cid is made by the bot, and written to the journal BEFORE the call.
let cid = ClientOrderId::new_v4();
self.journal.set(cid, Entry::Sending);
let request = PlaceOrderRequest {
cid,
instrument: self.instrument.clone(),
side: Side::Buy,
order_type: OrderType::Limit,
qty: self.params.min_qty,
price: Some(price),
};
match self.ud.place(request).await {
Ok(_ack) => {
self.journal.set(cid, Entry::Working);
self.working = Some(cid);
self.budget -= 1;
self.placed_price = Some(price);
}
// The broker said no. The order does not exist. A new try needs a new cid.
Err(Error::Rejected { code, message }) => {
self.journal
.set(cid, Entry::Done(format!("rejected {code}: {message}")));
}
// The gate is closed. Nothing was sent. Reconcile, and the next signal will try again.
Err(Error::NotReconciled | Error::Degraded) => {
self.journal
.set(cid, Entry::Done("not sent: gate closed".into()));
self.need_reconcile = true;
}
// The local limiter refused. Nothing was sent. Wait, do not hammer.
Err(Error::RateLimited { retry_after }) => {
self.journal
.set(cid, Entry::Done("not sent: rate limited".into()));
self.pause_until = Some(tokio::time::Instant::now() + retry_after);
}
// The command may have reached the broker. NEVER place again. Ask.
Err(Error::Unknown | Error::Transport(_)) => {
self.journal.set(cid, Entry::Check);
self.settle_unknown(cid).await?;
}
// Setup errors, Unsupported, ShuttingDown: the bot cannot go on.
Err(other) => return Err(other),
}
Ok(())
}
Повтор по таймеру: on_tick вызывается раз в четверть секунды. После RateLimited или закрытого шлюза тихий стакан сам повтор не
запустит (имитация БКС присылает один снимок), поэтому повтор ведет таймер, а не только следующий стакан.
Таблица исходов этого куска:
Что вернул place | Что значит | Что делает бот |
|---|---|---|
Ok | брокер принял, заявка стоит | запись Working, ждет статусов |
Rejected | брокер точно отказал, заявки нет | запись Done, новая попытка только с новым cid |
NotReconciled, Degraded | шлюз закрыт, ничего не ушло | пометка «сверить», запись Done: не отправлено |
RateLimited | квоты нет, ничего не ушло | пауза на retry_after, потом новый cid |
Unknown, Transport | команда могла дойти | запись Check, order_state по тому же cid |
| остальное | настройка, Unsupported, остановка | бот останавливается с ошибкой |
Выход из Unknown
Заявку не ставят второй раз. Спрашивают брокера по тому же cid. «Не найдена» сразу после обрыва не окончательно:
запрос мог быть в пути, поэтому бот ждет и спрашивает еще раз, а сдается только по истечении терпения.
/// The way out of `Unknown`: ask the broker about the same cid until it answers or patience runs out.
async fn settle_unknown(&mut self, cid: ClientOrderId) -> Result<(), Error> {
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
loop {
match self.ud.order_state(cid).await {
Ok(order) => {
// The order exists. Continue as with any other.
self.on_order(&order);
if !matches!(self.journal.get(cid), Some(Entry::Done(_))) {
self.working = Some(cid);
}
self.budget = self.budget.saturating_sub(1);
self.placed_price = order.price;
return Ok(());
}
// "Not found" right after a cut is not final: the request may still be on its way.
Err(e) if e.is_not_found() => {}
Err(Error::RateLimited { .. } | Error::Transport(_)) => {}
Err(other) => return Err(other),
}
if tokio::time::Instant::now() >= deadline {
// Still unknown to the broker: it was not placed. A new order will have a new cid.
self.journal.set(
cid,
Entry::Done("not placed (the broker never knew it)".into()),
);
return Ok(());
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
Тест запускает этот путь по-настоящему: имитация Т-Банка отвечает на place через 1,5 секунды, коннектор ждет 200
миллисекунд и возвращает Unknown, заявка при этом встала. Бот находит ее через order_state, и в журнале ровно одна
заявка.
Overflow: дочитать, сверить
/// After `Overflow` or `Degraded`: read what is waiting in the channel to the end, then reconcile.
async fn reconcile_now(&mut self) -> Result<(), Error> {
let report = self.ud.reconcile(&self.journal.live()).await?;
for order in &report.confirmed {
self.on_order(order);
}
for cid in &report.closed_offline {
let how = match self.ud.order_state(*cid).await {
Ok(order) => {
self.on_order(&order);
continue;
}
Err(_) => "the broker does not know it".to_string(),
};
self.journal.set(*cid, Entry::Done(how));
if self.working == Some(*cid) {
self.working = None;
}
}
self.need_reconcile = false;
Ok(())
}
/// The loop calls this after every event.
pub async fn recover_if_needed(
&mut self,
events: &mut mpsc::UnboundedReceiver<Envelope>,
) -> Result<(), Error> {
if self.need_reconcile {
// First the events that did arrive: they are the history up to the hole.
while let Ok(env) = events.try_recv() {
self.on_event(env).await?;
}
self.reconcile_now().await?;
}
Ok(())
}
Цикл событий
Бот разбирает свою очередь одним циклом, рядом с ним тикает таймер повторов (on_tick). Тест играет биржу: когда заявка встала, исполняет ее (server.fill) или просит бота
остановиться. Задержка deadline нужна только затем, чтобы пример не завис при ошибке.
// The loop: events in, the bot decides. A deadline keeps the example from hanging if something is wrong.
let deadline = tokio::time::Instant::now() + Duration::from_secs(20);
let mut placed = None;
let mut tick = tokio::time::interval(Duration::from_millis(250));
loop {
tokio::select! {
env = tokio::time::timeout_at(deadline, queue.recv()) => {
bot.on_event(env?.ok_or("the channel closed")?).await?;
}
_ = tick.tick() => bot.on_tick().await?,
}
bot.recover_if_needed(&mut queue).await?;
// The example plays the exchange: once the order works, fill it, or ask the bot to stop.
if ending == Ending::Filled
&& placed.is_some()
&& bot.working.is_none()
&& bot.filled().is_positive()
{
break; // the order is final and its fill is booked (the two events can come in either order)
}
if let Some(cid) = bot.working
&& placed != Some(cid)
{
placed = Some(cid);
match ending {
Ending::Filled => {
let price = Price::new(Decimal::new(24200, 2));
server.fill(&cid.hyphenated().to_string(), Qty::from(1), price);
}
Ending::StoppedByBot => break,
}
}
}
bot.stop(&mut queue).await?;
Остановка
Результат вызова (place, cancel) и событие о статусе идут разными путями, и порядок между ними не гарантирован: после cancel ответ
Ok может прийти раньше события Acked. Поэтому журнал ведется по потоку статусов, а ответ на вызов только решает, что делать дальше.
Бот перестает ставить заявки, снимает свою рабочую заявку, дожидается финального статуса и записывает его. Снимать ли заявки -
решение бота: библиотека при shutdown ничего не снимает. Отмена не бросается при первом сбое: RateLimited ждет и повторяет,
закрытый шлюз сверяется, Unknown разбирается через order_state (повторная отмена новых заявок не создает).
Остановка по Ctrl-C та же: ловите сигнал в select! рядом с циклом событий и вызывайте тот же stop.
tokio::select! {
env = queue.recv() => { /* bot.on_event(...) */ }
_ = tokio::signal::ctrl_c() => break, // потом bot.stop(&mut queue), shutdown
}
/// A clean stop. No new orders. The bot takes its own working order off (a choice of THIS bot: the
/// library never cancels anything on shutdown), waits for the final status, and writes it down.
pub async fn stop(
&mut self,
events: &mut mpsc::UnboundedReceiver<Envelope>,
) -> Result<(), Error> {
self.stopping = true;
if let Some(cid) = self.working {
for _ in 0..5 {
match self.ud.cancel(cid).await {
Ok(()) => break,
// The order was filled before the cancel arrived: read the result instead.
Err(Error::Rejected { .. }) => break,
// Nothing was sent: wait, or reconcile, and try again. Leaving the order is not an option.
Err(Error::RateLimited { retry_after }) => {
tokio::time::sleep(retry_after).await
}
Err(Error::NotReconciled | Error::Degraded) => {
// Drain first: what did arrive is the history up to the hole.
while let Ok(env) = events.try_recv() {
self.on_event(env).await?;
}
self.reconcile_now().await?
}
// Maybe cancelled, maybe not: ask, and cancel again only if it still works.
Err(Error::Unknown | Error::Transport(_)) => {
if let Ok(order) = self.ud.order_state(cid).await {
self.on_order(&order);
if self.working != Some(cid) {
break;
}
}
}
Err(other) => return Err(other),
}
}
// The final status comes on the stream; the read below is the proof.
let _ = tokio::time::timeout(Duration::from_secs(5), async {
while self.working.is_some() {
match events.recv().await {
Some(env) => self.on_event(env).await?,
None => break,
}
}
Ok::<(), Error>(())
})
.await;
if self.working.is_some() {
let order = self.ud.order_state(cid).await?;
self.on_order(&order);
}
// Still working after all this: the order stays on the exchange. Say so, do not hide it.
self.left_working = self.working;
}
Ok(())
}
Потом коннекторы: сначала UD, затем MD. shutdown забирает коннектор по значению, а бот держит на него ссылку, поэтому бота
отпускают раньше (drop(bot), иначе ошибка компиляции cannot move out of ... because it is borrowed).
// The bot borrows the connectors and `shutdown` takes them by value: let the bot go first.
drop(bot);
// Stop the account connector first (no more commands), then the market data.
let stop = ShutdownMode::Graceful {
deadline: Duration::from_secs(5),
};
let report = ud.shutdown(stop).await;
md.shutdown(stop).await;
println!(
"stopped: {} command(s) were left without an answer",
report.inflight_abandoned.len()
);
inflight_abandoned в отчете - команды, на которые еще не пришел ответ. После нормально завершенных заявок он пуст.
Как запустить
cargo test --test examples trading_bot # три теста: исполнение, остановка с отменой, выход из Unknown
cargo run --example trading_bot # то же на имитации БКС с выводом
Вся программа целиком
Для копирования: файл examples/trading_bot.rs как есть (в Cargo.toml те же зависимости, что выше, плюс
brokapi-mocks из страницы про имитации). Часть с run_bcs и run_tbank_unknown запускает бота против имитаций.
Текст программы
//! An event-driven trading bot: order book in, signal, order, statuses, a way out of `Unknown`, a clean stop.
//!
//! The code of the page "Как сделать торгового бота на событиях" of the book (docs/src/rust/howto-trading-bot.md).
//! The bot is written against the traits of `brokapi::core`, so the same `TradingBot` runs on every broker.
//! `run_bcs` plays it on the BCS imitation (the order is filled, or the bot stops and cancels it), `run_tbank_unknown`
//! plays it on the T-Bank imitation where the answer to `place` is lost, so the bot has to leave `Unknown`.
//! Nothing here touches the network or money.
//!
//! The strategy is a stand-in: when the book arrives and there is no order, buy the minimum quantity 1% below the
//! best bid. Everything around it (journal first, reconcile first, outcomes of every command, `Resync`,
//! `Overflow`, stop) is what a real bot needs.
//!
//! ```text
//! cargo run --example trading_bot
//! ```
#![allow(dead_code)]
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
// ANCHOR: imports
use brokapi::core::{
ClientOrderId, Decimal, Envelope, Error, Event, ExecutionSource, InstrumentId, InstrumentQuery,
InstrumentSource, OrderBook, OrderBookSource, OrderExecutor, OrderSnapshot, OrderState,
OrderType, PlaceOrderRequest, Price, Qty, RoundMode, ShutdownMode, Side, SubscribeOpts,
SubscriptionId, TradingParams,
};
use tokio::sync::mpsc;
// ANCHOR_END: imports
// ANCHOR: journal
/// What the bot knows about one of its orders. The journal is the bot's memory: the library keeps nothing
/// on disk. A real bot writes every change to a file or a database before it goes on (see the infrastructure
/// example). Here the journal is in memory, to keep the example short.
#[derive(Debug, Clone, PartialEq)]
pub enum Entry {
/// Written BEFORE `place` is called. If the process dies now, the order may or may not exist.
Sending,
/// The broker accepted the order, or the stream says it works.
Working,
/// The outcome of the command is not known. The bot asks the broker with `order_state`.
Check,
/// Final. The text says how it ended.
Done(String),
}
#[derive(Debug, Default)]
pub struct Journal {
pub entries: Vec<(ClientOrderId, Entry)>,
}
impl Journal {
pub fn set(&mut self, cid: ClientOrderId, entry: Entry) {
match self.entries.iter_mut().find(|(c, _)| *c == cid) {
Some(slot) => slot.1 = entry,
None => self.entries.push((cid, entry)),
}
}
pub fn get(&self, cid: ClientOrderId) -> Option<&Entry> {
self.entries.iter().find(|(c, _)| *c == cid).map(|(_, e)| e)
}
/// The cids the bot believes are alive: the ledger it gives to `reconcile`.
pub fn live(&self) -> Vec<ClientOrderId> {
self.entries
.iter()
.filter(|(_, e)| !matches!(e, Entry::Done(_)))
.map(|(c, _)| *c)
.collect()
}
}
// ANCHOR_END: journal
// ANCHOR: types
/// How far below the best bid the bot buys: 99%. (`Decimal::new` is not `const`, `from_parts` is.)
const BUY_AT: Decimal = Decimal::from_parts(99, 0, 0, false, 2);
/// The top of the book the bot trades from. Dropped on `Resync`.
#[derive(Debug, Clone, Copy)]
pub struct Top {
pub bid: Price,
pub ask: Option<Price>,
}
/// BCS names an instrument by ticker and board.
fn sber_bcs() -> InstrumentId {
use brokapi::core::{InstrumentKey, NativeId};
InstrumentId::new(
InstrumentKey::new("SBER", "TQBR"),
NativeId::TickerClass {
ticker: "SBER".into(),
class_code: "TQBR".into(),
},
)
}
/// The connectors write into a bounded channel and never wait for it. A small task moves every envelope into
/// the bot's own queue at once, so the channel is always empty and the bot may take its time (a command can wait
/// seconds for the broker). The queue is unbounded: watch its length in a real bot.
fn forward(mut events: mpsc::Receiver<Envelope>) -> mpsc::UnboundedReceiver<Envelope> {
let (queue_tx, queue) = mpsc::unbounded_channel();
tokio::spawn(async move {
while let Some(env) = events.recv().await {
if queue_tx.send(env).is_err() {
break;
}
}
});
queue
}
// ANCHOR_END: types
// ANCHOR: bot
/// The bot. `M` is the market data connector, `U` the connector of one account.
pub struct TradingBot<'a, M, U> {
md: &'a M,
ud: &'a U,
instrument: InstrumentId,
params: TradingParams,
pub journal: Journal,
/// The latest book. `None` after `Resync`: do not trade from a book that may be stale.
top: Option<Top>,
/// The order that works now. One at a time in this bot.
pub working: Option<ClientOrderId>,
/// The bot is stopping: no new orders.
stopping: bool,
/// A command found the gate closed, or `Overflow` came: reconcile before the next command.
need_reconcile: bool,
/// Do not place before this moment (a rate limit asked to wait).
pause_until: Option<tokio::time::Instant>,
/// What each of OUR orders has filled so far, from the order snapshots.
fills: HashMap<ClientOrderId, Qty>,
/// Which connector the book comes from, and the id of the book subscription: an `Overflow` of THAT
/// subscription needs a new subscription, an `Overflow` of the order stream needs a reconcile.
md_label: Arc<str>,
book_sub: Option<SubscriptionId>,
/// How many more orders the bot may place. The stand-in strategy buys once.
pub budget: u32,
/// The order that `stop` could not take off. A real bot alerts a person.
pub left_working: Option<ClientOrderId>,
/// The price of the last order that was accepted.
pub placed_price: Option<Price>,
}
impl<'a, M, U> TradingBot<'a, M, U>
where
M: OrderBookSource + InstrumentSource,
U: OrderExecutor + ExecutionSource,
{
/// Start-up in the order the library asks for: subscribe, reconcile, only then trade.
pub async fn start(
md: &'a M,
ud: &'a U,
instrument: InstrumentId,
mut journal: Journal,
price_grid: &brokapi::runtime::PriceGrid,
md_label: &str,
) -> Result<TradingBot<'a, M, U>, Error> {
// 1. Statuses and fills first: an event between the snapshot of the reconcile and the
// subscription would be lost otherwise.
ud.subscribe_order_state().await?;
ud.subscribe_executions().await?;
// 2. The ledger comes from the journal. A new bot has an empty one.
let report = ud.reconcile(&journal.live()).await?;
for order in &report.confirmed {
journal.set(order.cid, Entry::Working);
}
for cid in &report.closed_offline {
// Not alive at the broker any more. Find out how it ended and write it down.
let how = match ud.order_state(*cid).await {
Ok(order) => format!("{:?}", order.state),
Err(_) => "the broker does not know it".to_string(),
};
journal.set(*cid, Entry::Done(how));
}
// report.foreign: somebody else's orders. Look, do not touch.
// 3. The rules of the instrument. The price step goes to the connector too, so a price off the
// step is refused before it is sent.
let params = md.trading_params(&instrument).await?;
price_grid.learn(¶ms);
// 4. The order book. Only the latest book matters: Conflate.
let book_sub = md
.subscribe_order_book(
&instrument,
SubscribeOpts {
depth: Some(5),
..SubscribeOpts::conflate()
},
)
.await?;
Ok(Self {
md,
ud,
instrument,
params,
journal,
top: None,
working: report.confirmed.first().map(|o| o.cid),
stopping: false,
need_reconcile: false,
pause_until: None,
fills: HashMap::new(),
md_label: Arc::from(md_label),
book_sub: Some(book_sub),
budget: 1,
left_working: None,
placed_price: None,
})
}
/// One event from the shared channel.
pub async fn on_event(&mut self, env: Envelope) -> Result<(), Error> {
let source = env.source.clone();
match env.payload {
Event::OrderBook(OrderBook::Snapshot { bids, asks, .. }) => {
self.top = bids.first().map(|b| Top {
bid: b.price,
ask: asks.first().map(|a| a.price),
});
if self.working.is_none() && !self.stopping && self.budget > 0 {
self.try_buy().await?;
}
}
// The book may have lost data: forget it until the next snapshot.
Event::Resync { .. } => self.top = None,
Event::Order(order) => self.on_order(&order),
// The fills are counted from the order snapshots (`filled`), which a reconcile restores after a hole.
Event::Execution(_) => {}
// The book subscription lost events: only the book is void, subscribe again. Own events lost: trading
// is closed until a reconcile.
Event::Overflow { sub, .. }
if Some(sub) == self.book_sub && *source == *self.md_label =>
{
self.top = None;
self.md.unsubscribe(sub).await?;
self.book_sub = Some(
self.md
.subscribe_order_book(&self.instrument, SubscribeOpts::conflate())
.await?,
);
}
Event::Overflow { .. } => self.need_reconcile = true,
_ => {}
}
Ok(())
}
/// The statuses of the stream drive the journal. Final states free the bot to trade again.
fn on_order(&mut self, order: &OrderSnapshot) {
// The stream carries every order of the account, also those placed by hand. Only the journal's are ours.
if self.journal.get(order.cid).is_none() {
return;
}
self.fills.insert(order.cid, order.filled);
let entry = match &order.state {
OrderState::Acked { .. } | OrderState::PartiallyFilled { .. } => Entry::Working,
OrderState::Filled { .. } => Entry::Done("filled".into()),
OrderState::Cancelled { .. } => Entry::Done("cancelled".into()),
OrderState::Rejected { reason, .. } => Entry::Done(format!("rejected: {reason}")),
// `InFlight`, `Unknown`, and a state a later version adds: look at the broker.
_ => Entry::Check,
};
let is_final = matches!(entry, Entry::Done(_));
self.journal.set(order.cid, entry);
if is_final && self.working == Some(order.cid) {
self.working = None;
}
}
/// How much our orders have filled.
pub fn filled(&self) -> Qty {
self.fills.values().copied().sum()
}
/// A timer, not only the book, drives a retry: after `RateLimited` or a closed gate a quiet book would
/// never fire the signal again.
pub async fn on_tick(&mut self) -> Result<(), Error> {
if self.working.is_none() && !self.stopping && self.budget > 0 && self.top.is_some() {
self.try_buy().await?;
}
Ok(())
}
// ANCHOR_END: bot
// ANCHOR: place
/// The signal, and the order with every outcome handled.
async fn try_buy(&mut self) -> Result<(), Error> {
if self.need_reconcile {
self.reconcile_now().await?;
}
if let Some(until) = self.pause_until {
if tokio::time::Instant::now() < until {
return Ok(());
}
self.pause_until = None;
}
let Some(top) = self.top else { return Ok(()) };
// Exact decimal arithmetic. Round DOWN for a buy so the price never exceeds the plan, and to the step.
let price = (top.bid * BUY_AT)
.round_to_step(self.params.price_step, RoundMode::Down)
.map_err(|e| Error::Config(brokapi::core::ConfigError::Message(e.to_string())))?;
// The cid is made by the bot, and written to the journal BEFORE the call.
let cid = ClientOrderId::new_v4();
self.journal.set(cid, Entry::Sending);
let request = PlaceOrderRequest {
cid,
instrument: self.instrument.clone(),
side: Side::Buy,
order_type: OrderType::Limit,
qty: self.params.min_qty,
price: Some(price),
};
match self.ud.place(request).await {
Ok(_ack) => {
self.journal.set(cid, Entry::Working);
self.working = Some(cid);
self.budget -= 1;
self.placed_price = Some(price);
}
// The broker said no. The order does not exist. A new try needs a new cid.
Err(Error::Rejected { code, message }) => {
self.journal
.set(cid, Entry::Done(format!("rejected {code}: {message}")));
}
// The gate is closed. Nothing was sent. Reconcile, and the next signal will try again.
Err(Error::NotReconciled | Error::Degraded) => {
self.journal
.set(cid, Entry::Done("not sent: gate closed".into()));
self.need_reconcile = true;
}
// The local limiter refused. Nothing was sent. Wait, do not hammer.
Err(Error::RateLimited { retry_after }) => {
self.journal
.set(cid, Entry::Done("not sent: rate limited".into()));
self.pause_until = Some(tokio::time::Instant::now() + retry_after);
}
// The command may have reached the broker. NEVER place again. Ask.
Err(Error::Unknown | Error::Transport(_)) => {
self.journal.set(cid, Entry::Check);
self.settle_unknown(cid).await?;
}
// Setup errors, Unsupported, ShuttingDown: the bot cannot go on.
Err(other) => return Err(other),
}
Ok(())
}
// ANCHOR_END: place
// ANCHOR: unknown
/// The way out of `Unknown`: ask the broker about the same cid until it answers or patience runs out.
async fn settle_unknown(&mut self, cid: ClientOrderId) -> Result<(), Error> {
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
loop {
match self.ud.order_state(cid).await {
Ok(order) => {
// The order exists. Continue as with any other.
self.on_order(&order);
if !matches!(self.journal.get(cid), Some(Entry::Done(_))) {
self.working = Some(cid);
}
self.budget = self.budget.saturating_sub(1);
self.placed_price = order.price;
return Ok(());
}
// "Not found" right after a cut is not final: the request may still be on its way.
Err(e) if e.is_not_found() => {}
Err(Error::RateLimited { .. } | Error::Transport(_)) => {}
Err(other) => return Err(other),
}
if tokio::time::Instant::now() >= deadline {
// Still unknown to the broker: it was not placed. A new order will have a new cid.
self.journal.set(
cid,
Entry::Done("not placed (the broker never knew it)".into()),
);
return Ok(());
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
// ANCHOR_END: unknown
// ANCHOR: recover
/// After `Overflow` or `Degraded`: read what is waiting in the channel to the end, then reconcile.
async fn reconcile_now(&mut self) -> Result<(), Error> {
let report = self.ud.reconcile(&self.journal.live()).await?;
for order in &report.confirmed {
self.on_order(order);
}
for cid in &report.closed_offline {
let how = match self.ud.order_state(*cid).await {
Ok(order) => {
self.on_order(&order);
continue;
}
Err(_) => "the broker does not know it".to_string(),
};
self.journal.set(*cid, Entry::Done(how));
if self.working == Some(*cid) {
self.working = None;
}
}
self.need_reconcile = false;
Ok(())
}
/// The loop calls this after every event.
pub async fn recover_if_needed(
&mut self,
events: &mut mpsc::UnboundedReceiver<Envelope>,
) -> Result<(), Error> {
if self.need_reconcile {
// First the events that did arrive: they are the history up to the hole.
while let Ok(env) = events.try_recv() {
self.on_event(env).await?;
}
self.reconcile_now().await?;
}
Ok(())
}
// ANCHOR_END: recover
// ANCHOR: stop
/// A clean stop. No new orders. The bot takes its own working order off (a choice of THIS bot: the
/// library never cancels anything on shutdown), waits for the final status, and writes it down.
pub async fn stop(
&mut self,
events: &mut mpsc::UnboundedReceiver<Envelope>,
) -> Result<(), Error> {
self.stopping = true;
if let Some(cid) = self.working {
for _ in 0..5 {
match self.ud.cancel(cid).await {
Ok(()) => break,
// The order was filled before the cancel arrived: read the result instead.
Err(Error::Rejected { .. }) => break,
// Nothing was sent: wait, or reconcile, and try again. Leaving the order is not an option.
Err(Error::RateLimited { retry_after }) => {
tokio::time::sleep(retry_after).await
}
Err(Error::NotReconciled | Error::Degraded) => {
// Drain first: what did arrive is the history up to the hole.
while let Ok(env) = events.try_recv() {
self.on_event(env).await?;
}
self.reconcile_now().await?
}
// Maybe cancelled, maybe not: ask, and cancel again only if it still works.
Err(Error::Unknown | Error::Transport(_)) => {
if let Ok(order) = self.ud.order_state(cid).await {
self.on_order(&order);
if self.working != Some(cid) {
break;
}
}
}
Err(other) => return Err(other),
}
}
// The final status comes on the stream; the read below is the proof.
let _ = tokio::time::timeout(Duration::from_secs(5), async {
while self.working.is_some() {
match events.recv().await {
Some(env) => self.on_event(env).await?,
None => break,
}
}
Ok::<(), Error>(())
})
.await;
if self.working.is_some() {
let order = self.ud.order_state(cid).await?;
self.on_order(&order);
}
// Still working after all this: the order stays on the exchange. Say so, do not hide it.
self.left_working = self.working;
}
Ok(())
}
// ANCHOR_END: stop
}
/// What the example's "exchange" does with the bot's order.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Ending {
/// The exchange executes the order.
Filled,
/// The bot is told to stop while the order works, and cancels it.
StoppedByBot,
}
/// The result the tests look at.
#[derive(Debug)]
pub struct Outcome {
pub journal: Vec<(ClientOrderId, Entry)>,
pub filled: Qty,
pub price: Option<Price>,
}
/// Plays the bot on the BCS imitation. `ending` says how the order ends.
pub async fn run_bcs(ending: Ending) -> Result<Outcome, Box<dyn std::error::Error>> {
// ANCHOR: wiring
use brokapi::bcs::limits::default_limits;
use brokapi::bcs::protocol::config::BcsConfig;
use brokapi::bcs::{MarketDataConfig, MarketDataConnector, UserDataConfig, UserDataConnector};
use brokapi::runtime::BrokerLimiter;
let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
let cfg = BcsConfig::for_mock("mock-refresh-token", &server.http_base).with_write(true);
// One limiter for the token (BCS counts limits per token), one channel for all events.
let limiter = BrokerLimiter::new(&default_limits());
let (tx, events) = mpsc::channel::<Envelope>(1024);
let mut queue = forward(events);
let md = MarketDataConnector::start(
MarketDataConfig::new(cfg.clone(), "bcs-md"),
limiter.clone(),
tx.clone(),
)
.await?;
let ud = UserDataConnector::start(UserDataConfig::new(cfg, "bcs-ud"), limiter, tx).await?;
let grid = ud.price_grid().clone();
let mut bot =
TradingBot::start(&md, &ud, sber_bcs(), Journal::default(), &grid, "bcs-md").await?;
// ANCHOR_END: wiring
// ANCHOR: loop
// The loop: events in, the bot decides. A deadline keeps the example from hanging if something is wrong.
let deadline = tokio::time::Instant::now() + Duration::from_secs(20);
let mut placed = None;
let mut tick = tokio::time::interval(Duration::from_millis(250));
loop {
tokio::select! {
env = tokio::time::timeout_at(deadline, queue.recv()) => {
bot.on_event(env?.ok_or("the channel closed")?).await?;
}
_ = tick.tick() => bot.on_tick().await?,
}
bot.recover_if_needed(&mut queue).await?;
// The example plays the exchange: once the order works, fill it, or ask the bot to stop.
if ending == Ending::Filled
&& placed.is_some()
&& bot.working.is_none()
&& bot.filled().is_positive()
{
break; // the order is final and its fill is booked (the two events can come in either order)
}
if let Some(cid) = bot.working
&& placed != Some(cid)
{
placed = Some(cid);
match ending {
Ending::Filled => {
let price = Price::new(Decimal::new(24200, 2));
server.fill(&cid.hyphenated().to_string(), Qty::from(1), price);
}
Ending::StoppedByBot => break,
}
}
}
bot.stop(&mut queue).await?;
// ANCHOR_END: loop
let outcome = Outcome {
journal: bot.journal.entries.clone(),
filled: bot.filled(),
price: bot.placed_price,
};
// ANCHOR: shutdown
// The bot borrows the connectors and `shutdown` takes them by value: let the bot go first.
drop(bot);
// Stop the account connector first (no more commands), then the market data.
let stop = ShutdownMode::Graceful {
deadline: Duration::from_secs(5),
};
let report = ud.shutdown(stop).await;
md.shutdown(stop).await;
println!(
"stopped: {} command(s) were left without an answer",
report.inflight_abandoned.len()
);
// ANCHOR_END: shutdown
Ok(outcome)
}
/// Plays the bot on the T-Bank imitation, where the answer to the first `place` is lost.
pub async fn run_tbank_unknown() -> Result<Outcome, Box<dyn std::error::Error>> {
use std::sync::Arc;
use std::sync::atomic::Ordering;
use brokapi::runtime::BrokerLimiter;
use brokapi::tbank::limits::default_limits;
use brokapi::tbank::protocol::TbankConfig;
use brokapi::tbank::{
MarketDataConfig, MarketDataConnector, UserDataConfig, UserDataConnector,
};
use brokapi_mocks::tbank::{ACCOUNT_A, MockOptions, MockServer, TOKEN};
// The mock answers a place 1.5 s late, the connector gives up after 200 ms: the answer is lost, the
// order is not. That is a real `Unknown`.
let opts = Arc::new(MockOptions::default());
opts.post_delay_ms.store(1500, Ordering::SeqCst);
let server = MockServer::start(opts).await;
let cfg = TbankConfig::for_mock(TOKEN, &server.endpoint)
.with_account(ACCOUNT_A)
.with_request_timeout(Duration::from_millis(200));
let limiter = BrokerLimiter::new(&default_limits());
let (tx, events) = mpsc::channel::<Envelope>(1024);
let mut queue = forward(events);
let md = MarketDataConnector::start(
MarketDataConfig::new(cfg.clone(), "tbank-md"),
limiter.clone(),
tx.clone(),
)
.await?;
let ud = UserDataConnector::start(UserDataConfig::new(cfg, "tbank-ud"), limiter, tx).await?;
let grid = ud.price_grid().clone();
// T-Bank names an instrument by uid: ask the directory.
let sber = md
.find(InstrumentQuery::ByTicker {
ticker: "SBER".into(),
exchange: Some("TQBR".into()),
})
.await?
.into_iter()
.next()
.ok_or("SBER is not in the directory")?
.id;
let mut bot = TradingBot::start(&md, &ud, sber, Journal::default(), &grid, "tbank-md").await?;
let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
while bot.working.is_none() {
let env = tokio::time::timeout_at(deadline, queue.recv())
.await?
.ok_or("the channel closed")?;
bot.on_event(env).await?;
}
bot.stop(&mut queue).await?;
let outcome = Outcome {
journal: bot.journal.entries.clone(),
filled: bot.filled(),
price: bot.placed_price,
};
drop(bot);
let stop = ShutdownMode::Graceful {
deadline: Duration::from_secs(5),
};
ud.shutdown(stop).await;
md.shutdown(stop).await;
Ok(outcome)
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let outcome = run_bcs(Ending::Filled).await?;
println!("journal: {:?}", outcome.journal);
Ok(())
}
Как проверить результат
Что проверяют тесты. Цена заявки равна 99% лучшей цены покупки, округленной вниз до шага (241.85 при 244.30 и шаге 0.01).
В журнале ровно одна запись. После исполнения запись filled, после остановки cancelled. В сценарии с потерянным ответом
заявка одна.
Что менять под себя
- Стратегию: функция
try_buy, место, где вычисляется цена и количество. Все вокруг нее оставьте. - Брокера: замените подключение (
wiring). Для Т-Банка идентификатор инструмента берется из справочника (find, примерrun_tbank_unknown), для Финама собирается изSBER@MISX(Быстрый старт). - Журнал: замените на файл или базу, запись до
placeс принудительной записью на диск. - Несколько заявок и инструментов: состояние
workingстанет таблицей поcid, очередь по бумаге библиотека держит сама (Круг команд). - Боевой счет: сначала Перед запуском на настоящем счете.
Как собрать инфраструктуру на несколько брокеров и счетов (Rust)
Инструкция. Цель: один процесс держит несколько брокеров и счетов (БКС один, Т-Банк два на одном токене, Финам один, АЛОР один), все события
идут в один канал, заявки пишутся в журнал в файле, а после рестарта каждый счет сверяется с журналом. Правила: Контракт для
агентов, устройство: Несколько счетов и брокеров. Код ниже - куски программы
examples/infrastructure.rs. Она запускается тестом против имитаций всех брокеров, и тест проходит две жизни процесса.
Что получится
На схеме: коннекторы, общий канал, счетчики лимитов (у каждого брокера свой), журнал. Т-Банк делит один счетчик между рынком и обоими счетами, потому что считает лимит на пользователя.
flowchart TB
subgraph B[БКС: счетчик 1]
direction LR
BM[MD bcs-md] ~~~ BU[UD bcs]
end
subgraph T[Т-Банк: счетчик 2 на всех]
direction LR
TM[MD tbank-md] ~~~ TA[UD tbank-a] ~~~ TB2[UD tbank-b]
end
subgraph F[Финам: счетчик 3]
direction LR
FM[MD finam-md] ~~~ FU[UD finam]
end
subgraph A[АЛОР: счетчик 4]
direction LR
AM[MD alor-md] ~~~ AU[UD alor]
end
B ~~~ T ~~~ F ~~~ A
A -- события всех коннекторов --> Q[(Один канал событий)]
Q --> R[route: по env.source<br>в нужный счет]
R --> J[(Журнал в файле)]
J --> S[start: reconcile каждого счета<br>со своей частью журнала]
Из схемы следует: UD на счет, MD на брокера, счетчик на область квоты. Метка source у коннектора, которую вы задаете в
настройке, - единственное, по чему в общем канале видно, чье событие. Поэтому метки UD равны именам счетов.
Подключение
[package]
name = "my-bot"
edition = "2024" # нужен Rust 1.88 или новее: в коде книги цепочки let
[dependencies]
brokapi = { version = "0.1.0-alpha.5", features = ["bcs", "tbank", "finam", "alor"] }
tokio = { version = "1", features = ["full"] }
dotenvy = "0.15"
use brokapi::core::{
ClientOrderId, Envelope, Error, Event, ExecutionSource, InstrumentId, InstrumentQuery,
InstrumentSource, NativeId, OrderExecutor, OrderState, OrderType, PlaceOrderRequest,
PortfolioSource, Price, Qty, ReconcileReport, ShutdownMode, Side,
};
use brokapi::runtime::BrokerLimiter;
use tokio::sync::mpsc;
Настройки
Все, что нужно для подключения, в одной структуре. В боевом запуске она строится из окружения (BCS_REFRESH_TOKEN,
TBANK_TOKEN, TBANK_ACCOUNT_ID, TBANK_ACCOUNT_ID_2, FINAM_SECRET, FINAM_ACCOUNT_ID), в примере - из имитаций.
/// What the process needs to connect. In production it comes from the environment (`from_env`), in the example from
/// the imitations.
pub struct Settings {
pub bcs: brokapi::bcs::protocol::config::BcsConfig,
pub tbank: brokapi::tbank::protocol::TbankConfig,
/// Two accounts behind one T-Bank token.
pub tbank_accounts: [String; 2],
pub finam: brokapi::finam::FinamApiConfig,
pub finam_account: String,
pub alor: brokapi::alor::protocol::config::AlorConfig,
}
impl Settings {
/// BCS_REFRESH_TOKEN, BCS_IS_WRITE, TBANK_TOKEN, TBANK_SANDBOX, TBANK_ACCOUNT_ID, TBANK_ACCOUNT_ID_2, FINAM_SECRET,
/// FINAM_ACCOUNT_ID, ALOR_REFRESH_TOKEN, ALOR_PORTFOLIO.
pub fn from_env() -> Result<Self, Box<dyn std::error::Error>> {
dotenvy::dotenv().ok();
let (finam, account) = brokapi::finam::api_config_from_env()?;
Ok(Self {
bcs: brokapi::bcs::protocol::config::BcsConfig::from_env()?,
tbank: brokapi::tbank::protocol::TbankConfig::from_env()?,
tbank_accounts: [
std::env::var("TBANK_ACCOUNT_ID")?,
std::env::var("TBANK_ACCOUNT_ID_2")?,
],
finam,
finam_account: account.ok_or("FINAM_ACCOUNT_ID is not set")?,
alor: brokapi::alor::protocol::config::AlorConfig::from_env()?,
})
}
}
Для пробного запуска без брокеров те же настройки собираются из имитаций (запуск имитаций и их константы: Тесты против имитаций):
// The imitations live through both lives of the process, like the brokers do.
let bcs_server = mbcs::MockServer::start(Default::default()).await;
let tbank_server = mtbank::MockServer::start(Default::default()).await;
let finam_server = mfinam::MockServer::start().await;
let alor_server = malor::MockServer::start(Default::default()).await;
let settings = Settings {
bcs: brokapi::bcs::protocol::config::BcsConfig::for_mock(
"mock-refresh-token",
&bcs_server.http_base,
)
.with_write(true),
tbank: brokapi::tbank::protocol::TbankConfig::for_mock(
mtbank::TOKEN,
&tbank_server.endpoint,
),
tbank_accounts: [mtbank::ACCOUNT_A.into(), mtbank::ACCOUNT_B.into()],
finam: brokapi::finam::FinamApiConfig::new(mfinam::SECRET)
.with_endpoint(finam_server.url.clone()),
finam_account: "ACC1".into(),
alor: brokapi::alor::protocol::config::AlorConfig::for_mock(
"mock-refresh-token",
&alor_server.http_base,
),
};
MD Т-Банка не привязан к счету: его конфиг без account_id. Счета выбирают конфиги UD.
Журнал заявок в файле
Текстовый файл, одна строка на изменение: счет, cid, состояние, пояснение. Для каждой записи вызывается sync_data: журнал
должен быть на диске до вызова, который его требует. Побеждает последняя строка cid. Строка, оборванная падением,
пропускается при чтении.
/// What the journal knows about an order.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum State {
/// Written BEFORE `place`. After a crash the order may or may not exist at the broker.
Sending,
/// The broker has it and it works.
Working,
/// Final, with the text of how it ended.
Done(String),
}
/// The order journal: a text file, one line per change, `account TAB cid TAB state TAB detail`. The last line of a cid
/// wins. Every line is flushed to disk before the call that needs it: a journal that is written after `place` loses
/// the order that a crash cuts in between.
pub struct Journal {
path: PathBuf,
file: File,
orders: HashMap<ClientOrderId, (String, State)>,
}
impl Journal {
/// Opens the file (creates it if there is none) and reads what an earlier life wrote.
pub fn open(path: &Path) -> io::Result<Self> {
let mut orders = HashMap::new();
if path.exists() {
for line in BufReader::new(File::open(path)?).lines() {
let line = line?;
let mut parts = line.splitn(4, '\t');
let (Some(account), Some(cid), Some(state)) =
(parts.next(), parts.next(), parts.next())
else {
continue; // a line cut by a crash: it carries no complete change
};
let Ok(cid) = cid.parse::<ClientOrderId>() else {
continue;
};
let state = match state {
"sending" => State::Sending,
"working" => State::Working,
_ => State::Done(parts.next().unwrap_or("").to_string()),
};
orders.insert(cid, (account.to_string(), state));
}
}
let file = OpenOptions::new().create(true).append(true).open(path)?;
Ok(Self {
path: path.to_path_buf(),
file,
orders,
})
}
/// Writes a change and waits until the disk has it.
pub fn set(&mut self, account: &str, cid: ClientOrderId, state: State) -> io::Result<()> {
let (word, detail) = match &state {
State::Sending => ("sending", String::new()),
State::Working => ("working", String::new()),
State::Done(text) => ("done", text.replace(['\t', '\n', '\r'], " ")),
};
writeln!(self.file, "{account}\t{cid}\t{word}\t{detail}")?;
self.file.sync_data()?;
self.orders.insert(cid, (account.to_string(), state));
Ok(())
}
pub fn state(&self, cid: ClientOrderId) -> Option<&State> {
self.orders.get(&cid).map(|(_, s)| s)
}
/// The ledger of one account for `reconcile`: orders it believes are alive.
pub fn live(&self, account: &str) -> Vec<ClientOrderId> {
self.orders
.iter()
.filter(|(_, (a, s))| a == account && !matches!(s, State::Done(_)))
.map(|(cid, _)| *cid)
.collect()
}
}
Что нужно процессу от счета
Три брокера - три разных типа коннекторов. Общий трейт Account (его реализуют все автоматически) дает единый вид, и
остальной код не знает, какой это брокер. Исход place сведен к четырем вариантам. Чтобы убрать счет, например оставить один
счет Т-Банка вместо двух, уберите его UD из Infra, из accounts() и из ACCOUNTS.
/// What the process needs from an account, whatever the broker is. A blanket impl makes every connector one.
pub trait Account: OrderExecutor + ExecutionSource + PortfolioSource {}
impl<T: OrderExecutor + ExecutionSource + PortfolioSource> Account for T {}
/// How a `place` ended, for the caller.
#[derive(Debug, PartialEq, Eq)]
pub enum Placed {
/// The broker accepted it.
Working,
/// The broker refused it. No order exists.
Refused(String),
/// The command did not leave the process (gate closed, quota). No order exists, the cid is not spent.
NotSent(String),
/// The outcome is unknown. The journal says `Sending`; the next reconcile or `order_state` settles it.
Unknown,
}
Запуск всех коннекторов
Порядок запуска: счетчики, канал, коннекторы, подписки на статусы и сделки каждого счета, и только потом reconcile каждого
счета со своей частью журнала. У Финама один JWT на MD и UD (shared_tokens).
/// All the connectors of the process and the journal.
pub struct Infra {
pub bcs_md: brokapi::bcs::MarketDataConnector,
pub tbank_md: brokapi::tbank::MarketDataConnector,
pub finam_md: brokapi::finam::MarketDataConnector,
pub alor_md: brokapi::alor::MarketDataConnector,
pub bcs: brokapi::bcs::UserDataConnector,
pub tbank_a: brokapi::tbank::UserDataConnector,
pub tbank_b: brokapi::tbank::UserDataConnector,
pub finam: brokapi::finam::UserDataConnector,
pub alor: brokapi::alor::UserDataConnector,
pub journal: Journal,
/// Accounts that lost events (`Overflow`): reconcile them before trading.
pub degraded: Vec<&'static str>,
}
/// The names of the accounts: the journal key and the `source` label of the envelopes.
pub const ACCOUNTS: [&str; 5] = ["bcs", "tbank-a", "tbank-b", "finam", "alor"];
impl Infra {
/// Connects everything, subscribes, reconciles every account with the journal, and only then returns.
pub async fn start(
settings: &Settings,
journal_path: &Path,
) -> Result<(Self, mpsc::UnboundedReceiver<Envelope>), Box<dyn std::error::Error>> {
use brokapi::{alor, bcs, finam, tbank};
// One limiter per area where the broker counts a limit. T-Bank counts per user: both accounts and the market
// data connector share one. BCS counts per token, Finam per user, ALOR per login. The brokers never share.
let bcs_limits = BrokerLimiter::new(&bcs::limits::default_limits());
let tbank_limits = BrokerLimiter::new(&tbank::limits::default_limits());
let finam_limits = BrokerLimiter::new(&finam::limits::default_limits());
let alor_limits = BrokerLimiter::new(&alor::limits::default_limits());
// One channel for everything: the `source` label of an envelope says whose it is. A small task moves the
// envelopes into the process's own queue at once, so the library's channel is always empty and the process
// may wait for the brokers as long as it needs (rule 5 of the contract).
let (tx, channel) = mpsc::channel::<Envelope>(8192);
let events = forward(channel);
// Finam: one secret, one JWT for the market data and the account.
let finam_api = settings
.finam
.clone()
.with_tokens(settings.finam.shared_tokens().await?);
let infra = Self {
bcs_md: bcs::MarketDataConnector::start(
bcs::MarketDataConfig::new(settings.bcs.clone(), "bcs-md"),
bcs_limits.clone(),
tx.clone(),
)
.await?,
tbank_md: tbank::MarketDataConnector::start(
tbank::MarketDataConfig::new(settings.tbank.clone(), "tbank-md"),
tbank_limits.clone(),
tx.clone(),
)
.await?,
finam_md: finam::MarketDataConnector::start(
finam::MarketDataConfig::new(finam_api.clone(), "finam-md"),
finam_limits.clone(),
tx.clone(),
)
.await?,
alor_md: alor::MarketDataConnector::start(
alor::MarketDataConfig::new(settings.alor.clone(), "alor-md"),
alor_limits.clone(),
tx.clone(),
)
.await?,
// The `source` of an account connector is the account name.
bcs: bcs::UserDataConnector::start(
bcs::UserDataConfig::new(settings.bcs.clone(), "bcs"),
bcs_limits,
tx.clone(),
)
.await?,
tbank_a: tbank::UserDataConnector::start(
tbank::UserDataConfig::new(
settings
.tbank
.clone()
.with_account(&settings.tbank_accounts[0]),
"tbank-a",
),
tbank_limits.clone(),
tx.clone(),
)
.await?,
tbank_b: tbank::UserDataConnector::start(
tbank::UserDataConfig::new(
settings
.tbank
.clone()
.with_account(&settings.tbank_accounts[1]),
"tbank-b",
),
tbank_limits,
tx.clone(),
)
.await?,
finam: finam::UserDataConnector::start(
finam::UserDataConfig::new(finam_api, &settings.finam_account, "finam"),
finam_limits,
tx.clone(),
)
.await?,
alor: alor::UserDataConnector::start(
alor::UserDataConfig::new(settings.alor.clone(), "alor"),
alor_limits,
tx,
)
.await?,
journal: Journal::open(journal_path)?,
degraded: Vec::new(),
};
// Subscribe to the statuses and fills of every account FIRST, then reconcile it with ITS part of the journal.
for (_, account) in infra.accounts() {
account.subscribe_order_state().await?;
account.subscribe_executions().await?;
}
let mut infra = infra;
for name in ACCOUNTS {
infra.reconcile_retry(name).await?;
}
Ok((infra, events))
}
/// The accounts by name, as the trait the process needs.
pub fn accounts(&self) -> [(&'static str, &dyn Account); 5] {
[
("bcs", &self.bcs),
("tbank-a", &self.tbank_a),
("tbank-b", &self.tbank_b),
("finam", &self.finam),
("alor", &self.alor),
]
}
pub fn account(&self, name: &str) -> &dyn Account {
self.accounts()
.into_iter()
.find(|(n, _)| *n == name)
.map(|(_, a)| a)
.unwrap_or_else(|| panic!("no account {name}"))
}
Вспомогательные функции
Читатель канала (forward), слова состояний для журнала и распознавание «брокер не знает заявку». Последнее у брокеров разное:
Все брокеры отвечают Error::Rejected с кодом not_found (проверка Error::is_not_found()).
Терпение NOT_FOUND_PATIENCE в примере полсекунды для имитаций. Для настоящего брокера берите секунды.
/// How long a restarted process asks about an order the broker does not know, before it closes the journal line.
const NOT_FOUND_PATIENCE: Duration = Duration::from_millis(500);
/// The connectors write into a bounded channel and never wait for it: this task moves every envelope into the
/// process's own queue. The queue is unbounded, so watch its length.
fn forward(mut events: mpsc::Receiver<Envelope>) -> mpsc::UnboundedReceiver<Envelope> {
let (queue_tx, queue) = mpsc::unbounded_channel();
tokio::spawn(async move {
while let Some(env) = events.recv().await {
if queue_tx.send(env).is_err() {
break;
}
}
});
queue
}
fn state_word(state: &OrderState) -> &'static str {
match state {
OrderState::InFlight { .. } => "in flight",
OrderState::Unknown { .. } => "unknown",
OrderState::Acked { .. } => "working",
OrderState::PartiallyFilled { .. } => "partly filled",
OrderState::Filled { .. } => "filled",
OrderState::Cancelled { .. } => "cancelled",
OrderState::Rejected { .. } => "rejected",
_ => "unknown",
}
}
/// "The broker does not know this order". All brokers say it as `Rejected` with the code `not_found`.
fn is_not_found(e: &Error) -> bool {
e.is_not_found()
}
Сверка при старте и после сбоя
Каждый счет сверяется со своей частью журнала. Из отчета: confirmed - заявка стоит, closed_offline - ее нет среди живых,
значит исполнилась, снялась, отклонилась или так и не была отправлена. Узнать итог можно запросом order_state. Если брокер
заявку не знает даже после ожидания, значит, процесс упал между записью в журнал и вызовом place: запись закрывается как
«не дошла». foreign показывается, но не трогается.
/// Reconciles one account with the journal and writes down what it found. The gate of the account opens only if
/// this succeeds; if it fails, repeat it later and do not trade on that account meanwhile.
pub async fn reconcile(&mut self, name: &'static str) -> Result<ReconcileReport, Error> {
let ledger = self.journal.live(name);
let report = self.account(name).reconcile(&ledger).await?;
for order in &report.confirmed {
self.write(name, order.cid, State::Working);
}
for cid in &report.closed_offline {
// Not alive at the broker. Ask how it ended. "Not found" can also mean "never sent": a crash between the
// journal line and the call. Both are final for the journal; a new order gets a new cid.
let how = self.settle(name, *cid).await?;
self.write(name, *cid, State::Done(how));
}
// report.foreign: orders nobody here placed. Shown, never touched.
self.degraded.retain(|n| *n != name);
Ok(report)
}
/// The final state of an order that is not alive any more, as text for the journal.
async fn settle(&self, name: &str, cid: ClientOrderId) -> Result<String, Error> {
// How long "not found" may still change after a restart is not measured on a real broker: here half a second
// is enough for the imitations, take seconds for a real one.
let deadline = tokio::time::Instant::now() + NOT_FOUND_PATIENCE;
loop {
match self.account(name).order_state(cid).await {
Ok(order) => return Ok(state_word(&order.state).to_string()),
// The broker does not know it. Right after a cut that is not final, so wait a little.
Err(e) if is_not_found(&e) => {}
Err(Error::RateLimited { .. } | Error::Transport(_)) => {}
Err(other) => return Err(other),
}
if tokio::time::Instant::now() >= deadline {
return Ok("never reached the broker".to_string());
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
/// The journal must not lose a line. If the disk fails, trading must stop: there is nothing to say here.
fn write(&mut self, name: &str, cid: ClientOrderId, state: State) {
self.journal
.set(name, cid, state)
.expect("the journal must be writable");
}
Сверка при старте идет через reconcile_retry: лимит или сбой сети не убивают процесс, а повторяются с паузой, остальные ошибки
возвращаются. После Overflow счета из degraded сверяются методом recover: сначала очередь читается до конца, потом
сверка.
/// A reconcile that does not give up on the first hiccup: a limit or a network error means "no sealed answer
/// yet", so wait and ask again, a few times. Any other error is returned: a retry would fail the same way.
pub async fn reconcile_retry(&mut self, name: &'static str) -> Result<ReconcileReport, Error> {
let mut pause = Duration::from_millis(200);
for attempt in 1..=5 {
match self.reconcile(name).await {
Ok(report) => return Ok(report),
Err(Error::RateLimited { retry_after }) if attempt < 5 => {
tokio::time::sleep(retry_after).await
}
Err(Error::Transport(_)) if attempt < 5 => {
tokio::time::sleep(pause).await;
pause *= 2;
}
Err(other) => return Err(other),
}
}
unreachable!("the last attempt returns")
}
/// After `Overflow` (the accounts in `degraded`): read the queue to the end, THEN reconcile each such account.
/// The events that did arrive are the history up to the hole. The states after the hole come from the reconcile
/// and `order_state`; fills since the hole from `executions(range)` if the process keeps its own totals.
pub async fn recover(
&mut self,
events: &mut mpsc::UnboundedReceiver<Envelope>,
) -> Result<(), Error> {
while let Ok(env) = events.try_recv() {
self.route(env);
}
for name in std::mem::take(&mut self.degraded) {
self.reconcile_retry(name).await?;
}
Ok(())
}
Заявка: журнал, вызов, журнал
/// Places an order on an account: journal first, then the command, then the journal again.
pub async fn place(&mut self, name: &'static str, req: PlaceOrderRequest) -> Placed {
if self.degraded.contains(&name) {
return Placed::NotSent("the account lost events, reconcile first".into());
}
let cid = req.cid;
self.write(name, cid, State::Sending);
match self.account(name).place(req).await {
Ok(_) => {
self.write(name, cid, State::Working);
Placed::Working
}
Err(Error::Rejected { code, message }) => {
self.write(name, cid, State::Done(format!("rejected {code}")));
Placed::Refused(message)
}
// Nothing left the process: a closed gate, a local limit, a wrong setup, a missing capability, a stop.
Err(
e @ (Error::NotReconciled
| Error::Degraded
| Error::ShuttingDown
| Error::RateLimited { .. }
| Error::Config(_)
| Error::Unsupported { .. }),
) => {
self.write(name, cid, State::Done("not sent".into()));
Placed::NotSent(e.to_string())
}
// Unknown or Transport: the order may exist. Leave `Sending`: it is in the ledger of the next reconcile.
// An error a later version adds is treated the same way: the safe reading is "may exist".
Err(_) => Placed::Unknown,
}
}
Вариант NotSent покрывает все ошибки, при которых ничего не ушло: закрытый шлюз, остановка, локальный лимит, неверная
настройка, неподдерживаемая возможность. Unknown и Transport оставляют заявку возможно существующей.
Unknown оставляет в журнале Sending. Эта запись попадет в список следующей сверки, и order_state скажет, чем кончилось.
Повторять place нельзя.
Разбор общего канала
/// One envelope of the shared channel: the label says whose it is, the payload what to do with it.
pub fn route(&mut self, env: Envelope) {
let Some(name) = ACCOUNTS.iter().copied().find(|n| **n == *env.source) else {
return; // a market data connector: the strategy reads those, not the infrastructure
};
match env.payload {
Event::Order(order) => {
let state = match &order.state {
OrderState::Acked { .. } | OrderState::PartiallyFilled { .. } => State::Working,
OrderState::InFlight { .. } | OrderState::Unknown { .. } => return,
final_state => State::Done(state_word(final_state).to_string()),
};
self.write(name, order.cid, state);
}
Event::Overflow { .. } => self.degraded.push(name),
_ => {}
}
}
Журнал не зависит от событий для правильности: place записывает Working по ответу, а сверка при старте исправит все остальное.
События лишь ускоряют запись итогов. Поэтому для примера не важно, что статусы БКС приходят после подключения сокета в фоне
(ловушка в тестах): потеряно только раннее событие, итог узнает сверка.
События маркетдаты (bcs-md, tbank-md, finam-md, alor-md) инфраструктура пропускает: их читает стратегия. Overflow счета
помечает его как «сверить до торговли».
Остановка
/// Stops everything. Accounts first, so no command is taken while the market data is still closing. Orders at the
/// brokers are not touched.
pub async fn stop(self, mode: ShutdownMode) {
self.bcs.shutdown(mode).await;
self.tbank_a.shutdown(mode).await;
self.tbank_b.shutdown(mode).await;
self.finam.shutdown(mode).await;
self.alor.shutdown(mode).await;
self.bcs_md.shutdown(mode).await;
self.tbank_md.shutdown(mode).await;
self.finam_md.shutdown(mode).await;
self.alor_md.shutdown(mode).await;
}
В отчете shutdown у БКС unsubscribed всегда 0: сессии WebSocket отписываются не отдельными сообщениями. У Т-Банка и Финама
счет подписок считается.
Вся программа целиком
Текст программы examples/infrastructure.rs
//! One process, several brokers and accounts: a shared event channel, one order journal in a file, and a restart
//! that reconciles every account against that journal.
//!
//! The code of the page "Как собрать инфраструктуру на несколько брокеров и счетов" of the book
//! (docs/src/rust/howto-infrastructure.md). The accounts are BCS (one), T-Bank (two, one token), Finam (one) and ALOR (one). `run` plays
//! two lives of the process against the in-process imitations: the first places an order on every account and goes away
//! without cancelling (and leaves a journal line for an order that was never sent), while the process is down one order
//! is filled, the second life reconciles every account with the journal and finds out what became of everything.
//! Nothing here touches the network or money.
//!
//! ```text
//! cargo run --example infrastructure
//! ```
#![allow(dead_code)]
use std::collections::HashMap;
use std::fs::{File, OpenOptions};
use std::io::{self, BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::time::Duration;
// ANCHOR: imports
use brokapi::core::{
ClientOrderId, Envelope, Error, Event, ExecutionSource, InstrumentId, InstrumentQuery,
InstrumentSource, NativeId, OrderExecutor, OrderState, OrderType, PlaceOrderRequest,
PortfolioSource, Price, Qty, ReconcileReport, ShutdownMode, Side,
};
use brokapi::runtime::BrokerLimiter;
use tokio::sync::mpsc;
// ANCHOR_END: imports
// ANCHOR: journal
/// What the journal knows about an order.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum State {
/// Written BEFORE `place`. After a crash the order may or may not exist at the broker.
Sending,
/// The broker has it and it works.
Working,
/// Final, with the text of how it ended.
Done(String),
}
/// The order journal: a text file, one line per change, `account TAB cid TAB state TAB detail`. The last line of a cid
/// wins. Every line is flushed to disk before the call that needs it: a journal that is written after `place` loses
/// the order that a crash cuts in between.
pub struct Journal {
path: PathBuf,
file: File,
orders: HashMap<ClientOrderId, (String, State)>,
}
impl Journal {
/// Opens the file (creates it if there is none) and reads what an earlier life wrote.
pub fn open(path: &Path) -> io::Result<Self> {
let mut orders = HashMap::new();
if path.exists() {
for line in BufReader::new(File::open(path)?).lines() {
let line = line?;
let mut parts = line.splitn(4, '\t');
let (Some(account), Some(cid), Some(state)) =
(parts.next(), parts.next(), parts.next())
else {
continue; // a line cut by a crash: it carries no complete change
};
let Ok(cid) = cid.parse::<ClientOrderId>() else {
continue;
};
let state = match state {
"sending" => State::Sending,
"working" => State::Working,
_ => State::Done(parts.next().unwrap_or("").to_string()),
};
orders.insert(cid, (account.to_string(), state));
}
}
let file = OpenOptions::new().create(true).append(true).open(path)?;
Ok(Self {
path: path.to_path_buf(),
file,
orders,
})
}
/// Writes a change and waits until the disk has it.
pub fn set(&mut self, account: &str, cid: ClientOrderId, state: State) -> io::Result<()> {
let (word, detail) = match &state {
State::Sending => ("sending", String::new()),
State::Working => ("working", String::new()),
State::Done(text) => ("done", text.replace(['\t', '\n', '\r'], " ")),
};
writeln!(self.file, "{account}\t{cid}\t{word}\t{detail}")?;
self.file.sync_data()?;
self.orders.insert(cid, (account.to_string(), state));
Ok(())
}
pub fn state(&self, cid: ClientOrderId) -> Option<&State> {
self.orders.get(&cid).map(|(_, s)| s)
}
/// The ledger of one account for `reconcile`: orders it believes are alive.
pub fn live(&self, account: &str) -> Vec<ClientOrderId> {
self.orders
.iter()
.filter(|(_, (a, s))| a == account && !matches!(s, State::Done(_)))
.map(|(cid, _)| *cid)
.collect()
}
}
// ANCHOR_END: journal
// ANCHOR: settings
/// What the process needs to connect. In production it comes from the environment (`from_env`), in the example from
/// the imitations.
pub struct Settings {
pub bcs: brokapi::bcs::protocol::config::BcsConfig,
pub tbank: brokapi::tbank::protocol::TbankConfig,
/// Two accounts behind one T-Bank token.
pub tbank_accounts: [String; 2],
pub finam: brokapi::finam::FinamApiConfig,
pub finam_account: String,
pub alor: brokapi::alor::protocol::config::AlorConfig,
}
impl Settings {
/// BCS_REFRESH_TOKEN, BCS_IS_WRITE, TBANK_TOKEN, TBANK_SANDBOX, TBANK_ACCOUNT_ID, TBANK_ACCOUNT_ID_2, FINAM_SECRET,
/// FINAM_ACCOUNT_ID, ALOR_REFRESH_TOKEN, ALOR_PORTFOLIO.
pub fn from_env() -> Result<Self, Box<dyn std::error::Error>> {
dotenvy::dotenv().ok();
let (finam, account) = brokapi::finam::api_config_from_env()?;
Ok(Self {
bcs: brokapi::bcs::protocol::config::BcsConfig::from_env()?,
tbank: brokapi::tbank::protocol::TbankConfig::from_env()?,
tbank_accounts: [
std::env::var("TBANK_ACCOUNT_ID")?,
std::env::var("TBANK_ACCOUNT_ID_2")?,
],
finam,
finam_account: account.ok_or("FINAM_ACCOUNT_ID is not set")?,
alor: brokapi::alor::protocol::config::AlorConfig::from_env()?,
})
}
}
// ANCHOR_END: settings
// ANCHOR: account
/// What the process needs from an account, whatever the broker is. A blanket impl makes every connector one.
pub trait Account: OrderExecutor + ExecutionSource + PortfolioSource {}
impl<T: OrderExecutor + ExecutionSource + PortfolioSource> Account for T {}
/// How a `place` ended, for the caller.
#[derive(Debug, PartialEq, Eq)]
pub enum Placed {
/// The broker accepted it.
Working,
/// The broker refused it. No order exists.
Refused(String),
/// The command did not leave the process (gate closed, quota). No order exists, the cid is not spent.
NotSent(String),
/// The outcome is unknown. The journal says `Sending`; the next reconcile or `order_state` settles it.
Unknown,
}
// ANCHOR_END: account
// ANCHOR: infra
/// All the connectors of the process and the journal.
pub struct Infra {
pub bcs_md: brokapi::bcs::MarketDataConnector,
pub tbank_md: brokapi::tbank::MarketDataConnector,
pub finam_md: brokapi::finam::MarketDataConnector,
pub alor_md: brokapi::alor::MarketDataConnector,
pub bcs: brokapi::bcs::UserDataConnector,
pub tbank_a: brokapi::tbank::UserDataConnector,
pub tbank_b: brokapi::tbank::UserDataConnector,
pub finam: brokapi::finam::UserDataConnector,
pub alor: brokapi::alor::UserDataConnector,
pub journal: Journal,
/// Accounts that lost events (`Overflow`): reconcile them before trading.
pub degraded: Vec<&'static str>,
}
/// The names of the accounts: the journal key and the `source` label of the envelopes.
pub const ACCOUNTS: [&str; 5] = ["bcs", "tbank-a", "tbank-b", "finam", "alor"];
impl Infra {
/// Connects everything, subscribes, reconciles every account with the journal, and only then returns.
pub async fn start(
settings: &Settings,
journal_path: &Path,
) -> Result<(Self, mpsc::UnboundedReceiver<Envelope>), Box<dyn std::error::Error>> {
use brokapi::{alor, bcs, finam, tbank};
// One limiter per area where the broker counts a limit. T-Bank counts per user: both accounts and the market
// data connector share one. BCS counts per token, Finam per user, ALOR per login. The brokers never share.
let bcs_limits = BrokerLimiter::new(&bcs::limits::default_limits());
let tbank_limits = BrokerLimiter::new(&tbank::limits::default_limits());
let finam_limits = BrokerLimiter::new(&finam::limits::default_limits());
let alor_limits = BrokerLimiter::new(&alor::limits::default_limits());
// One channel for everything: the `source` label of an envelope says whose it is. A small task moves the
// envelopes into the process's own queue at once, so the library's channel is always empty and the process
// may wait for the brokers as long as it needs (rule 5 of the contract).
let (tx, channel) = mpsc::channel::<Envelope>(8192);
let events = forward(channel);
// Finam: one secret, one JWT for the market data and the account.
let finam_api = settings
.finam
.clone()
.with_tokens(settings.finam.shared_tokens().await?);
let infra = Self {
bcs_md: bcs::MarketDataConnector::start(
bcs::MarketDataConfig::new(settings.bcs.clone(), "bcs-md"),
bcs_limits.clone(),
tx.clone(),
)
.await?,
tbank_md: tbank::MarketDataConnector::start(
tbank::MarketDataConfig::new(settings.tbank.clone(), "tbank-md"),
tbank_limits.clone(),
tx.clone(),
)
.await?,
finam_md: finam::MarketDataConnector::start(
finam::MarketDataConfig::new(finam_api.clone(), "finam-md"),
finam_limits.clone(),
tx.clone(),
)
.await?,
alor_md: alor::MarketDataConnector::start(
alor::MarketDataConfig::new(settings.alor.clone(), "alor-md"),
alor_limits.clone(),
tx.clone(),
)
.await?,
// The `source` of an account connector is the account name.
bcs: bcs::UserDataConnector::start(
bcs::UserDataConfig::new(settings.bcs.clone(), "bcs"),
bcs_limits,
tx.clone(),
)
.await?,
tbank_a: tbank::UserDataConnector::start(
tbank::UserDataConfig::new(
settings
.tbank
.clone()
.with_account(&settings.tbank_accounts[0]),
"tbank-a",
),
tbank_limits.clone(),
tx.clone(),
)
.await?,
tbank_b: tbank::UserDataConnector::start(
tbank::UserDataConfig::new(
settings
.tbank
.clone()
.with_account(&settings.tbank_accounts[1]),
"tbank-b",
),
tbank_limits,
tx.clone(),
)
.await?,
finam: finam::UserDataConnector::start(
finam::UserDataConfig::new(finam_api, &settings.finam_account, "finam"),
finam_limits,
tx.clone(),
)
.await?,
alor: alor::UserDataConnector::start(
alor::UserDataConfig::new(settings.alor.clone(), "alor"),
alor_limits,
tx,
)
.await?,
journal: Journal::open(journal_path)?,
degraded: Vec::new(),
};
// Subscribe to the statuses and fills of every account FIRST, then reconcile it with ITS part of the journal.
for (_, account) in infra.accounts() {
account.subscribe_order_state().await?;
account.subscribe_executions().await?;
}
let mut infra = infra;
for name in ACCOUNTS {
infra.reconcile_retry(name).await?;
}
Ok((infra, events))
}
/// The accounts by name, as the trait the process needs.
pub fn accounts(&self) -> [(&'static str, &dyn Account); 5] {
[
("bcs", &self.bcs),
("tbank-a", &self.tbank_a),
("tbank-b", &self.tbank_b),
("finam", &self.finam),
("alor", &self.alor),
]
}
pub fn account(&self, name: &str) -> &dyn Account {
self.accounts()
.into_iter()
.find(|(n, _)| *n == name)
.map(|(_, a)| a)
.unwrap_or_else(|| panic!("no account {name}"))
}
// ANCHOR_END: infra
// ANCHOR: recover
/// A reconcile that does not give up on the first hiccup: a limit or a network error means "no sealed answer
/// yet", so wait and ask again, a few times. Any other error is returned: a retry would fail the same way.
pub async fn reconcile_retry(&mut self, name: &'static str) -> Result<ReconcileReport, Error> {
let mut pause = Duration::from_millis(200);
for attempt in 1..=5 {
match self.reconcile(name).await {
Ok(report) => return Ok(report),
Err(Error::RateLimited { retry_after }) if attempt < 5 => {
tokio::time::sleep(retry_after).await
}
Err(Error::Transport(_)) if attempt < 5 => {
tokio::time::sleep(pause).await;
pause *= 2;
}
Err(other) => return Err(other),
}
}
unreachable!("the last attempt returns")
}
/// After `Overflow` (the accounts in `degraded`): read the queue to the end, THEN reconcile each such account.
/// The events that did arrive are the history up to the hole. The states after the hole come from the reconcile
/// and `order_state`; fills since the hole from `executions(range)` if the process keeps its own totals.
pub async fn recover(
&mut self,
events: &mut mpsc::UnboundedReceiver<Envelope>,
) -> Result<(), Error> {
while let Ok(env) = events.try_recv() {
self.route(env);
}
for name in std::mem::take(&mut self.degraded) {
self.reconcile_retry(name).await?;
}
Ok(())
}
// ANCHOR_END: recover
// ANCHOR: reconcile
/// Reconciles one account with the journal and writes down what it found. The gate of the account opens only if
/// this succeeds; if it fails, repeat it later and do not trade on that account meanwhile.
pub async fn reconcile(&mut self, name: &'static str) -> Result<ReconcileReport, Error> {
let ledger = self.journal.live(name);
let report = self.account(name).reconcile(&ledger).await?;
for order in &report.confirmed {
self.write(name, order.cid, State::Working);
}
for cid in &report.closed_offline {
// Not alive at the broker. Ask how it ended. "Not found" can also mean "never sent": a crash between the
// journal line and the call. Both are final for the journal; a new order gets a new cid.
let how = self.settle(name, *cid).await?;
self.write(name, *cid, State::Done(how));
}
// report.foreign: orders nobody here placed. Shown, never touched.
self.degraded.retain(|n| *n != name);
Ok(report)
}
/// The final state of an order that is not alive any more, as text for the journal.
async fn settle(&self, name: &str, cid: ClientOrderId) -> Result<String, Error> {
// How long "not found" may still change after a restart is not measured on a real broker: here half a second
// is enough for the imitations, take seconds for a real one.
let deadline = tokio::time::Instant::now() + NOT_FOUND_PATIENCE;
loop {
match self.account(name).order_state(cid).await {
Ok(order) => return Ok(state_word(&order.state).to_string()),
// The broker does not know it. Right after a cut that is not final, so wait a little.
Err(e) if is_not_found(&e) => {}
Err(Error::RateLimited { .. } | Error::Transport(_)) => {}
Err(other) => return Err(other),
}
if tokio::time::Instant::now() >= deadline {
return Ok("never reached the broker".to_string());
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
/// The journal must not lose a line. If the disk fails, trading must stop: there is nothing to say here.
fn write(&mut self, name: &str, cid: ClientOrderId, state: State) {
self.journal
.set(name, cid, state)
.expect("the journal must be writable");
}
// ANCHOR_END: reconcile
// ANCHOR: place
/// Places an order on an account: journal first, then the command, then the journal again.
pub async fn place(&mut self, name: &'static str, req: PlaceOrderRequest) -> Placed {
if self.degraded.contains(&name) {
return Placed::NotSent("the account lost events, reconcile first".into());
}
let cid = req.cid;
self.write(name, cid, State::Sending);
match self.account(name).place(req).await {
Ok(_) => {
self.write(name, cid, State::Working);
Placed::Working
}
Err(Error::Rejected { code, message }) => {
self.write(name, cid, State::Done(format!("rejected {code}")));
Placed::Refused(message)
}
// Nothing left the process: a closed gate, a local limit, a wrong setup, a missing capability, a stop.
Err(
e @ (Error::NotReconciled
| Error::Degraded
| Error::ShuttingDown
| Error::RateLimited { .. }
| Error::Config(_)
| Error::Unsupported { .. }),
) => {
self.write(name, cid, State::Done("not sent".into()));
Placed::NotSent(e.to_string())
}
// Unknown or Transport: the order may exist. Leave `Sending`: it is in the ledger of the next reconcile.
// An error a later version adds is treated the same way: the safe reading is "may exist".
Err(_) => Placed::Unknown,
}
}
// ANCHOR_END: place
// ANCHOR: route
/// One envelope of the shared channel: the label says whose it is, the payload what to do with it.
pub fn route(&mut self, env: Envelope) {
let Some(name) = ACCOUNTS.iter().copied().find(|n| **n == *env.source) else {
return; // a market data connector: the strategy reads those, not the infrastructure
};
match env.payload {
Event::Order(order) => {
let state = match &order.state {
OrderState::Acked { .. } | OrderState::PartiallyFilled { .. } => State::Working,
OrderState::InFlight { .. } | OrderState::Unknown { .. } => return,
final_state => State::Done(state_word(final_state).to_string()),
};
self.write(name, order.cid, state);
}
Event::Overflow { .. } => self.degraded.push(name),
_ => {}
}
}
// ANCHOR_END: route
// ANCHOR: stop
/// Stops everything. Accounts first, so no command is taken while the market data is still closing. Orders at the
/// brokers are not touched.
pub async fn stop(self, mode: ShutdownMode) {
self.bcs.shutdown(mode).await;
self.tbank_a.shutdown(mode).await;
self.tbank_b.shutdown(mode).await;
self.finam.shutdown(mode).await;
self.alor.shutdown(mode).await;
self.bcs_md.shutdown(mode).await;
self.tbank_md.shutdown(mode).await;
self.finam_md.shutdown(mode).await;
self.alor_md.shutdown(mode).await;
}
// ANCHOR_END: stop
}
// ANCHOR: helpers
/// How long a restarted process asks about an order the broker does not know, before it closes the journal line.
const NOT_FOUND_PATIENCE: Duration = Duration::from_millis(500);
/// The connectors write into a bounded channel and never wait for it: this task moves every envelope into the
/// process's own queue. The queue is unbounded, so watch its length.
fn forward(mut events: mpsc::Receiver<Envelope>) -> mpsc::UnboundedReceiver<Envelope> {
let (queue_tx, queue) = mpsc::unbounded_channel();
tokio::spawn(async move {
while let Some(env) = events.recv().await {
if queue_tx.send(env).is_err() {
break;
}
}
});
queue
}
fn state_word(state: &OrderState) -> &'static str {
match state {
OrderState::InFlight { .. } => "in flight",
OrderState::Unknown { .. } => "unknown",
OrderState::Acked { .. } => "working",
OrderState::PartiallyFilled { .. } => "partly filled",
OrderState::Filled { .. } => "filled",
OrderState::Cancelled { .. } => "cancelled",
OrderState::Rejected { .. } => "rejected",
_ => "unknown",
}
}
/// "The broker does not know this order". All brokers say it as `Rejected` with the code `not_found`.
fn is_not_found(e: &Error) -> bool {
e.is_not_found()
}
// ANCHOR_END: helpers
/// The instrument of each account in the form its broker wants. In real code every one comes from `find`.
pub async fn sber_for(
infra: &Infra,
name: &str,
) -> Result<InstrumentId, Box<dyn std::error::Error>> {
use brokapi::core::InstrumentKey;
Ok(match name {
"bcs" => InstrumentId::new(
InstrumentKey::new("SBER", "TQBR"),
NativeId::TickerClass {
ticker: "SBER".into(),
class_code: "TQBR".into(),
},
),
"tbank-a" | "tbank-b" => {
infra
.tbank_md
.find(InstrumentQuery::ByTicker {
ticker: "SBER".into(),
exchange: Some("TQBR".into()),
})
.await?
.into_iter()
.next()
.ok_or("SBER is not in the T-Bank directory")?
.id
}
"alor" => brokapi::alor::util::instrument_id("SBER", "TQBR"),
_ => brokapi::finam::util::instrument_from_symbol("SBER@MISX"),
})
}
fn buy(instrument: InstrumentId, cid: ClientOrderId) -> PlaceOrderRequest {
PlaceOrderRequest {
cid,
instrument,
side: Side::Buy,
order_type: OrderType::Limit,
qty: Qty::from(10),
price: Some(Price::from(200)),
}
}
/// What the test looks at after the two lives.
#[derive(Debug)]
pub struct Result2 {
pub cids: HashMap<&'static str, ClientOrderId>,
pub orphan: ClientOrderId,
pub journal: HashMap<ClientOrderId, State>,
pub reports: HashMap<&'static str, (usize, usize, usize)>,
}
/// Reads the channel into the journal until `done` holds or `how_long` passes.
async fn pump(
infra: &mut Infra,
events: &mut mpsc::UnboundedReceiver<Envelope>,
how_long: Duration,
done: impl Fn(&Infra) -> bool,
) {
let _ = tokio::time::timeout(how_long, async {
while !done(infra) {
match events.recv().await {
Some(env) => infra.route(env),
None => break,
}
}
})
.await;
}
/// The two lives of the process. `dir` holds the journal.
pub async fn run(dir: &Path) -> Result<Result2, Box<dyn std::error::Error>> {
use brokapi_mocks::{alor as malor, bcs as mbcs, finam as mfinam, tbank as mtbank};
// ANCHOR: mock_settings
// The imitations live through both lives of the process, like the brokers do.
let bcs_server = mbcs::MockServer::start(Default::default()).await;
let tbank_server = mtbank::MockServer::start(Default::default()).await;
let finam_server = mfinam::MockServer::start().await;
let alor_server = malor::MockServer::start(Default::default()).await;
let settings = Settings {
bcs: brokapi::bcs::protocol::config::BcsConfig::for_mock(
"mock-refresh-token",
&bcs_server.http_base,
)
.with_write(true),
tbank: brokapi::tbank::protocol::TbankConfig::for_mock(
mtbank::TOKEN,
&tbank_server.endpoint,
),
tbank_accounts: [mtbank::ACCOUNT_A.into(), mtbank::ACCOUNT_B.into()],
finam: brokapi::finam::FinamApiConfig::new(mfinam::SECRET)
.with_endpoint(finam_server.url.clone()),
finam_account: "ACC1".into(),
alor: brokapi::alor::protocol::config::AlorConfig::for_mock(
"mock-refresh-token",
&alor_server.http_base,
),
};
// ANCHOR_END: mock_settings
let journal_path = dir.join("orders.journal");
let _ = std::fs::remove_file(&journal_path);
// ---- first life: an order on every account, then the process goes away without cancelling anything.
let (mut infra, mut events) = Infra::start(&settings, &journal_path).await?;
let mut cids = HashMap::new();
for name in ACCOUNTS {
let cid = ClientOrderId::new_v4();
let instrument = sber_for(&infra, name).await?;
let placed = infra.place(name, buy(instrument, cid)).await;
assert_eq!(placed, Placed::Working, "{name}");
cids.insert(name, cid);
}
// The journal line of an order that a crash cut off before `place` was called.
let orphan = ClientOrderId::new_v4();
infra.journal.set("tbank-b", orphan, State::Sending)?;
pump(&mut infra, &mut events, Duration::from_secs(5), |i| {
ACCOUNTS
.iter()
.all(|n| i.journal.state(cids[n]) == Some(&State::Working))
})
.await;
infra.stop(ShutdownMode::Immediate).await;
println!("first life over: the orders stay on the exchanges");
// ---- while the process is down, the order of T-Bank account A is executed.
tbank_server.fill(mtbank::ACCOUNT_A, &cids["tbank-a"].hyphenated().to_string());
// ---- second life: the journal is read, every account is reconciled with its part of it.
let (mut infra, mut events) = Infra::start(&settings, &journal_path).await?;
let mut reports = HashMap::new();
for name in ACCOUNTS {
// `start` reconciled already; a second reconcile is cheap, safe, and shows the report.
let r = infra.reconcile(name).await?;
reports.insert(
name,
(r.confirmed.len(), r.closed_offline.len(), r.foreign.len()),
);
}
// Take the remaining orders off before leaving (a choice of this process; the library never does it).
for name in ["bcs", "tbank-b", "finam", "alor"] {
infra.account(name).cancel(cids[name]).await?;
}
pump(&mut infra, &mut events, Duration::from_secs(5), |i| {
["bcs", "tbank-b", "finam", "alor"]
.iter()
.all(|n| matches!(i.journal.state(cids[n]), Some(State::Done(_))))
})
.await;
let journal = ACCOUNTS
.iter()
.map(|n| cids[n])
.chain([orphan])
.filter_map(|c| infra.journal.state(c).cloned().map(|s| (c, s)))
.collect();
// An account that lost events is reconciled after the queue is read to the end.
infra.degraded.push("bcs");
infra.recover(&mut events).await?;
assert!(infra.degraded.is_empty());
infra
.stop(ShutdownMode::Graceful {
deadline: Duration::from_secs(5),
})
.await;
Ok(Result2 {
cids,
orphan,
journal,
reports,
})
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let dir = std::env::temp_dir();
let r = run(&dir).await?;
for (account, cid) in &r.cids {
println!("{account}: {cid} -> {:?}", r.journal[cid]);
}
Ok(())
}
Как проверить
cargo test --test examples infrastructure # в клоне репозитория
cargo run --example infrastructure # то же с выводом
Вне клона скопируйте файл программы выше в свой проект (зависимости как в начале страницы плюс brokapi-mocks) и вызовите
run(&каталог) из main.
Тест проходит две жизни процесса. Первая: заявка на каждом из четырех счетов, плюс строка журнала для заявки, которая так и не
была отправлена (падение между журналом и вызовом), процесс уходит, заявки остаются на бирже. Пока процесса нет, заявка
Т-Банка (счет A) исполняется. Вторая жизнь читает журнал и сверяет каждый счет. Проверяется: исполненная заявка закрыта как
filled, три живые найдены в confirmed и сняты процессом, неотправленная закрыта как «не дошла», чужих заявок нет.
Что менять под себя
- Состав счетов: добавьте UD и имя в
ACCOUNTSиaccounts(). Правило прежнее: UD на счет, MD на брокера, счетчик на область. - Журнал: вместо файла любая база. Условие одно: запись дошла до диска до вызова.
- Стратегию подключите к
route: рыночные события не обрабатываются инфраструктурой, их можно отдавать стратегии поenv.sourceиenv.sub. - Автосверку после
Overflow:degradedсодержит счета, которые надо сверить. Вызовитеreconcileдля каждого, предварительно дочитав канал (Если что-то пошло не так).
Как собрать исследовательский движок
Инструкция. Цель: выгрузить длинную историю свечей и сделок по нескольким бумагам у нескольких брокеров, не нарушая лимитов,
записать поток событий в файл и проиграть его из файла столько раз, сколько нужно для разбора. Правила: Контракт для
агентов. Код ниже - куски программы examples/research.rs. Она запускается тестом против имитаций брокеров: имитации, как настоящие брокеры, отклоняют запрос длиннее предела и отдают ряд под заданный период.
Что получится
flowchart TB
subgraph BR[Брокеры, у каждого свой счетчик лимитов]
B[БКС: 1440 баров на запрос]
T[Т-Банк: от суток до 10 лет<br>по таймфрейму]
F[Финам: осторожные 500 баров]
A[АЛОР: предел не назван,<br>окно задает бот]
end
BR --> W[CandlePages: окна по пределу,<br>ожидание RateLimited, склейка без дублей]
W --> D[(Файлы баров по бумагам<br>файл сам себе контрольная точка)]
BR --> L[Живой поток событий]
L --> R[record: файл events.log]
R --> P[replay: те же события из файла]
L --> A1[analyze: живой разбор]
P --> A2[analyze: разбор из файла]
Из схемы следует: история и живой поток независимы. Историю читают окнами по запросу, живой поток записывают как есть, и тот же
разбор (analyze) читает и живой канал, и проигрыш из файла, получая одно и то же.
Подключение
[package]
name = "my-bot"
edition = "2024" # нужен Rust 1.88 или новее: в коде книги цепочки let
[dependencies]
brokapi = { version = "0.1.0-alpha.5", features = ["bcs", "tbank", "finam", "alor"] }
tokio = { version = "1", features = ["full"] }
use brokapi::core::{
Candle, Envelope, Error, ErrorClass, Event, InstrumentId, InstrumentQuery, InstrumentSource,
MarketDataSource, NativeId, OrderBook, OrderBookSource, SubscribeOpts, TimeRange, Timeframe,
UnixNanos,
};
use brokapi::runtime::history::{CandlePages, HistoryError, HistoryOpts};
use brokapi::runtime::record::{EnvelopeReader, EnvelopeWriter, ReplaySpeed, replay};
use brokapi::runtime::{BrokerLimiter, BrokerLimits};
use tokio::sync::mpsc;
Как библиотека читает длинную историю
Один запрос свечей брокер принимает лишь в пределах: БКС не больше 1440 баров, Т-Банк по таблице (минутные бары за сутки,
пятиминутные за неделю, часовые за 3 месяца, дневные за 6 лет, недельные за 5, месячные за 10 лет), Финам и АЛОР предел не называют (у АЛОРа candles_max_span пуст, и без окна вся история уходит одним запросом: задайте HistoryOpts::window, если нужно резать).
Эти значения взяты из документации брокеров, их отдает метод candles_max_span(таймфрейм) коннектора.
CandlePages из brokapi::runtime::history пользуется им:
- режет период на окна не длиннее предела и читает их по порядку, по одному за вызов
next_page, - на
RateLimitedждетretry_after(не дольшеmax_rate_wait, не большеmax_rate_waitsраз на окно), - на сетевой сбой (
Transport) читает окно еще раз с растущей паузой: чтение безопасно повторять, команды - нет, - на любой другой отказ останавливается и возвращает
HistoryErrorс местом, откуда продолжить (resume_from); - склеивает окна без дублей: бар, который вернули оба соседних окна (брокеры по-разному считают конец периода), остается один раз.
Если коннектор не знает предел (None), период идет одним запросом. Размер окна можно задать самому: HistoryOpts::window.
requests() считает вызовы candles, в том числе те, что локальный счетчик отказал (до брокера они не дошли). Сколько запросов
реально ушло, считайте на стороне брокера (в тестах - счетчики имитаций). rate_waits() считает только ожидания внутри CandlePages.
Для данных на несколько тысяч баров есть load_candles: читает все в память одним вызовом.
Файлы с барами
Один файл на бумагу: текст, строка на бар (время, открытие, максимум, минимум, закрытие, объем). Текст нарочно: его читает
любая программа, а начало файла остается правильным файлом. Контрольной точкой служит сам файл: где он кончается, там
продолжается выгрузка. Состояние не может разойтись с данными, потому что отдельного состояния нет.
/// `<dir>/<venue>/<ticker>_H1.csv`: one line per bar, `open_ts_ns,open,high,low,close,volume`. Text on purpose: any tool
/// reads it, and a prefix of the file is still a valid file.
fn bars_path(dir: &Path, venue: &str, ticker: &str, tf: Timeframe) -> PathBuf {
dir.join(venue).join(format!("{ticker}_{tf:?}.csv"))
}
/// After a crash the last line may be cut. Drop it, so the file ends with a whole line.
fn repair(path: &Path) -> std::io::Result<()> {
if let Ok(bytes) = fs::read(path)
&& !bytes.is_empty()
&& bytes.last() != Some(&b'\n')
{
let keep = bytes.iter().rposition(|b| *b == b'\n').map_or(0, |i| i + 1);
OpenOptions::new()
.write(true)
.open(path)?
.set_len(keep as u64)?;
}
Ok(())
}
/// Where to continue: after the last bar on disk, or at `from` if there is none. Nothing else is stored: the file is
/// its own checkpoint, so it cannot disagree with itself.
fn resume_point(path: &Path, tf: Timeframe, from: UnixNanos) -> UnixNanos {
let last = fs::read_to_string(path).ok().and_then(|text| {
text.lines()
.last()
.and_then(|l| l.split(',').next())
.and_then(|t| t.parse::<i64>().ok())
});
match last {
Some(open) => UnixNanos::from_nanos(open + tf.seconds() as i64 * 1_000_000_000),
None => from,
}
}
fn rows(bars: &[Candle]) -> String {
bars.iter()
.map(|c| {
format!(
"{},{},{},{},{},{}\n",
c.open_ts.as_nanos(),
c.open,
c.high,
c.low,
c.close,
c.volume
)
})
.collect()
}
Выгрузка
Сначала вспомогательная waiting: любое чтение тратит квоту, и find, и last_trades, и подписка, а не только свечи, и любое может вернуть RateLimited.
CandlePages ждет сама, остальные чтения нет, их оборачивает waiting. Пример без нее ломается, как только бумаг больше, чем запас ведра (burst).
По брокерам выгрузка идет одновременно, по бумагам внутри брокера по очереди: у брокера один счетчик лимитов на все. Каждая
страница пишется одним write_all и сразу sync_data, и только потом просится следующая.
/// Waits out `RateLimited` for any read. EVERY read spends quota, `find` and `last_trades` included, not only candles.
async fn waiting<T, F, Fut>(mut call: F) -> Result<T, Error>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<T, Error>>,
{
for _ in 0..20 {
match call().await {
Err(Error::RateLimited { retry_after }) => tokio::time::sleep(retry_after).await,
other => return other,
}
}
call().await
}
/// Downloads `tf` bars of every instrument of the venue over `range` into files. Restart-safe: run it again after a
/// stop and it continues where each file ends. The library cuts the range into windows the broker accepts, waits out
/// the rate limit and joins the windows without duplicates; this function decides what happens to each page and to
/// each failure: a failure that may pass (`Retryable`) is waited out and the instrument is continued from its file, a
/// failure that will repeat (`Fatal`) is written to the report and the next instrument goes on.
pub async fn download(
venue: &Venue<'_>,
tf: Timeframe,
range: TimeRange,
dir: &Path,
) -> Result<VenueReport, Box<dyn std::error::Error + Send + Sync>> {
let mut report = VenueReport::default();
for (ticker, id) in &venue.instruments {
let path = bars_path(dir, venue.name, ticker, tf);
fs::create_dir_all(path.parent().expect("a file has a folder"))?;
let mut attempts = 0;
loop {
repair(&path)?;
let from = resume_point(&path, tf, range.from);
let mut file = OpenOptions::new().create(true).append(true).open(&path)?;
let mut pages = CandlePages::new(
venue.md,
id,
tf,
TimeRange::new(from, range.to),
// The library repeats a window after a network error by itself: 2 times, the pause doubles.
// A short first pause (the default is a second) keeps the demonstration quick.
HistoryOpts {
transport_backoff: Duration::from_millis(20),
..HistoryOpts::default()
},
);
let outcome = loop {
match pages.next_page().await {
Ok(Some(bars)) => {
// One write per page, then to disk. The page is on disk before the next one is asked for.
file.write_all(rows(&bars).as_bytes())?;
file.sync_data()?;
report.bars += bars.len();
}
Ok(None) => break Ok(()),
Err(e) => break Err(e),
}
};
report.requests += pages.requests();
report.rate_waits += pages.rate_waits();
let HistoryError { error, .. } = match outcome {
Ok(()) => break,
Err(e) => e,
};
// Progress resets the patience: only an instrument that does not move at all gives up.
if resume_point(&path, tf, range.from) > from {
attempts = 0;
}
match error.class() {
ErrorClass::Retryable if attempts < 5 => {
attempts += 1;
let pause = match error {
Error::RateLimited { retry_after } => retry_after,
_ => Duration::from_millis(500),
};
tokio::time::sleep(pause).await;
}
// Fatal (the same read fails again) or the patience is spent: write it down, go to the next one.
_ => {
report.failed.push((ticker.clone(), error.to_string()));
break;
}
}
}
}
Ok(report)
}
Что делает download с ошибками, по классу ошибки (error.class()):
| Класс | Примеры | Что делает движок |
|---|---|---|
Retryable | RateLimited после всех ожиданий, Transport после повторов | ждет (retry_after или полсекунды) и продолжает бумагу с места, где кончается ее файл. До пяти попыток подряд без продвижения |
Fatal | Rejected, Config, Unsupported | та же ошибка повторится: бумага попадает в report.failed, движок идет к следующей |
Что делает тест. Год часовых баров по двум бумагам у всех брокеров, ровно 8760 баров на бумагу без пропусков и дублей. Число запросов к имитациям равно числу окон (БКС 7 на бумагу, Т-Банк 5, Финам 18, АЛОР 1: предел не назван). Счетчик лимитов в примере нарочно мал, поэтому движок несколько раз ждет и не падает. Повторный запуск ничего не просит у брокеров.
Сделки ленты
last_trades(инструмент, число) дает последние сделки ленты: сколько глубины, зависит от брокера (БКС: текущая сессия, Т-Банк:
последний час). Глубокой истории сделок в общем интерфейсе нет. Живую ленту записывайте потоком subscribe_market_trades
(режим DropNewest или Lossless, если терять сделки нельзя) в тот же журнал событий, что описан ниже.
Запись потока событий в файл
EnvelopeWriter из brokapi::runtime::record пишет конверты в любой Write: строка JSON на конверт, перед ними строка-заголовок
с версией формата. Цены и количества записываются строками, поэтому возвращаются точно (244.30 остается 244.30).
/// Reads the live channel, writes every envelope to the log, and passes a copy on to the analysis. Stops after `max`
/// envelopes or when nothing comes for `quiet`.
pub async fn record(
mut live: mpsc::Receiver<Envelope>,
log: &Path,
max: u64,
quiet: Duration,
to_analysis: mpsc::Sender<Envelope>,
) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
let mut writer = EnvelopeWriter::new(std::io::BufWriter::new(fs::File::create(log)?))?;
while writer.written() < max {
let Ok(Some(env)) = tokio::time::timeout(quiet, live.recv()).await else {
break;
};
writer.write(&env)?;
let _ = to_analysis.send(env).await;
}
let written = writer.written();
writer.finish()?; // flushes: the tail of the file must not stay in a buffer
Ok(written)
}
/// Plays the log into a channel as fast as the consumer reads. Nothing is lost: a replay waits for the consumer.
pub async fn play(
log: &Path,
to_analysis: mpsc::Sender<Envelope>,
) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
let reader = EnvelopeReader::new(BufReader::new(fs::File::open(log)?))?;
let report = replay(reader, &to_analysis, ReplaySpeed::Instant).await?;
Ok(report.sent)
}
Чего файл не хранит: монотонные часы rx_mono. После чтения в нем время чтения. Для порядка и замеров в проигрыше используйте
rx_ts и broker_ts.
Проигрыш из файла
replay отправляет конверты в канал по порядку, как коннектор. Скорость: ReplaySpeed::Instant (насколько быстро читает
потребитель) или Paced { factor, max_gap } (темп записи, деленный на factor, пауза не длиннее max_gap).
Отличие от живого потока. Проигрыш ждет потребителя (send().await), поэтому медленный потребитель замедляет проигрыш и
ничего не теряется. Живой коннектор ждать не может и применяет режим подписки. Значит, проигрыш никогда не даст Overflow: код
разбора Overflow нужно проверять отдельно.
Файл в формате с более новой версией читать нельзя: LogError::Schema. Поврежденная строка останавливает чтение с номером
строки, пропустить ее молча нельзя.
Разбор, один для живого потока и для файла
/// A tiny analysis over an event stream: how many books came from which connector, and the last mid price of each.
/// The same function reads a live channel and a replay.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Stats {
pub books: BTreeMap<String, usize>,
pub last_mid: BTreeMap<String, String>,
}
pub async fn analyze(mut events: mpsc::Receiver<Envelope>) -> Stats {
let mut stats = Stats::default();
while let Some(env) = events.recv().await {
if let Event::OrderBook(OrderBook::Snapshot { bids, asks, .. }) = env.payload
&& let (Some(bid), Some(ask)) = (bids.first(), asks.first())
{
*stats.books.entry(env.source.to_string()).or_default() += 1;
// Exact arithmetic, no floats.
let mid = (bid.price.decimal() + ask.price.decimal()) / brokapi::core::Decimal::from(2);
stats
.last_mid
.insert(env.source.to_string(), mid.to_string());
}
}
stats
}
Тест записывает 30 событий живого потока, проигрывает файл и проверяет, что число записанных равно числу проигранных и что разбор вернул одно и то же.
Вся программа целиком
Текст программы examples/research.rs
//! A research engine: a year of candles from several brokers within their limits, written to disk and resumable, a live
//! event stream recorded to a file, and the same stream played back from that file.
//!
//! The code of the page "Как собрать исследовательский движок" of the book (docs/src/rust/howto-research.md). `run` works on
//! the in-process imitations of the brokers, which, like the real ones, refuse a candle request over their span
//! and answer a series for the range asked. Nothing here touches the network or money.
//!
//! ```text
//! cargo run --example research
//! ```
#![allow(dead_code)]
use std::collections::BTreeMap;
use std::fs::{self, OpenOptions};
use std::io::{BufReader, Write};
use std::path::{Path, PathBuf};
use std::time::Duration;
// ANCHOR: imports
use brokapi::core::{
Candle, Envelope, Error, ErrorClass, Event, InstrumentId, InstrumentQuery, InstrumentSource,
MarketDataSource, NativeId, OrderBook, OrderBookSource, SubscribeOpts, TimeRange, Timeframe,
UnixNanos,
};
use brokapi::runtime::history::{CandlePages, HistoryError, HistoryOpts};
use brokapi::runtime::record::{EnvelopeReader, EnvelopeWriter, ReplaySpeed, replay};
use brokapi::runtime::{BrokerLimiter, BrokerLimits};
use tokio::sync::mpsc;
// ANCHOR_END: imports
/// What the engine needs from a market data connector, whatever the broker is.
pub trait Market: OrderBookSource + MarketDataSource + InstrumentSource {}
impl<T: OrderBookSource + MarketDataSource + InstrumentSource> Market for T {}
/// One broker as the engine sees it: a name, a connector, and the instruments to download.
pub struct Venue<'a> {
pub name: &'static str,
pub md: &'a dyn Market,
pub instruments: Vec<(String, InstrumentId)>,
}
// ANCHOR: files
/// `<dir>/<venue>/<ticker>_H1.csv`: one line per bar, `open_ts_ns,open,high,low,close,volume`. Text on purpose: any tool
/// reads it, and a prefix of the file is still a valid file.
fn bars_path(dir: &Path, venue: &str, ticker: &str, tf: Timeframe) -> PathBuf {
dir.join(venue).join(format!("{ticker}_{tf:?}.csv"))
}
/// After a crash the last line may be cut. Drop it, so the file ends with a whole line.
fn repair(path: &Path) -> std::io::Result<()> {
if let Ok(bytes) = fs::read(path)
&& !bytes.is_empty()
&& bytes.last() != Some(&b'\n')
{
let keep = bytes.iter().rposition(|b| *b == b'\n').map_or(0, |i| i + 1);
OpenOptions::new()
.write(true)
.open(path)?
.set_len(keep as u64)?;
}
Ok(())
}
/// Where to continue: after the last bar on disk, or at `from` if there is none. Nothing else is stored: the file is
/// its own checkpoint, so it cannot disagree with itself.
fn resume_point(path: &Path, tf: Timeframe, from: UnixNanos) -> UnixNanos {
let last = fs::read_to_string(path).ok().and_then(|text| {
text.lines()
.last()
.and_then(|l| l.split(',').next())
.and_then(|t| t.parse::<i64>().ok())
});
match last {
Some(open) => UnixNanos::from_nanos(open + tf.seconds() as i64 * 1_000_000_000),
None => from,
}
}
fn rows(bars: &[Candle]) -> String {
bars.iter()
.map(|c| {
format!(
"{},{},{},{},{},{}\n",
c.open_ts.as_nanos(),
c.open,
c.high,
c.low,
c.close,
c.volume
)
})
.collect()
}
// ANCHOR_END: files
/// What one venue's download did.
#[derive(Debug, Default, Clone)]
pub struct VenueReport {
pub requests: u32,
pub rate_waits: u32,
pub bars: usize,
/// Instruments that could not be finished: ticker and the reason.
pub failed: Vec<(String, String)>,
}
// ANCHOR: download
/// Waits out `RateLimited` for any read. EVERY read spends quota, `find` and `last_trades` included, not only candles.
async fn waiting<T, F, Fut>(mut call: F) -> Result<T, Error>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<T, Error>>,
{
for _ in 0..20 {
match call().await {
Err(Error::RateLimited { retry_after }) => tokio::time::sleep(retry_after).await,
other => return other,
}
}
call().await
}
/// Downloads `tf` bars of every instrument of the venue over `range` into files. Restart-safe: run it again after a
/// stop and it continues where each file ends. The library cuts the range into windows the broker accepts, waits out
/// the rate limit and joins the windows without duplicates; this function decides what happens to each page and to
/// each failure: a failure that may pass (`Retryable`) is waited out and the instrument is continued from its file, a
/// failure that will repeat (`Fatal`) is written to the report and the next instrument goes on.
pub async fn download(
venue: &Venue<'_>,
tf: Timeframe,
range: TimeRange,
dir: &Path,
) -> Result<VenueReport, Box<dyn std::error::Error + Send + Sync>> {
let mut report = VenueReport::default();
for (ticker, id) in &venue.instruments {
let path = bars_path(dir, venue.name, ticker, tf);
fs::create_dir_all(path.parent().expect("a file has a folder"))?;
let mut attempts = 0;
loop {
repair(&path)?;
let from = resume_point(&path, tf, range.from);
let mut file = OpenOptions::new().create(true).append(true).open(&path)?;
let mut pages = CandlePages::new(
venue.md,
id,
tf,
TimeRange::new(from, range.to),
// The library repeats a window after a network error by itself: 2 times, the pause doubles.
// A short first pause (the default is a second) keeps the demonstration quick.
HistoryOpts {
transport_backoff: Duration::from_millis(20),
..HistoryOpts::default()
},
);
let outcome = loop {
match pages.next_page().await {
Ok(Some(bars)) => {
// One write per page, then to disk. The page is on disk before the next one is asked for.
file.write_all(rows(&bars).as_bytes())?;
file.sync_data()?;
report.bars += bars.len();
}
Ok(None) => break Ok(()),
Err(e) => break Err(e),
}
};
report.requests += pages.requests();
report.rate_waits += pages.rate_waits();
let HistoryError { error, .. } = match outcome {
Ok(()) => break,
Err(e) => e,
};
// Progress resets the patience: only an instrument that does not move at all gives up.
if resume_point(&path, tf, range.from) > from {
attempts = 0;
}
match error.class() {
ErrorClass::Retryable if attempts < 5 => {
attempts += 1;
let pause = match error {
Error::RateLimited { retry_after } => retry_after,
_ => Duration::from_millis(500),
};
tokio::time::sleep(pause).await;
}
// Fatal (the same read fails again) or the patience is spent: write it down, go to the next one.
_ => {
report.failed.push((ticker.clone(), error.to_string()));
break;
}
}
}
}
Ok(report)
}
// ANCHOR_END: download
// ANCHOR: stats
/// A tiny analysis over an event stream: how many books came from which connector, and the last mid price of each.
/// The same function reads a live channel and a replay.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Stats {
pub books: BTreeMap<String, usize>,
pub last_mid: BTreeMap<String, String>,
}
pub async fn analyze(mut events: mpsc::Receiver<Envelope>) -> Stats {
let mut stats = Stats::default();
while let Some(env) = events.recv().await {
if let Event::OrderBook(OrderBook::Snapshot { bids, asks, .. }) = env.payload
&& let (Some(bid), Some(ask)) = (bids.first(), asks.first())
{
*stats.books.entry(env.source.to_string()).or_default() += 1;
// Exact arithmetic, no floats.
let mid = (bid.price.decimal() + ask.price.decimal()) / brokapi::core::Decimal::from(2);
stats
.last_mid
.insert(env.source.to_string(), mid.to_string());
}
}
stats
}
// ANCHOR_END: stats
// ANCHOR: record
/// Reads the live channel, writes every envelope to the log, and passes a copy on to the analysis. Stops after `max`
/// envelopes or when nothing comes for `quiet`.
pub async fn record(
mut live: mpsc::Receiver<Envelope>,
log: &Path,
max: u64,
quiet: Duration,
to_analysis: mpsc::Sender<Envelope>,
) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
let mut writer = EnvelopeWriter::new(std::io::BufWriter::new(fs::File::create(log)?))?;
while writer.written() < max {
let Ok(Some(env)) = tokio::time::timeout(quiet, live.recv()).await else {
break;
};
writer.write(&env)?;
let _ = to_analysis.send(env).await;
}
let written = writer.written();
writer.finish()?; // flushes: the tail of the file must not stay in a buffer
Ok(written)
}
/// Plays the log into a channel as fast as the consumer reads. Nothing is lost: a replay waits for the consumer.
pub async fn play(
log: &Path,
to_analysis: mpsc::Sender<Envelope>,
) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
let reader = EnvelopeReader::new(BufReader::new(fs::File::open(log)?))?;
let report = replay(reader, &to_analysis, ReplaySpeed::Instant).await?;
Ok(report.sent)
}
// ANCHOR_END: record
/// A deliberately small budget, so the history download has to wait for the limiter: a burst of 2, then one request
/// per 5 ms. The real defaults of each broker are in `brokapi::<broker>::limits::default_limits()`.
fn small_budget() -> std::sync::Arc<BrokerLimiter> {
let raw = r#"
[demo.buckets.market]
rate = 200
per = "1s"
burst = 2
[demo.route]
"*" = ["market"]
[demo.conns]
max_conns = 4
max_subs_per_conn = 50
supports_multiplex = false
"#;
BrokerLimiter::new(&BrokerLimits::from_toml_str(raw).expect("valid limits"))
}
/// Spends the burst of a budget as of 20 ms from now. The reads that follow are refused until the real clock
/// is past that moment, so the download has to wait for the limiter whatever the speed of the machine. (A
/// budget that only refills fast would make the waits depend on how quick the imitation answers.)
fn hold(budget: &BrokerLimiter) {
let later = brokapi::runtime::now() + Duration::from_millis(20);
while budget.acquire("GetCandles", later).is_ok() {}
}
/// What the test looks at.
#[derive(Debug)]
pub struct Result3 {
pub reports: BTreeMap<&'static str, VenueReport>,
pub second_run_requests: u32,
pub recorded: u64,
pub replayed: u64,
pub live_stats: Stats,
pub replay_stats: Stats,
pub tbank_requests: Vec<(i32, i64, i64)>,
pub bcs_requests: usize,
pub finam_requests: usize,
pub alor_requests: usize,
/// T-Bank reads that the imitation refused for good: the engine writes the instruments to the report.
pub fatal: VenueReport,
/// T-Bank reads that failed twice on the network and then passed: the engine waited and went on.
pub transient: VenueReport,
}
/// The whole engine on the imitations. `dir` holds the data files and the log.
pub async fn run(dir: &Path) -> Result<Result3, Box<dyn std::error::Error + Send + Sync>> {
use brokapi::{alor, bcs, finam, tbank};
use brokapi_mocks::{alor as malor, bcs as mbcs, finam as mfinam, tbank as mtbank};
use std::sync::Arc;
use std::sync::atomic::Ordering;
// Imitations that answer a series for the asked range and refuse a range over the broker's span.
let bcs_opts = Arc::new(mbcs::MockOptions::default());
bcs_opts.synthetic_candles.store(true, Ordering::SeqCst);
let bcs_server = mbcs::MockServer::start(bcs_opts.clone()).await;
let tbank_opts = Arc::new(mtbank::MockOptions::default());
tbank_opts.synthetic_candles.store(true, Ordering::SeqCst);
let tbank_server = mtbank::MockServer::start(tbank_opts.clone()).await;
let finam_server = mfinam::MockServer::start().await;
finam_server
.state
.options
.synthetic_candles
.store(true, Ordering::SeqCst);
let alor_opts = Arc::new(malor::MockOptions::default());
let alor_server = malor::MockServer::start(alor_opts.clone()).await;
// The small budgets of the brokers, kept here so that the download can start with them spent.
let budgets = [
small_budget(),
small_budget(),
small_budget(),
small_budget(),
];
// One channel for the live events of all the market data connectors.
let (tx, live) = mpsc::channel::<Envelope>(4096);
let finam_api =
finam::FinamApiConfig::new(mfinam::SECRET).with_endpoint(finam_server.url.clone());
let bcs_md = bcs::MarketDataConnector::start(
bcs::MarketDataConfig::new(
bcs::protocol::config::BcsConfig::for_mock("mock-refresh-token", &bcs_server.http_base),
"bcs-md",
),
Arc::clone(&budgets[0]),
tx.clone(),
)
.await?;
let tbank_md = tbank::MarketDataConnector::start(
tbank::MarketDataConfig::new(
tbank::protocol::TbankConfig::for_mock(mtbank::TOKEN, &tbank_server.endpoint),
"tbank-md",
),
Arc::clone(&budgets[1]),
tx.clone(),
)
.await?;
let finam_md = finam::MarketDataConnector::start(
finam::MarketDataConfig::new(finam_api, "finam-md"),
Arc::clone(&budgets[2]),
tx.clone(),
)
.await?;
let alor_md = alor::MarketDataConnector::start(
alor::MarketDataConfig::new(
alor::protocol::config::AlorConfig::for_mock(
"mock-refresh-token",
&alor_server.http_base,
),
"alor-md",
),
Arc::clone(&budgets[3]),
tx,
)
.await?;
// The instruments of each venue in its own form. In real code all of them come from `find`.
let bcs_id = |t: &str| {
InstrumentId::new(
brokapi::core::InstrumentKey::new(t, "TQBR"),
NativeId::TickerClass {
ticker: t.into(),
class_code: "TQBR".into(),
},
)
};
let mut tbank_ids = Vec::new();
for t in ["SBER", "GAZP"] {
let found = waiting(|| {
tbank_md.find(InstrumentQuery::ByTicker {
ticker: t.into(),
exchange: Some("TQBR".into()),
})
})
.await?;
tbank_ids.push((
t.to_string(),
found.into_iter().next().ok_or("not in the directory")?.id,
));
}
let venues = vec![
Venue {
name: "bcs",
md: &bcs_md,
instruments: ["SBER", "GAZP"]
.map(|t| (t.to_string(), bcs_id(t)))
.to_vec(),
},
Venue {
name: "tbank",
md: &tbank_md,
instruments: tbank_ids,
},
Venue {
name: "finam",
md: &finam_md,
instruments: ["SBER", "GAZP"]
.map(|t| {
(
t.to_string(),
finam::util::instrument_from_symbol(&format!("{t}@MISX")),
)
})
.to_vec(),
},
Venue {
name: "alor",
md: &alor_md,
instruments: ["SBER", "GAZP"]
.map(|t| (t.to_string(), alor::util::instrument_id(t, "TQBR")))
.to_vec(),
},
];
// ---- 1. A year of hourly bars. The brokers are independent (own limits), so they run side by side.
let year = TimeRange::new(
UnixNanos::from_nanos(1_767_225_600_000_000_000), // 2026-01-01
UnixNanos::from_nanos(1_798_761_600_000_000_000), // 2027-01-01
);
budgets.iter().for_each(|b| hold(b));
let (r_bcs, r_tbank, r_finam, r_alor) = tokio::try_join!(
download(&venues[0], Timeframe::H1, year, dir),
download(&venues[1], Timeframe::H1, year, dir),
download(&venues[2], Timeframe::H1, year, dir),
download(&venues[3], Timeframe::H1, year, dir),
)?;
let mut reports = BTreeMap::new();
reports.insert("bcs", r_bcs);
reports.insert("tbank", r_tbank);
reports.insert("finam", r_finam);
reports.insert("alor", r_alor);
// A second run finds every file complete and asks the brokers for nothing.
let again = download(&venues[0], Timeframe::H1, year, dir).await?;
// What the brokers saw for the main download (the scenarios below add requests of their own).
let tbank_requests = tbank_opts.candle_requests.lock().unwrap().clone();
let bcs_requests = bcs_opts.candle_requests.lock().unwrap().len();
let finam_requests = finam_server
.state
.options
.bars_requests
.lock()
.unwrap()
.len();
let alor_requests = alor_opts.history_requests.lock().unwrap().len();
// A read the broker refuses for good (`Rejected`) ends in the report, the engine goes on with the next instrument.
tbank_opts.candle_fail.store(1, Ordering::SeqCst);
let fatal = download(&venues[1], Timeframe::H1, year, &dir.join("fatal")).await?;
// A read that fails on the network: the engine waits and continues from the file. The imitation fails the
// next two calls and then heals.
tbank_opts.candle_fail.store(0, Ordering::SeqCst);
tbank_opts
.candle_network_failures
.store(2, Ordering::SeqCst);
let transient = download(&venues[1], Timeframe::H1, year, &dir.join("transient")).await?;
// ---- 2. The deals of the tape: the latest ones, to a file of their own.
for v in &venues {
let (ticker, id) = &v.instruments[0];
let trades = waiting(|| v.md.last_trades(id, 50)).await?;
let mut out = String::new();
for t in &trades {
out.push_str(&format!(
"{},{},{},{:?}\n",
t.ts.rx_ts.as_nanos(),
t.price,
t.qty,
t.side
));
}
fs::write(dir.join(v.name).join(format!("{ticker}_trades.csv")), out)?;
}
// ---- 3. Record the live stream while an analysis reads the same events.
for v in &venues {
let (_, id) = &v.instruments[0];
waiting(|| v.md.subscribe_order_book(id, SubscribeOpts::conflate())).await?;
}
let (tee_tx, tee_rx) = mpsc::channel::<Envelope>(4096);
let analysis = tokio::spawn(analyze(tee_rx));
let log = dir.join("events.log");
let recorded = record(live, &log, 30, Duration::from_secs(3), tee_tx).await?;
let live_stats = analysis.await?;
// ---- 4. The same events again, from the file.
let (play_tx, play_rx) = mpsc::channel::<Envelope>(16);
let analysis = tokio::spawn(analyze(play_rx));
let replayed = play(&log, play_tx).await?;
let replay_stats = analysis.await?;
// Stop the connectors (the borrows of `venues` end here).
drop(venues);
let stop = brokapi::core::ShutdownMode::Immediate;
bcs_md.shutdown(stop).await;
tbank_md.shutdown(stop).await;
finam_md.shutdown(stop).await;
alor_md.shutdown(stop).await;
Ok(Result3 {
reports,
second_run_requests: again.requests,
recorded,
replayed,
live_stats,
replay_stats,
tbank_requests,
bcs_requests,
finam_requests,
alor_requests,
fatal,
transient,
})
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let dir = std::env::temp_dir().join("brokapi-research");
let r = run(&dir).await?;
println!("{:#?}", r.reports);
Ok(())
}
Как проверить
cargo test --test examples research
cargo run --example research # то же с выводом отчета по брокерам
Сверка с NautilusTrader и пределы подхода
NautilusTrader хранит данные в каталоге Parquet с запросами по инструменту и времени и проигрывает их в бэктесте. В brokapi взят самый простой вариант без зависимостей: текстовые файлы баров и журнал событий в JSON-строках.
| NautilusTrader | brokapi | |
|---|---|---|
| Формат | Parquet-каталог с индексом | текстовые файлы и журнал событий (строка JSON на конверт) |
| Запрос по инструменту и времени | есть | нет, фильтруйте при чтении |
| Проигрыш | движок бэктеста | replay в канал, потребитель - ваш код |
| Бэктест (исполнение, комиссии, позиции) | есть | нет, библиотека бэктест не делает |
Для лет данных и частых запросов берите каталог: тот же поток конвертов можно складывать в Parquet своим кодом, формат
Envelope стабилен (schema) и сериализуется через serde.
Что менять под себя
- Таймфрейм и период: параметры
download.M10у БКС не поддерживается (Unsupported). - Бумаги: список в
Venue. В боевом коде идентификаторы берутся изfind. - Куда писать: вместо файлов любая база. Условие то же: страница на диске до следующего запроса.
- Лимиты:
HistoryOpts(max_rate_waits,max_rate_wait,transport_retries) и сами лимиты брокера (limits_from_toml_path, Лимиты запросов).
Запуск, рестарт и остановка бота
Инструкция: что делать боту при старте, после рестарта и при остановке, в каком порядке и что при этом должно быть
видно. Почему порядок именно такой, объяснено на странице Круг жизни. Код
ниже - куски программы examples/lifecycle.rs: она запускается тестом против имитации БКС, поэтому текст и
проверенный код не расходятся. Программа играет два запуска бота: первый ставит заявку и уходит, не снимая ее,
второй стартует, сверяется с журналом и находит эту заявку.
Старт и рестарт
Старт и рестарт - одна процедура. На первом запуске журнал бота пуст, на втором в нем лежат cid заявок, которые бот
считал живыми.
- Поднять коннекторы.
start(конфиг, счетчик_лимитов, канал_событий). Шлюз команд после этого закрыт. - Подписаться на статусы заявок и исполнения. Подписки при закрытом шлюзе разрешены.
// Second run, after a restart. The gate is closed after `start`: commands return NotReconciled.
let (ud, mut events) = start_ud(cfg).await?;
// Subscribe BEFORE the reconcile: an event between the snapshot and the subscription would be lost.
ud.subscribe_order_state().await?;
Вы должны увидеть: start вернул коннектор без ошибки. Если вернулась ошибка, токен не подошел (у БКС и Финама
start сразу авторизуется, у Т-Банка при заданном счете первая проверка будет при первом запросе). Любая
торговая команда сейчас вернет NotReconciled, и это правильно.
- Достать из журнала
cidзаявок, считавшихся живыми, и вызватьreconcile. - Разобрать три списка.
// The journal says which orders the bot thinks are alive.
let report = ud.reconcile(&journal).await?;
for order in &report.confirmed {
println!(
"confirmed {}: still at the broker, filled {}",
order.cid,
order.filled.decimal()
);
}
for cid in &report.closed_offline {
// Find out how it ended: order_state(cid) and executions(...), then write it to the journal.
println!("closed offline {cid}: ask order_state and write the result");
}
for order in &report.foreign {
// Not ours. Do not touch: a person decides.
println!("foreign {}: shown to a person", order.cid);
}
Вы должны увидеть строку confirmed ... для каждой заявки, которая стоит у брокера. Если reconcile вернул ошибку,
шлюз не открыт: подождите и вызовите еще раз. Ошибка ничего не говорит о заявках. Что делать с каждым списком:
confirmed: сверить с журналом состояние и исполненный объем, поправить. Продолжать вести.closed_offline: для каждого запросить итог (order_state,executions) и записать в журнал.foreign: показать человеку или записать в журнал как чужие. Не снимать и не менять: бот не знает, зачем заявку поставили. Если такую заявку снять, можно сломать чужую стратегию.
- Для стоп-заявок отдельно вызвать
active_stop_orders(): обычная сверка их не показывает (Стоп-заявки). - Работать. Шлюз открыт.
// The gate is open now. The bot trades. Here it takes its own order off.
ud.cancel(cid).await?;
journal.clear();
Если пропустить шаги 2-3 и сразу ставить заявки, получите NotReconciled. Если подписаться после сверки, события,
пришедшие между снимком и подпиской, потеряются.
Остановка
shutdown(режим) останавливает коннектор. Он забирает коннектор по значению, после вызова пользоваться им нельзя.
Режимы:
Graceful { deadline }- аккуратно: закрыть подписки штатным способом брокера и дождаться подтверждений не дольшеdeadline.Immediate- сразу, без отписок.
// Stop: new commands get ShuttingDown, subscriptions are closed, the report says what was left.
let report = ud
.shutdown(ShutdownMode::Graceful {
deadline: Duration::from_secs(5),
})
.await;
println!(
"stopped: {} subscription(s) closed, {} failed, {} command(s) without an answer",
report.unsubscribed,
report.unsubscribe_failed,
report.inflight_abandoned.len()
);
Что происходит по шагам:
- Новые торговые команды получают
ShuttingDown. Шлюз закрывается навсегда. - (
Graceful) Подписки закрываются. У gRPC-брокеров закрытие вызова и есть отписка, у MD БКС отправляется сообщение отписки. - Заявки, у которых еще нет ответа, не ждутся и не повторяются: они локально переводятся в
Unknown, а список возвращается в отчете (ShutdownReport.inflight_abandoned). У Т-Банка на каждую такую заявку еще уходит событие. Почему не ждутся: повторить их нельзя, а ждать вечно нельзя. - Задачи останавливаются.
Вы должны увидеть отчет: сколько подписок закрыто (unsubscribed), сколько закрыть не удалось
(unsubscribe_failed), какие заявки остались без ответа (inflight_abandoned). Если список не пуст, при
следующем запуске эти cid должны быть в журнале как «проверить»: reconcile вернет их как confirmed или
closed_offline.
Заявки на бирже при остановке не затрагиваются. Если до остановки заявка была принята, она останется на бирже, а при следующем запуске ее подхватит сверка. Снять заявки перед выходом - решение бота: библиотека этого не делает, потому что «снять все» может быть неверным решением (например, защитный стоп должен пережить бота). Порядок остановки нескольких коннекторов тоже за ботом: обычно сначала UD (чтобы не принимать команды), потом MD.
Если что-то пошло не так
Инструкции на случаи, когда бот не знает, что со счетом: у заявки нет ответа, канал событий переполнился, пропала
связь. Что означает каждая ошибка и состояние, - на странице Ошибки и что с ними делать, почему
библиотека устроена так - в Круге команд и Круге жизни. Код - куски программы
examples/recovery.rs, она запускается тестом против имитации: Unknown там настоящий, получен таймаутом на
имитации Т-Банка.
place вернул Unknown
Заявка ушла к брокеру, ответа нет. Она могла встать, могла не дойти. Не повторяйте place: если первая встала, на
счете окажутся две заявки. Вместо повтора спросите брокера по тому же cid.
flowchart TB
U[place вернул Unknown] --> S[order_state по cid]
S --> Q{Что ответил брокер}
Q -- заявка есть --> A[Работать как с обычной:<br>Acked, Filled, Cancelled...]
Q -- заявки нет --> W[Подождать несколько секунд<br>и спросить еще раз]
W --> Q2{Появилась}
Q2 -- да --> A
Q2 -- нет --> N[Заявки нет. Поставить новую<br>с новым cid, если еще нужно]
На схеме: путь от Unknown до решения. Из нее следует: «заявки нет» сразу после обрыва не окончательно, запрос,
ушедший в сеть, мог дойти позже, поэтому один отрицательный ответ ничего не доказывает.
/// `place` answered `Unknown`: ask the broker about the `cid` until it answers or `patience` runs out.
///
/// `Ok(Some(order))` - the order exists, continue with its state. `Ok(None)` - the broker still does not
/// know it after `patience`: it was not placed, and a new order needs a NEW `cid`.
pub async fn resolve_unknown<U: OrderExecutor>(
ud: &U,
cid: ClientOrderId,
patience: Duration,
) -> Result<Option<OrderSnapshot>, Error> {
let deadline = tokio::time::Instant::now() + patience;
loop {
match ud.order_state(cid).await {
Ok(order) => return Ok(Some(order)),
// "Not found" right after a cut is not final: the request may still be on its way.
Err(e) if e.is_not_found() => {}
// A limit or a network error says nothing about the order: wait and ask again.
Err(Error::RateLimited { .. } | Error::Transport(_)) => {}
Err(other) => return Err(other),
}
if tokio::time::Instant::now() >= deadline {
return Ok(None);
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
Вы должны увидеть: функция вернула Some(заявка), если брокер ее знает, и None, если не знает и после ожидания. Как
это читается:
order_state(cid)возвращает состояние заявки по вашемуcid. Если брокер такой заявки не знает, вернетсяRejected(кодnot_found, одинаковый у всех).- Брокер срок не называет, поэтому ожидание ограничивайте своим временем, а не ждите бесконечно.
- Если в игру вступает сомнение (например, заявка могла исполниться и исчезнуть из активных), смотрите
executionsиportfolio. - При следующем запуске бота
reconcileвернет такую заявку какconfirmedилиclosed_offline. - Вторую заявку ставьте только когда убедились, что первой нет. Номер нужен новый: старый библиотека не отправит
(
duplicate_place). - Для
cancelсUnknownотмену можно повторить вручную после проверкиorder_state: повторная отмена новых заявок не создает. Дляreplaceспрашивайте состояние по обоим номерам, исходному и новому.
Пришел Overflow или ошибка Degraded
Буфер подписки без потерь переполнился: бот читал канал медленнее, чем шли события. Часть событий о своих заявках могла пропасть, поэтому торговля закрыта, пока вы не сверитесь со счетом. Что делать, по порядку:
- Дочитать канал событий до пустого. То, что дошло, обработайте до сверки: это события до дыры.
- Вызвать
reconcile(&журнал). Успех снимаетDegraded, подписки снова принимают события. - Восстановить пропущенное из снимков: состояние заявок из
confirmedиorder_state, сделки изexecutions(...)с моментаlost_fromизOverflow, портфель изportfolio(). - Вернуться к работе.
/// `Event::Overflow` came, or a command returned `Degraded`: read the channel to the end, then reconcile.
///
/// `handle` gets every event that was waiting, so nothing that did arrive is lost. A successful
/// `reconcile` opens the gate again. After it, restore what was lost from the snapshots: the states in
/// `confirmed`, the fills from `executions(...)` since `lost_from`, the portfolio from `portfolio()`.
pub async fn after_overflow<U: OrderExecutor>(
ud: &U,
events: &mut mpsc::Receiver<Envelope>,
ledger: &[ClientOrderId],
mut handle: impl FnMut(Envelope),
) -> Result<ReconcileReport, Error> {
while let Ok(env) = events.try_recv() {
handle(env);
}
ud.reconcile(ledger).await
}
Вы должны увидеть: функция вернула отчет сверки, а следующий place перестал отвечать Degraded. Если reconcile
вернул ошибку, шлюз остался закрытым, повторите позже. Для рыночных подписок сверки нет, поэтому после Overflow
такую подписку снимают (unsubscribe) и оформляют заново. Чтобы переполнения не было: читайте канал отдельной задачей и
не делайте в ней долгих операций, размер буфера подбирайте с запасом на всплеск
(Круг событий).
Поток заявок оборвался
Соединения коннектор восстанавливает сам, а бот узнает об обрыве потока заявок или потока исполнений по событию
Event::Reconnected { sub, gap_from }. Оно приходит, когда поток снова открыт, а за время обрыва могли пройти события, которых
бот не видел: заявка исполнилась, снялась, была отклонена. Это то же состояние, что после Overflow: шлюз закрыт
(Degraded), команды в сеть не уходят.
- Дочитайте канал до конца. События до дыры нужны как обычно.
- Прочитайте состояние у брокера:
active_orders(),order_state(cid)по своим заявкам в пути,executions(range), гдеrangeначинается с запасом раньшеgap_from. - Вызовите
reconcile(&журнал). Успешная сверка открывает шлюз, следующийplaceпроходит.
if let Event::Reconnected { sub, gap_from } = &env.payload {
// 1. the events before the hole were handled as always
// 2. read what the broker has: orders and fills since a little before `gap_from`
let live = ud.active_orders().await?;
let since = TimeRange::new(UnixNanos::from_nanos(gap_from.as_nanos() - 60_000_000_000), UnixNanos::from_nanos(i64::MAX / 2));
let fills = ud.executions(since).await?;
// 3. the gate opens
ud.reconcile(&journal.live()).await?;
}
Если вы не слушаете события, а хотите узнать о закрытом шлюзе по ошибке, это Error::Degraded на команде, и действия те же.
Потеря связи без Reconnected (например, поток молчит, но не оборван) бывает, если сам брокер перестал присылать события.
Такое в коде не обнаруживается. Если связь была потеряна надолго или вы не уверены в журнале, зовите reconcile: он безопасен и
ничего не меняет на счете.
Что приходит сверх события, по брокерам: у Т-Банка коннектор сам перечитывает активные заявки и присылает изменившиеся, у БКС
и Финама нет. Поэтому после Reconnected читайте состояние сами, не ждите, что события наверстают.
Другие случаи, когда звать reconcile
- Не уверены в журнале. Вызов только читает, его можно делать в любой момент и сколько угодно раз.
NotReconciledна команде. После запуска не было успешной сверки: вызовитеreconcile, потом повторите команду,cidгоден (Запуск, рестарт и остановка).- Пришел
Resync. Это про стакан, а не про заявки: выбросьте хранимый стакан и дождитесь следующего снимка (Круг событий).
Несколько счетов и брокеров
В одной программе можно держать любые сочетания: несколько брокеров, несколько счетов у одного брокера. Для этого есть три простых правила.
UD на счет. MD на брокера. Один счетчик лимитов на область, в которой брокер считает лимит.
- UD (заявки, портфель) - по одному на каждый счет. Один UD не умеет работать с двумя счетами.
- MD (рынок) - один на брокера. Рыночные данные от счета не зависят, второй MD ничего не добавит.
- Счетчик лимитов (
BrokerLimiter) - один на ту область, где брокер считает лимит, и его получают все коннекторы, которые эту квоту тратят. Если дать им разные счетчики, вместе они превысят квоту и брокер начнет отказывать. Как устроен счетчик: Лимиты запросов. Если дать один счетчик там, где лимиты независимы, они будут мешать друг другу без нужды.
Библиотека не делает: общий портфель по всем счетам и выбор, с какого счета ставить заявку. Это слой над коннекторами, его пишет бот.
Таблица по брокерам
| Брокер | Что задает счет | Где брокер считает лимиты | Счетчиков | Токены |
|---|---|---|---|---|
| БКС | токен (токен выдается на один счет), поэтому account_id нет | на токен (по документации) | по одному на каждый счет, то есть на каждый токен | свой refresh-токен на счет |
| Т-Банк | account_id в настройке, один токен может видеть несколько счетов | на пользователя: все счета и все токены вместе, общий потолок 50 запросов в секунду с одного адреса (по документации Т-Банка) | один на всю программу | один токен на все счета |
| Финам | account_id в настройке, один секрет видит свои счета | на пользователя (по документации). Если упретесь в RateLimited раньше времени, уменьшите значения в limits.toml | один | один секрет и один JWT на все счета |
| АЛОР | портфель и биржа в настройке (ALOR_PORTFOLIO, ALOR_EXCHANGE), один коннектор на портфель | на логин: все портфели одного логина вместе (по документации). Если упретесь в RateLimited раньше времени, уменьшите значения в limits.toml | один на логин | один refresh-токен на логин, из него токен доступа на 30 минут |
| Bybit | выбора счета нет: ключ принадлежит одному счету или субсчету | на ключ (пользователь и метод) и на адрес (600 запросов за 5 секунд). Все коннекторы одного ключа берут один BrokerLimiter | по одному на каждый ключ | свой ключ на субсчет |
Почему так записано для Т-Банка: документация брокера говорит, что лимитная политика распространяется на все счета пользователя, а общее число запросов по всем счетам и токенам в секунду не должно превышать 50.
Где записана область, там и менять: в файле crates/<брокер>/limits.toml.
Метки source
У каждого UD своя метка, по ней в общем канале видно, с какого счета событие. Назовите метки так, чтобы по ним
можно было понять счет: tbank-ud-main, tbank-ud-iis. Библиотека метку не разбирает, она нужна только вам.
Один брокер, несколько счетов
flowchart TB
Bot[Бот] --> MD & U1 & U2
subgraph L[Один счетчик лимитов на пользователя]
MD[MD Т-Банка]
U1[UD: брокерский счет]
U2[UD: ИИС]
end
MD --> Q[(Канал событий)]
U1 -- source=tbank-ud-main --> Q
U2 -- source=tbank-ud-iis --> Q
Q --> Bot
Пример на два счета Т-Банка. Один токен, два account_id, один счетчик, один MD, один канал:
use std::time::Duration;
use brokapi::core::{Envelope, Event, OrderExecutor};
use brokapi::runtime::BrokerLimiter;
use brokapi::tbank::limits::default_limits;
use brokapi::tbank::{MarketDataConfig, MarketDataConnector, UserDataConfig, UserDataConnector};
use brokapi::tbank::protocol::TbankConfig;
use tokio::sync::mpsc;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let cfg = TbankConfig::from_env()?; // один токен
// Т-Банк считает лимит на пользователя: счетчик один на всех
let limiter = BrokerLimiter::new(&default_limits());
let (tx, mut events) = mpsc::channel::<Envelope>(4096);
let md = MarketDataConnector::start(
MarketDataConfig::new(cfg.clone(), "tbank-md"),
limiter.clone(),
tx.clone(),
)
.await?;
// По одному UD на счет, у каждого своя метка
let main_account = UserDataConnector::start(
UserDataConfig::new(cfg.clone().with_account("ID_БРОКЕРСКОГО_СЧЕТА"), "tbank-ud-main"),
limiter.clone(),
tx.clone(),
)
.await?;
let iis_account = UserDataConnector::start(
UserDataConfig::new(cfg.with_account("ID_ИИС"), "tbank-ud-iis"),
limiter,
tx,
)
.await?;
// Каждому счету своя сверка и свой журнал cid
main_account.reconcile(&[]).await?;
iis_account.reconcile(&[]).await?;
main_account.subscribe_order_state().await?;
iis_account.subscribe_order_state().await?;
// Разбор общего канала: кто прислал, определяет source
let _ = tokio::time::timeout(Duration::from_secs(30), async {
while let Some(env) = events.recv().await {
match (&*env.source, env.payload) {
("tbank-ud-main", Event::Order(o)) => println!("основной счет: {} {:?}", o.cid, o.state),
("tbank-ud-iis", Event::Order(o)) => println!("ИИС: {} {:?}", o.cid, o.state),
("tbank-md", Event::OrderBook(_)) => {} // рыночные данные - общие для обоих счетов
_ => {}
}
}
})
.await;
let stop = brokapi::core::ShutdownMode::Graceful { deadline: Duration::from_secs(5) };
main_account.shutdown(stop).await;
iis_account.shutdown(stop).await;
md.shutdown(stop).await;
Ok(())
}
Что видно в этом примере. Каждый UD видит только заявки своего счета: проверено на имитации, UD одного счета не
может прочитать или снять заявку другого. Сверка идет отдельно по каждому счету со своим списком cid. Заявка
одного счета в confirmed другого не окажется.
Финам
Один секрет и один JWT на несколько счетов: создайте менеджер токенов один раз и отдайте всем коннекторам.
let tokens = api.shared_tokens().await?; // один вызов авторизации
let api = api.with_tokens(tokens);
let a = UserDataConnector::start(UserDataConfig::new(api.clone(), "СЧЕТ_A", "finam-ud-a"), limiter.clone(), tx.clone()).await?;
let b = UserDataConnector::start(UserDataConfig::new(api.clone(), "СЧЕТ_B", "finam-ud-b"), limiter.clone(), tx.clone()).await?;
let md = MarketDataConnector::start(MarketDataConfig::new(api, "finam-md"), limiter, tx).await?;
При запуске UD проверяет, что счет принадлежит секрету (список счетов берется из данных токена).
Если счета нет в списке, start возвращает Config.
БКС
Счет определяет токен, поэтому два счета - два refresh-токена и два счетчика лимитов. MD держим с одним из токенов, и он делит счетчик с UD этого же токена.
let cfg_a = BcsConfig::new(token_a).with_write(true);
let cfg_b = BcsConfig::new(token_b).with_write(true);
let limiter_a = BrokerLimiter::new(&limits);
let limiter_b = BrokerLimiter::new(&limits);
let md = MarketDataConnector::start(MarketDataConfig::new(cfg_a.clone(), "bcs-md"), limiter_a.clone(), tx.clone()).await?;
let ud_a = UserDataConnector::start(UserDataConfig::new(cfg_a, "bcs-ud-a"), limiter_a, tx.clone()).await?;
let ud_b = UserDataConnector::start(UserDataConfig::new(cfg_b, "bcs-ud-b"), limiter_b, tx).await?;
Два токена БКС с двумя независимыми счетчиками: у каждого свои заявки, стопы, события и сверка.
Несколько брокеров
Коннекторы разных брокеров ничего не знают друг о друге: у каждого свое соединение и свой счетчик. Канал событий
можно дать общий (тогда брокеров различают по source), можно каждому свой.
flowchart TB Bot[Бот] Bot --> B[БКС<br>MD и UD] Bot --> T[Т-Банк<br>MD и UD] Bot --> F[Финам<br>MD и UD] B & T & F --> Q[(Общий канал,<br>различаем по source)] Q --> Bot
Для общего кода полезно, что все UD имеют один набор трейтов (OrderExecutor, PortfolioSource…). Функцию можно
написать один раз и вызывать для любого брокера, как это сделано в Быстром старте.
Различия, которые общий код должен учитывать сам:
- Единицы количества. Во всех
Qtyэто штуки (единицы инструмента), Т-Банк переводит лоты сам. Размер лота вTradingParams::lot_size. - Замена заявки есть не у всех (
caps().supports_replace). - Идентификатор инструмента у каждого брокера свой вид (
NativeId): Брокеры.
Стоп-заявки
Стоп-заявка лежит у брокера и ждет цены. Пока цена не дошла до заданной, на бирже ее нет. Когда дошла, брокер выставляет обычную заявку, рыночную или лимитную.
Для стоп-заявок есть отдельный трейт StopOrderExecutor. Обычных заявок он не касается, а reconcile и
active_orders стоп-заявок не показывают. Он реализован у всех брокеров (caps().supports_stop_orders).
Виды
Вид (StopOrderKind) | Зачем | Когда срабатывает |
|---|---|---|
StopLoss | защита позиции | продажа - когда цена упала до stop_price, покупка - когда выросла |
TakeProfit | фиксация прибыли | продажа - когда цена выросла до stop_price, покупка - когда упала |
StopLimit | как StopLoss, но после срабатывания всегда лимитная заявка | как у StopLoss, limit_price обязателен |
Параметры (PlaceStopOrderRequest): cid, инструмент, сторона, вид, количество, stop_price (цена срабатывания),
limit_price (цена заявки после срабатывания: нет цены - рыночная), срок (StopExpiry: до отмены, до конца дня,
до даты). Номер cid - тот же UUID, что у обычных заявок, и правила те же: свой на каждую заявку, повторно не
отправляется, заранее записывается в журнал.
Методы
| Метод | Что делает |
|---|---|
place_stop(req) | ставит стоп-заявку, возвращает OrderAck с номером брокера |
cancel_stop(ref) | снимает не сработавшую. ref - либо StopOrderRef::Cid(cid), либо StopOrderRef::Broker(номер) |
active_stop_orders() | список стоп-заявок, которые ждут срабатывания |
subscribe_stop_orders() | поток статусов. У всех брокеров Unsupported: у БКС потока статусов стоп-заявок нет, у Т-Банка потока нет в API, у Финама отдельного потока нет |
Шлюз команд (NotReconciled, Degraded, ShuttingDown) действует на place_stop и cancel_stop так же, как на
place и cancel. Неясный ответ - Unknown, повтора нет. Подробности исходов: Круг команд.
Проверки без сети (нет цены у стоп-лимита, срок, которого нет у брокера, некратное лоту количество) библиотека делает сама до отправки, квота на них не тратится. Узнать о срабатывании стопа можно только опросом: нового события о нем нет. Когда стоп сработал, брокер ставит обычную заявку, и ее ведет обычный поток заявок. Связь между стопом и порожденной заявкой библиотека не строит.
Пример
use brokapi::core::{
ClientOrderId, Error, InstrumentId, PlaceStopOrderRequest, Price, Qty, Side, StopExpiry,
StopOrderExecutor, StopOrderKind, StopOrderRef,
};
/// Ставит стоп-лосс на продажу, смотрит список ждущих и снимает стоп по номеру брокера.
async fn protect<U: StopOrderExecutor>(
ud: &U,
instrument: InstrumentId,
qty: Qty,
stop_price: Price,
) -> Result<(), Error> {
let cid = ClientOrderId::new_v4();
let ack = ud
.place_stop(PlaceStopOrderRequest {
cid,
instrument,
side: Side::Sell,
kind: StopOrderKind::StopLoss,
qty,
stop_price,
limit_price: None, // рыночная заявка после срабатывания
expiry: StopExpiry::GoodTillCancel,
})
.await?;
// Номер брокера нужно сохранить: после рестарта у Т-Банка отмена по cid невозможна
let broker_id = ack.broker_id;
for stop in ud.active_stop_orders().await? {
println!("стоп {:?} {:?} по {}, cid {:?}", stop.kind, stop.state, stop.stop_price, stop.cid);
}
ud.cancel_stop(StopOrderRef::Broker(broker_id)).await
}
Потерянный ответ
Unknown у place_stop значит, что стоп мог дойти до брокера. Тот же cid повторить нельзя, новый стоп наугад ставить тоже: выйдет два.
Прочитайте active_stop_orders():
- Есть стоп с вашим
cid. Он ваш. БКС, Финам, АЛОР и Bybit возвращаютcidсами. Т-Банк нет: его коннектор связывает стоп сcid, если в списке ровно один стоп безcid, который подходит под запрос (бумага, сторона, вид, количество, цены) и создан, пока запрос был в пути. - Есть стопы без
cidна той же бумаге и в ту же сторону, ваш среди них неясен. Второй стоп не ставьте. У Т-Банка так бывает, когда рядом стоит такой же стоп другой программы или два одинаковых ответа потерялись: коннектор не гадает иcidне назначает. - Ничего нет. Стопа у брокера нет. Подождите несколько секунд, посмотрите еще раз (медленный брокер принимает стоп с опозданием), потом
ставьте заново с новым
cid.
Рабочий код этих трех шагов на Python: Стоп-заявки, Python.
Что отличается у брокеров
| БКС | Т-Банк | Финам | АЛОР | Bybit | |
|---|---|---|---|---|---|
| Как ставится | условная заявка (orderType = 5), синхронным методом | отдельный сервис стоп-заявок | стоп-лосс и стоп-лимит обычной заявкой типа стоп, тейк-профит отдельным методом SL/TP | стоп-заявка (рыночная при срабатывании) или стоп-лимит (лимитная), обычным методом с условием срабатывания | обычная заявка с ценой срабатывания (triggerPrice), рыночная при срабатывании или лимитная (StopLimit) |
Тейк-профит с ценой после срабатывания (limit_price) | да (лимитное исполнение) | да | нет: у Финама там защитный спред, а не цена. Вернется Rejected с кодом unsupported_limit_price | да (стоп-лимит) | да (StopLimit или вид с limit_price) |
| Срок «до отмены» | да | да | да | да (уходит как год вперед: брокер требует время окончания) | да (единственный) |
| Срок «до конца дня» | нет, unsupported_expiry | нет, unsupported_expiry | да | да (конец дня по Москве) | нет, unsupported_expiry |
| Срок «до даты» | нет, unsupported_expiry | да | только у тейк-профита | да | нет, unsupported_expiry |
| Количество | штуки, целое не меньше 1 | штуки, кратные лоту (коннектор переводит в лоты, иначе invalid_qty) | штуки, число больше нуля (кратность лоту проверяется, если лот передан) | штуки, кратные лоту (коннектор переводит в лоты, иначе invalid_qty) | базовая монета, кратная шагу контракта (иначе invalid_qty) |
Свой cid в списке | находится одним запросом по номеру заявки, если брокер знает условные заявки | только пока работает процесс, который поставил заявку. Брокер cid не возвращает, связь хранится в памяти. После рестарта cid пустой. Исключение: стоп с потерянным ответом получает cid, если подходит ровно один | да, читается из комментария заявки | читается из комментария заявки, после перезапуска тоже | находится в списке по orderLinkId, свой cid возвращается сам |
| Поток статусов | нет | нет в API | нет | нет | нет, и после срабатывания стоп приходит в потоке заявок как обычная заявка под тем же cid |
Особенности:
- БКС: срок жизни. Документация срок условной заявки не называет. Поэтому БКС принимает только «до отмены» и никакого поля срока не шлет. Срок жизни условной заявки в документации БКС не назван. Направление срабатывания БКС выбирает сам из стороны и вида. Пару «стоп-лосс плюс тейк-профит» одной заявкой библиотека не создает. Чужую пару в списке показывает двумя заявками с одним номером.
- Т-Банк: отмена после рестарта.
cancel_stop(StopOrderRef::Cid(cid))для стопа, поставленного раньше этого запуска, вернетRejectedс кодомunknown_stop_order. Снимайте поStopOrderRef::Broker(номер): номер берите из журнала бота или изactive_stop_orders(). - Bybit: списки. Ожидающие стоп-заявки не попадают в
active_ordersиreconcileкак рабочие заявки, для нихactive_stop_orders(после срабатывания стоп становится обычной заявкой). Позицию стоп сам не закрывает:reduceOnlyиcloseOnTriggerконнектор не ставит, поэтому размер стопа равен позиции. Подробно: Bybit. - Финам: списки. Стоп-заявки не попадают в
active_ordersиreconcile. Для них свой список. - Финам: процент вместо штук. Если у чужой SL/TP-заявки единица объема - процент от позиции, в
qtyбудет 0 («не в штуках»), а не процент. Свои заявки библиотека ставит в штуках. - Т-Банк: длинный список. Когда стоп-заявок слишком много для одного ответа (ошибка 30261),
active_stop_ordersчитает список по периодам и склеивает. Если и так не вышло,Rejectedс кодомstop_orders_response_limit. Подробно: Т-Банк. - Т-Банк: лимит по инструменту. Ответ 80007 (максимум стоп-заявок по инструменту) - это
Rejectedс кодомstop_orders_limit, а неRateLimited: повтор не поможет, снимите одну из заявок. - Сверка и стопы.
cidстоп-заявок в список дляreconcileне кладите: обычной сверкой они не видны. После старта читайтеactive_stop_orders().
Стоп-заявки проверяйте на минимальном объеме с ценой далеко от рынка.
Любой метод брокера: raw
Общий интерфейс закрывает то, что умеют все брокеры. Но у каждого брокера есть много своего: купоны и
дивиденды, отчеты, избранное, корпоративные действия, свежие методы. Чтобы они не оказались закрыты, у каждого
коннектора (MD и UD, у всех брокеров) есть метод raw(). Он позволяет вызвать любой метод API брокера
с теми же токеном, адресом и счетчиком лимитов, что у самого коннектора.
| Брокер | Тип | Что получает ваш код в замыкании |
|---|---|---|
| Т-Банк | TbankRaw | TbankClient с клиентами всех сервисов (users(), market_data(), instruments()…) |
| БКС | BcsRaw | BcsHttpClient (REST), плюс connect_ws для WebSocket-каналов |
| Финам | FinamRaw | FinamApi с клиентами всех сервисов |
| АЛОР | AlorRaw | AlorHttpClient (REST, по методу на операцию описания API), плюс connect_ws и connect_cws для каналов WebSocket |
| Bybit | BybitRaw | BybitHttpClient (REST API V5 с подписью: методы заявок, чтений, amend_order и другие), плюс connect_ws для каналов WebSocket |
Сколько методов брокера есть и какие из них доступны только так: Покрытие API. Это не недоделка, а способ дать все остальное, не раздувая общий интерфейс.
Два вызова
call(имя, замыкание)- для чтения. Сетевой сбой и таймаут - ошибка, которую можно повторить. У Финама при просроченном токене запрос один раз повторяется после обновления JWT. У БКС и АЛОРа клиент сам повторяет запрос один раз после ответа 401 (команды заявок этого не делают).call_command(имя, замыкание)- для всего, что меняет счет. Если ответа нет (таймаут, обрыв, ошибка сервера), результатUnknown: брокер мог выполнить команду. Библиотека такой вызов никогда не повторяет.
Если сомневаетесь, меняет ли метод счет, берите call_command.
Что происходит при вызове
- Берется квота. Имя метода (
"GetUserTariff") ищется в файле лимитов (limits.tomlкрейта брокера). Нет такого имени - тратится общее ведро брокера (маршрут"*"). Бесплатно не уходит ни один вызов. Квоты нет - вызов не отправляется, возвращаетсяRateLimited. - Идет вызов с токеном коннектора. Время ожидания то же, что у самого коннектора.
- Ошибка брокера переводится в те же ошибки библиотеки, что и везде (Круг команд).
Правила:
- Один вызов
raw()- один метод API. Не вызывайте несколько методов в одном замыкании: квота берется за один. - Имя метода - это имя метода из описания API (для Т-Банка и Финама имя метода .proto, для БКС имя операции из файла лимитов). Хотите точный учет - добавьте маршрут для метода в файл лимитов.
- Потоки. Если метод возвращает поток (server-streaming), квота покрывает только открытие. Дальше поток ваш: читайте его сами. Поток Финама хранит JWT, с которым он открыт, и после смены токена его надо открыть заново (коннектор делает это для своих потоков, для ваших - вы).
- WebSocket БКС.
connect_ws(канал, отправитель)открывает один из каналов (лимиты, маржинальные показатели) с тем же токеном. Квота называетсяConnect<Канал>. Сессия сама переподключается и подписывается заново.
Чего raw() не делает
raw() не знает про заявки коннектора. Заявка, поставленная через raw(), не попадает ни в события, ни в
reconcile, и коннектор не следит за ее состоянием. Заявки ставьте обычными методами. Через raw() вызывайте то,
чего в общем интерфейсе нет.
Примеры
Т-Банк: тариф и последние цены нескольких инструментов сразу (нужен крейт brokapi-tbank-protocol).
use brokapi::tbank::MarketDataConnector;
use brokapi::tbank::protocol::v1 as pb;
async fn tbank_raw(md: &MarketDataConnector) -> Result<(), brokapi::core::Error> {
let raw = md.raw();
// Квота берется по имени метода, вызов идет с токеном коннектора
let tariff = raw
.call("GetUserTariff", |c| async move { c.users().get_user_tariff(pb::GetUserTariffRequest {}).await })
.await?;
println!("лимитов в тарифе: {}", tariff.unary_limits.len());
let prices = raw
.call("GetLastPrices", |c| async move {
c.market_data()
.get_last_prices(pb::GetLastPricesRequest {
instrument_id: vec!["SBER_TQBR".into(), "GAZP_TQBR".into()],
..Default::default()
})
.await
})
.await?;
for p in prices.last_prices {
println!("{} {:?}", p.ticker, p.price);
}
Ok(())
}
БКС: котировки и история операций (нужен крейт brokapi-bcs-protocol).
use brokapi::bcs::protocol::http::models::{OperationHistoryRequest, QuoteInstrument, QuotesRequest};
use brokapi::bcs::MarketDataConnector;
async fn bcs_raw(md: &MarketDataConnector) -> Result<(), brokapi::core::Error> {
let raw = md.raw();
let quotes = raw
.call("GetQuotes", |http| async move {
http.get_quotes(&QuotesRequest { instruments: vec![QuoteInstrument::new("SBER", "TQBR")] }).await
})
.await?;
for q in quotes.records {
println!("{:?} покупка {:?} продажа {:?} последняя {:?}", q.ticker, q.bid, q.offer, q.last);
}
// Операции без сделок (пополнения, дивиденды, комиссии): в общем интерфейсе их нет
let history = raw
.call("GetOperationHistory", |http| async move {
http.search_operations(&OperationHistoryRequest::default(), Some(10), None).await
})
.await?;
println!("операций: {}", history.records.len());
Ok(())
}
Финам: последняя котировка и время биржи (нужен крейт brokapi-finam-protocol). Замыкание у Финама может
вызываться дважды (после обновления токена), поэтому оно типа Fn, а не FnOnce.
use brokapi::finam::MarketDataConnector;
use brokapi::finam::protocol::pb::grpc::tradeapi::v1::assets as ps;
use brokapi::finam::protocol::pb::grpc::tradeapi::v1::marketdata as pm;
async fn finam_raw(md: &MarketDataConnector) -> Result<(), brokapi::core::Error> {
let raw = md.raw();
let quote = raw
.call("LastQuote", |api| async move {
api.market_data().last_quote(pm::QuoteRequest { symbol: "SBER@MISX".into() }).await
})
.await?;
println!("{} {:?}", quote.symbol, quote.quote);
let clock = raw.call("Clock", |api| async move { api.assets().clock(ps::ClockRequest {}).await }).await?;
println!("время биржи: {:?}", clock.timestamp);
Ok(())
}
АЛОР: котировки нескольких бумаг сразу и сводка портфеля (оба метода в общем интерфейсе не нужны).
use brokapi::alor::MarketDataConnector;
async fn alor_raw(md: &MarketDataConnector, portfolio: &str) -> Result<(), brokapi::core::Error> {
let raw = md.raw();
let quotes = raw
.call("GetQuotes", |http| async move { http.get_quotes(&[("MOEX", "SBER"), ("MOEX", "GAZP")]).await })
.await?;
for q in quotes {
println!("{} покупка {:?} продажа {:?} последняя {:?}", q.symbol, q.bid, q.ask, q.last_price);
}
let portfolio = portfolio.to_string();
let summary = raw
.call("GetSummary", |http| async move { http.get_summary("MOEX", &portfolio).await })
.await?;
println!("оценка портфеля: {:?}", summary.portfolio_evaluation);
Ok(())
}
Остальные методы описания API (группы заявок, оценка заявки, риски, остальные подписки WebSocket, канал /cws) устроены так же:
метод клиента AlorHttpClient в замыкании, имя для квоты первым аргументом.
Все примеры только читают. Все, что меняет счет (отчет, SL/TP-заявка Финама, группа заявок АЛОРа и подобное), идет через
call_command. Готовые полные примеры лежат в examples/*_raw_access.rs соответствующих крейтов.
Как проверено
На имитации у всех брокеров: вызов проходит авторизацию, тратит квоту по имени метода, отказ по квоте не
доходит до брокера, счетчик общий с соседним коннектором, метод без маршрута идет по маршруту по умолчанию.
У Финама еще проверено обновление токена при чтении и отсутствие повтора у команды. У Т-Банка и БКС неясный ответ
команды дает Unknown. У БКС проверено открытие WebSocket-канала через raw().
Как тестировать код против имитаций брокеров (Rust)
Инструкция. Крейт brokapi-mocks запускает в вашем процессе имитации БКС, Т-Банка, Финама, АЛОРа и Bybit. Они говорят на настоящем
протоколе брокера (HTTP и WebSocket у БКС, АЛОРа и Bybit, gRPC у Т-Банка и Финама), поэтому коннектор работает с ними так же, как с брокером:
без сети, без токенов, без денег. Все примеры книги и все программы в examples/ запускаются против них.
Имитация проверяет, что ваш код правильно разбирает то, что брокер описал в документации. Она не доказывает, что настоящий брокер отвечает так же.
brokapi-mocks не входит в обещания библиотеки (publish = false): его интерфейс может меняться в любой версии. Он нужен
тестам и примерам.
Подключение
# edition = "2024" в [package], Rust 1.88 или новее
[dev-dependencies]
brokapi = { path = "../brokapi", features = ["bcs", "tbank", "finam", "alor"] }
brokapi-mocks = { path = "../brokapi/crates/mocks" }
tokio = { version = "1", features = ["full", "test-util"] }
Путь указывает на клон репозитория. Тесты - обычные #[tokio::test] (среда в одном потоке). Как обращаться со временем в таких
тестах, написано в разделе Время в тестах.
Запуск каждой имитации
Имитация живет, пока жива переменная MockServer, и слушает случайный порт на 127.0.0.1. Адрес и токен подставляются в
настройку коннектора специальным конструктором for_mock.
| БКС | Т-Банк | Финам | АЛОР | |
|---|---|---|---|---|
| Запуск | brokapi_mocks::bcs::MockServer::start(Default::default()).await | brokapi_mocks::tbank::MockServer::start(Default::default()).await | brokapi_mocks::finam::MockServer::start().await | brokapi_mocks::alor::MockServer::start(Default::default()).await |
| Адрес | server.http_base | server.endpoint | server.url | server.http_base (сервер авторизации по тому же адресу) |
| Настройка клиента | BcsConfig::for_mock("mock-refresh-token", &server.http_base).with_write(true) | TbankConfig::for_mock(TOKEN, &server.endpoint).with_account(ACCOUNT_A) | FinamApiConfig::new(SECRET).with_endpoint(server.url.clone()) | AlorConfig::for_mock("mock-refresh-token", &server.http_base) (портфель D00001, биржа MOEX) |
| Счета | один счет на refresh-токен: другое имя после mock-refresh- дает отдельный счет | ACCOUNT_A и ACCOUNT_B с разными данными | "ACC1" и "ACC2" | один портфель D00001, другой refresh-токен дает отдельный счет с пустыми заявками |
| Инструменты | любой тикер, класс TQBR | справочник (find) знает SBER и GAZP (uid-sber, uid-gazp), лот 10. Для неизвестного тикера find отвечает пустым списком, не ошибкой. Свечи с synthetic_candles отдаются и для идентификатора TickerClass любого тикера | справочник знает SBER@MISX, GAZP@MISX. Свечи с synthetic_candles отдаются для любого символа | - |
use std::sync::Arc;
use brokapi::bcs::protocol::config::BcsConfig;
use brokapi::bcs::{MarketDataConfig, MarketDataConnector, UserDataConfig, UserDataConnector};
use brokapi::bcs::limits::default_limits;
use brokapi::runtime::BrokerLimiter;
use tokio::sync::mpsc;
let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
let cfg = BcsConfig::for_mock("mock-refresh-token", &server.http_base).with_write(true);
let limiter = BrokerLimiter::new(&default_limits());
let (tx, mut events) = mpsc::channel(1024);
let ud = UserDataConnector::start(UserDataConfig::new(cfg, "bcs-ud"), limiter, tx).await?;
ud.reconcile(&[]).await?; // обязательно, как и с настоящим брокером
Чтобы читать счетчики и менять рычаги работающей имитации (event_sockets(), synthetic_candles и другие), создайте настройки сами и
передайте Arc в start. Тип надо назвать: Arc::new(Default::default()) компилятор не выведет.
use std::sync::Arc;
use brokapi_mocks::bcs::{MockOptions, MockServer};
let opts: Arc<MockOptions> = Arc::default();
let server = MockServer::start(opts.clone()).await;
// ... рычаги и счетчики лежат в `opts`, например `opts.event_sockets()` - сколько сокетов событий открыто.
BrokerLimiter::new возвращает Arc<BrokerLimiter>: передавайте его дальше через .clone().
Готовые рабочие программы с запуском имитаций: examples/trading_bot.rs (БКС и Т-Банк), examples/infrastructure.rs
и examples/research.rs (все три). Тесты на них: tests/examples.rs.
Время в тестах
Тесты без настоящих пауз: ни sleep, ни ожидания «на всякий случай». Часы коннекторов - часы tokio: все они берут текущее
время из brokapi::runtime::now(), поэтому лимит запросов, таймаут команды и пауза переподключения идут по тем же часам, что и
тест.
Нельзя только одного: останавливать часы на весь тест (#[tokio::test(start_paused = true)]), когда имитация слушает настоящий
порт. Пока запрос идет по сокету, у среды нет задач, и остановленные часы сами перескакивают к ближайшему таймеру. Таймаут запроса
срабатывает раньше, чем прочитан ответ. Поэтому у тестов с имитациями часы настоящие, а вместо пауз - два помощника в
brokapi_mocks::time:
| Помощник | Зачем |
|---|---|
jump(d) | сдвигает часы вперед на d сразу. Лимит запросов, который восстанавливается за секунду, полон после jump(1 с). Таймеры, которые ждали, срабатывают |
jump_until("что ждем", шаг, || условие) | сдвигает часы на шаг снова и снова, пока условие не станет истинным. Для ожидания того, что перед действием спит, например пауза 0,5 с перед переоткрытием потока. Вызывать, когда запросов в пути нет |
wait_until("что ждем", || условие) | ждет, пока условие станет истинным (счетчик имитации, событие), без пауз. Через 10 секунд роняет тест и называет, чего не дождался. wait_until_async - то же для условия, которое спрашивают вызовом коннектора |
elapse_when("что ждем", || запрос дошел, d, вызов) | выполняет вызов, а когда запрос дошел до имитации, сдвигает часы на d. Так проверяют Unknown по таймауту без ожидания таймаута: имитация держит ответ дольше (post_delay_ms), чем таймаут клиента, а часы перескакивают таймаут за ноль секунд |
Про «ничего не произошло» (чужой счет не получил событие, повтора не было) вместо «подождали и не увидели» проверяют так: после действия делают что-то, что приходит заведомо позже (событие по своему счету, круг запроса к брокеру), и смотрят, что между ними лишнего нет.
В готовых тестах книги так и сделано: crates/*/tests/.
Константы и пути
| Что | Где | Значение |
|---|---|---|
| Токен Т-Банка | brokapi_mocks::tbank::TOKEN | "mock-token" |
| Счета Т-Банка | brokapi_mocks::tbank::{ACCOUNT_A, ACCOUNT_B} | "acc-a", "acc-b" |
| Секрет Финама | brokapi_mocks::finam::SECRET | "mock-secret" |
| Счета Финама | строки | "ACC1", "ACC2" |
| Refresh-токен БКС | строка | "mock-refresh-token" (токен доступа имитации brokapi_mocks::bcs::DEFAULT_ACCESS) |
| Refresh-токен и портфель АЛОРа | brokapi_mocks::alor::PORTFOLIO | "mock-refresh-token", "D00001" (токен доступа brokapi_mocks::alor::DEFAULT_ACCESS, лот бумаг brokapi_mocks::alor::LOT = 10) |
| Цена, которую имитации отклоняют | brokapi_conformance::REJECT_PRICE | 13.37 (можно писать число) |
| Цена, отклоняемая в теле ответа (БКС) | brokapi_mocks::bcs::REJECT_IN_BODY_PRICE | 13.38 |
Какие данные отдают имитации
- Стакан. БКС (чтение по REST и поток): лучшая цена покупки 244.30, продажи 244.35. Подписка на стакан БКС дает один снимок
и дальше тихо: бот, которому нужен повтор по таймеру, не дождется второго снимка. Т-Банк: покупка около 273.90, продажа около 274.00, при подписке
приходит несколько снимков. Финам (чтение по REST): покупка 100.0, продажа 100.1, поток снимков с периодом
tick_ms. Имитации принимают любую цену, кроме отклоняемой. - Свечи и бары. По умолчанию фиксированный короткий ответ. Включите
synthetic_candles(опция имитации), и имитация отдаст ряд под заданный период по одному бару на шаг таймфрейма и отклонит период длиннее предела брокера (БКС: больше 1440 баров, Т-Банк:INVALID_ARGUMENT 30014по таблице пределов). Так проверяется выгрузка окнами. - АЛОР. Стакан: покупка 244.30, продажа 244.31, подписка на стакан дает один снимок. Бумаги
SBER,GAZP(режимTQBR, лот 10, шаг 0.01) и фьючерсSiZ6, тикерNOPEнеизвестен. Портфель: рубли и 30 штукSBER. Свечи всегда рядом по сетке таймфрейма на весь запрошенный период. ПовторX-REQIDполучает копию первого ответа, как сказано в документации брокера. - Заявки. Имитации помнят заявки: их можно поставить, прочитать, снять, заменить (где брокер умеет). Данные счета отдельные.
Имитация Bybit
Таблицы выше про четырех российских брокеров. Имитация Bybit устроена так же, отличия коротко.
use brokapi::bybit::protocol::config::BybitConfig;
let server = brokapi_mocks::bybit::MockServer::start(Default::default()).await;
let cfg = BybitConfig::for_mock("mock-key", "mock-secret", &server.http_base);
// MarketDataConfig::new(cfg.clone(), "bybit-md"), UserDataConfig::new(cfg, "bybit-ud"): дальше как у остальных.
- Подпись настоящая. Имитация проверяет подпись каждого приватного запроса и окно времени (
recv_window), как биржа: клиент, который подписывает неверно, получает отказ. Ключbrokapi_mocks::bybit::DEFAULT_KEY("mock-key"), секретSECRET("mock-secret"). Другой ключ - отдельный счет со своими заявками. - Инструменты.
MOCKUSDT(бессрочный, шаг количества и самое малое количество 10, шаг цены 0.01,LOT= 10),BTCUSDT(числа настоящего контракта: шаг 0.001, цена 0.10) и срочный фьючерсBTC-26DEC26. ТикерNOPEUSDT(UNKNOWN_SYMBOL) неизвестен: чтение дает код 10001. - Заявки и рычаги.
server.fill(&cid.hyphenated().to_string(), qty, price)исполняет заявку (статус, исполнение, события).server.cut_event_sockets()обрывает приватные сокеты (бот получаетEvent::Reconnected),server.push_event(текст)пишет сырое сообщение в приватные сокеты. Лимитная заявка по ценеbrokapi_conformance::REJECT_PRICE(13.37) отвергается кодом 110003. - Настройки (
MockOptions):rate_limit_order_book(чтение стакана отвечает HTTP 429),order_delay_ms(команда ждет столько по часам теста, дляUnknownпо таймауту),stop_answer_lost(стоп принят, ответ потерян: HTTP 503),clock_offset_ms(часы биржи уходят вперед),refuse_ws_login,fail_subscribe_text,stream_gate(биржа медленно открывает потоки),disconnect_after_data_frames. - Свечи. Ряд под запрошенный период, по одной свече на шаг интервала (
synthetic_candlesне нужен).
Рычаги, которыми вызывают нужный исход
| Что получить | БКС | Т-Банк | Финам | АЛОР |
|---|---|---|---|---|
| Исполнить заявку | server.fill(&cid.hyphenated().to_string(), qty, price) | server.fill(ACCOUNT_A, &cid.hyphenated().to_string()) | вручную: server.state.push_order(счет, ...) | server.fill(&cid.hyphenated().to_string(), qty, price): сначала событие заявки, потом сделка |
Rejected на постановке (цена задается в тесте, не берется из стакана) | лимитная цена brokapi_conformance::REJECT_PRICE (13.37), в теле ответа 200 - цена 13.38 (mbcs::REJECT_IN_BODY_PRICE) | REJECT_PRICE (13.37) | REJECT_PRICE (13.37) | REJECT_PRICE (13.37), ответ HTTP 400 |
Unknown на постановке | opts.order_delay_ms больше, чем cfg.request_timeout клиента (имитация держит команду это время по часам теста). Пример: a_place_without_an_answer_is_unknown в crates/bcs/tests/conformance.rs | opts.post_delay_ms больше, чем with_request_timeout(...) клиента. Либо opts.post_fail с кодом gRPC | opts.place_delay_ms больше таймаута клиента, либо opts.place_error | opts.order_delay_ms больше, чем request_timeout клиента |
| Обрыв потока стакана | opts.disconnect_after_data_frames | opts.md_cut_after | opts.book_drop_after | opts.disconnect_after_data_frames |
Брокер медленно открывает поток (subscribe_* должен ждать, а не вернуться раньше) | opts.stream_gate.hold() до старта коннектора: рукопожатие сокетов заявок и портфеля не отвечается. opts.stream_gate.release() отпускает. opts.stream_gate.waiting() - сколько запросов держится. Подписка рынка держится так же | opts.stream_gate.hold(): потоки заявок, исполнений и портфеля не открываются. Рынок: opts.md_silent = true, подтверждения подписок не приходят | server.state.options.stream_gate.hold(): открытие любого потока не возвращается | opts.stream_gate.hold(): подписки на сокете не обрабатываются и не подтверждаются |
Обрыв потоков своих заявок и исполнений (Event::Reconnected) | server.cut_event_sockets(), подключения считает opts.ws_connections() | server.cut_user_streams(), открытия считает opts.user_stream_opens | server.state.cut_user_streams(), открытия считает server.state.order_stream_opens | server.cut_event_sockets(), подключения считает opts.ws_connections() |
| Статус 6 «Отменяется» | opts.cancel_pending, закончить отмену server.finish_cancel(cid) | нет | нет | - |
| Исполнения с накопленными итогами | server.fill(cid, qty, price) шлет executedQuantity, executionValue, averagePrice как брокер | нет | нет | - |
| Длинная история сделок | server.add_history_trades(n, цена, количество) (список листается по page и size, не больше 1000 последних) | opts.operations_pages | server.state.push_trade(...), ответ режется по limit и интервалу | server.add_history_trades(n, цена, количество) (один ответ до 1000) |
| Живая заявка, которой нет в списке | server.hide_from_list(cid) | нет | нет | - |
| Заявка, поставленная кем-то другим | server.fill/ручной вызов | server.place_foreign(счет, request_id, инструмент, лоты) | server.state.push_order(...) | raw().call_command(...) с чужим комментарием |
| Лот инструмента нельзя прочитать | нет | opts.instrument_by_fail, opts.omit_lot_size | нет | - |
Overflow | канал на одно место, никто не читает, cfg.order_state_buffer = 2 и несколько исполнений подряд | то же | то же | то же |
RateLimited | малый бюджет: свои числа в BrokerLimiter::new (формат файла). «Один отказ, потом проходит»: burst = 1, длинный per | то же | то же | то же |
| Чтение свечей падает | не предусмотрено | opts.candle_fail: 1 - отказ навсегда (Rejected), 2 - сбой сети (Transport), 0 - норма. opts.candle_network_failures: сколько следующих чтений упадут по сети, потом снова норма. Только с synthetic_candles | не предусмотрено | - |
| Свечи по заданному периоду | opts.synthetic_candles | opts.synthetic_candles | server.state.options.synthetic_candles | всегда: ряд по сетке, opts.history_requests хранит запросы |
opts - это Arc<MockOptions>, который передается в start. Для Финама рычаги лежат в server.state.options. Все поля - атомарные
числа и флаги, их можно менять, пока имитация работает.
Ловушки тестов
subscribe_*возвращается, когда поток открыт. Ждать подключения сокета (event_sockets()) послеsubscribe_order_stateиsubscribe_executionsне нужно: заявка, поставленная сразу после возврата, не пропадет. Событие, случившееся до подписки, теряется, его читают черезorder_stateилиactive_orders. Подписка раньше заявки - правило и для имитации.- Имитация сервера должна жить столько же, сколько коннекторы. Положите ее в переменную, которая не уходит из области раньше.
- Рестарт процесса проверяется двумя жизнями коннекторов при одной имитации: имитация помнит заявки между ними (инфраструктура).
- Таймауты ожидания в тестах задавайте с запасом на медленный CI, но не бесконечные: зависший тест хуже упавшего. Они срабатывают только когда тест упал, поэтому на скорость прогона не влияют.
- Имитации не знают реальных лимитов брокеров и реальной скорости. Лимиты запросов проверяйте своим малым бюджетом.
Чего имитации не покажут
Реальные форматы чисел и времени, реальные лимиты и ошибки, поведение брокера на повтор cid, единицы количества у БКС. Проверяйте эти места
на минимальной заявке.
brokapi для Python
Тонкая обертка над ядром brokapi на Rust. Стадия: открытая альфа, интерфейс до беты может меняться. Перед боевым счетом: Перед боевым счетом.
Обертка не придумывает своих правил. Это те же БКС, Т-Банк, Финам, АЛОР и Bybit, те же сверка, cid, лимиты и ошибки, что у ядра, только
вызываются они из Python. События приходят через async for, деньги, цены и количества - decimal.Decimal, никогда float.
Устройство, правила и особенности брокеров общие для всех путей и лежат в общих частях книги. Здесь то, что зависит от языка:
как подключить пакет и как решить задачу кодом.
Что нужно
- Python 3.10 или новее. Пакет один на все версии (
abi3). pip install brokapi. Готовые пакеты есть для Linux (x86_64 и aarch64), macOS (Intel и Apple silicon) и Windows, на остальных системах расширение соберется при установке (нужен Rust 1.88 или новее). Подробности и частые ошибки: Установка. Пакет дает библиотеку. Программаbrokapi(Gateway) ставится отдельно: Установка.
python -m venv .venv && . .venv/bin/activate
pip install "git+https://github.com/runinfra/brokapi#subdirectory=crates/python" # из клона: pip install ./crates/python
python -c "import brokapi; print(brokapi.__version__)"
Должен напечататься номер версии.
Инструкции по задачам
| Что нужно сделать | Страница |
|---|---|
| Пройти путь от нуля до первой заявки: имитация без токена, песочница Т-Банка, потом боевой счет | Быстрый старт на Python |
Перенести бота с tinkoff-investments (или с QUIK) | Если вы пришли с tinkoff-investments |
| Написать торгового бота на событиях | Торговый бот на событиях, Python |
| Держать несколько брокеров и счетов в одном процессе | Несколько брокеров и счетов |
| Выгрузить длинную историю свечей | История свечей |
| Поставить стоп-заявку, найти ее и снять. Проверить стоп после рестарта | Стоп-заявки, Python |
| Остановить бота и знать, что останется на счете | Аварийная остановка бота |
| Вызвать метод брокера, которого нет в общем интерфейсе | Любой метод брокера, Python |
| Записать поток событий в файл и проиграть его из файла | Запись событий и проигрыш, Python |
| Проверить свой код без сети и денег | Тесты против имитаций, Python |
| Подключить бота к гейтвею вместо брокера | Подключить бота: Python |
| Понять, как Python связан с ядром, и чем отличается от Rust | Как это устроено |
| Найти класс, метод или поле до последней подробности | Справочник API: Python |
Что умеет первая версия
| Возможность | Как в Python |
|---|---|
| Подключиться к БКС, Т-Банку, Финаму, АЛОРу, Bybit | await brokapi.connect("bcs"), настройки из переменных окружения или аргументами |
| Подключиться через гейтвей | await brokapi.connect_gateway("bcs-main", bot="my-bot"): тот же Client, остальной код бота не меняется |
| Стакан, сделки рынка, котировки, свечи | order_book, last_trades, candles, подписки subscribe_*, история по окнам candle_pages |
| Свои заявки и исполнения | subscribe_order_state, subscribe_executions, executions |
| Заявки | reconcile, place, cancel, replace (где брокер умеет), order_state, active_orders |
| Стоп-заявки | place_stop, cancel_stop, active_stop_orders, subscribe_stop_orders (потока статусов нет ни у кого: Unsupported) |
| Любой метод API брокера | await client.raw("GetLastPrices", {...}): имя метода и словарь, ответ словарем, квота общая с клиентом |
| Запись и проигрыш событий | brokapi.record: EnvelopeWriter, EnvelopeReader, replay. Файл тот же, что у Rust |
| Справочник, лот, шаг цены, статус торгов, расписание | instrument, find, trading_params, trading_status, schedule |
| Портфель | portfolio, positions, balances, подписка subscribe_portfolio |
| События | async for envelope in client.events, один поток на клиента, brokapi.merge для нескольких |
| Ошибки | исключения с теми же именами и текстом, что у Error в Rust: Unknown, Rejected, RateLimited, NotReconciled, Degraded и другие |
| Тесты | brokapi.testing.mock_bcs(), mock_tbank(), mock_finam(), mock_alor(): имитации брокеров, на них идет каждый пример книги |
Python повторяет Rust по возможностям. Отличие одно: raw не отдает потоки (метод, который открывает поток, и WebSocket-каналы БКС), их
читает общий интерфейс. Подробности: Любой метод брокера, Python.
Правила, которые остаются
Обертка сохраняет обязательные правила ядра (Контракт для агентов):
- торговля открывается только после
reconcile, до негоplaceподнимаетNotReconciled cidпридумывает бот (brokapi.new_cid()) и записывает в свой журнал до вызоваplaceUnknownникогда не повторяется: после негоorder_state(cid)- один счетчик лимитов на область брокера:
limiter=уconnect - деньги, цены и количества -
Decimal,floatв вызове отвергается сTypeError
Что бывает, если нарушить каждое правило, объяснено в общих частях: Круг команд, Круг жизни, Лимиты, Деньги, цены и количества.
Общее, что нужно прочитать
- Контракт для агентов: обязательные правила на одной странице, нужны и человеку.
- Карта и три круга: события, команды, жизнь коннектора.
- Ошибки и что с ними делать, события и возможности, Брокеры.
- Для ИИ-агентов, которые пишут на Python: llms.txt для Python (на сайте
/docs/python/llms.txt).
Каждый пример в этом разделе - тест против имитаций брокеров, он запускается при каждой проверке репозитория.
Быстрый старт на Python
Цель страницы: за один заход дойти от пустого окружения до первой заявки и не рисковать деньгами по дороге. Ступеней три: сначала имитация брокера в вашем процессе (без токена и без денег), потом песочница Т-Банка, и только потом боевой счет.
Боевой токен выдает человек, и первую боевую заявку ставят только с его слова. ИИ-агент токен с правом торговли сам не создает и флаги записи (
BCS_IS_WRITEи подобные) сам не включает: правило 0. На настоящем счете БКС, Т-Банка, Финама и АЛОРа библиотеку пока не запускали (Bybit проверен вживую), подробности: Перед боевым счетом.
Шаг 1. Получить библиотеку
Нужен Python 3.10 или новее. Пакет brokapi ставится в виртуальное окружение:
python -m venv .venv && . .venv/bin/activate
pip install brokapi
Самая свежая версия из репозитория - вместо второй строки (нужен Rust 1.88 или новее, расширение собирается при установке, это несколько минут. Подробности и частые ошибки - на странице Установка):
pip install "git+https://github.com/runinfra/brokapi#subdirectory=crates/python"
Проверка:
python -c "import brokapi; print(brokapi.__version__)"
Должна напечататься версия 0.1.0a5. Не напечатала: таблица «Если что-то пошло не так» на странице
Установка.
Шаг 2. Первая программа: без токена и без денег
Обычный скрипт. Имитация (brokapi.testing.mock_tbank) - это настоящий сервер на 127.0.0.1, который говорит на протоколе Т-Банка, а
клиент к нему тот же brokapi.Client, что вернул бы brokapi.connect("tbank", ...). Токен, счет и сеть не нужны.
Программа начинается с импортов:
import asyncio
import sys
from decimal import Decimal
import brokapi
from brokapi.testing import mock_tbank
Сама работа - функция first_order. Она берет клиента любого брокера, поэтому дальше, на песочнице и на боевом счете, она не меняется:
async def first_order(client: brokapi.Client) -> None:
"""Reads the market, puts one order far below it and takes it off at once."""
# 1. What to trade and by what rules: the step of the price and the lot.
sber = await client.instrument("SBER")
params = await client.trading_params(sber)
# 2. The quote: the best bid and the best ask are the top of the order book.
book = await client.order_book(sber, depth=1)
best_bid = book.bids[0].price
print(f"SBER: bid {best_bid}, ask {book.asks[0].price}, lot {params.lot_size}, price step {params.price_step}")
# 3. Subscribe to the states of your orders, and only then reconcile: no event is lost between the two.
await client.subscribe_order_state()
report = await client.reconcile([]) # a new bot has an empty ledger. Until now `place` is NotReconciled
print(f"reconcile: {len(report.foreign)} foreign order(s) on the account")
# 4. An order far from the market: 90% of the best bid, rounded down to the price step, one lot.
price = brokapi.round_to_step(best_bid * Decimal("0.9"), params.price_step, brokapi.RoundMode.DOWN)
cid = brokapi.new_cid() # a real bot writes the cid to its journal here, before `place`
ack = await client.place(cid=cid, instrument=sber, side=brokapi.Side.BUY, qty=params.lot_size, price=price)
print(f"placed: broker order id {ack.broker_id}, price {price}")
# 5. Take it off and ask the broker what became of it. The answer of `order_state` is the proof.
await client.cancel(cid)
state = await client.order_state(cid)
print(f"after cancel: {state.state.kind.value}, orders alive: {len(await client.active_orders())}")
Запуск на имитации: блок async with поднимает сервер и останавливает его в конце, mock.connect() возвращает клиента.
async def on_the_imitation() -> None:
# The block starts the imitation and stops it at the end. `mock.connect()` is `brokapi.connect(...)` to it.
async with await mock_tbank() as mock:
client = await mock.connect()
await first_order(client)
# The price of the imitation stands still: it has no market behind it.
book = await client.order_book(await client.instrument("SBER"), depth=1)
print(f"the price stands still: bid {book.bids[0].price}")
Готовая программа целиком - в разделе «Вся программа» ниже (к этим кускам добавлены импорты и запуск через asyncio.run). Сохраните ее как
quickstart_tbank.py и запустите python quickstart_tbank.py. Должно напечататься:
SBER: bid 273.9, ask 274, lot 10, price step 0.01
reconcile: 0 foreign order(s) on the account
placed: broker order id EX1, price 246.51
after cancel: Cancelled, orders alive: 0
the price stands still: bid 273.9
Что здесь произошло:
- Котировка - верх стакана. Отдельного метода «котировка» у клиента нет:
order_book(sber, depth=1)дает лучшую цену покупки и продажи. - Подписка на статусы своих заявок идет до
reconcile, иначе событие между снимком сверки и подпиской пропало бы.reconcile([])открыл шлюз торговых команд: до негоplaceвернул быNotReconciled. Список пустой, потому что бот новый. - Цена заявки - 90% от лучшей покупки, округленная вниз до шага точной арифметикой (
round_to_step). Количество - один лот, в штуках (лотSBERв имитации 10). cidбот придумывает сам. В настоящем боте в этот момент строка уходит в журнал на диск: «отправляю».placeвернулOrderAck: «брокер принял». Что стало с заявкой, говоритorder_state, и послеcancelэтоCancelled.
Цена в имитации стоит на месте. За стаканом нет рынка: сколько раз ни спроси, будет 273.9 и 274, а заявка сама не исполнится. Исполнить ее
просят у имитации: mock.fill(cid) (Тесты против имитаций). Поэтому на имитации проверяют порядок вызовов и разбор
исходов, а стратегию по движению цены на ней проверить нельзя. Имитация написана по документации брокера и не доказывает, что настоящий брокер
ответит так же.
Такие же имитации есть у БКС, Финама и АЛОРа (mock_bcs(), mock_finam(), mock_alor()), first_order работает на каждой. У Bybit имитация mock_bybit() с контрактом MOCKUSDT и свой первый запуск, с демо-счетом вместо песочницы: Bybit.
Шаг 3. Песочница Т-Банка
Следующая ступень - настоящий сервер брокера, но с игровыми деньгами. Нужны токен песочницы и счет песочницы. Токен выдает человек в кабинете
Т-Банка, как это сделать - в разделе «Вход» страницы Т-Банк. Счет песочницы сам не появляется: его открывает метод
OpenSandboxAccount и пополняет SandboxPayIn (по документации Т-Банка). Без счета connect ответит Config: у токена нет открытого счета.
export TBANK_TOKEN=... # токен песочницы
python quickstart_tbank.py sandbox
В коде меняется одна строка: клиента делает connect, а не имитация. sandbox=True выбирает адрес песочницы (без него адрес боевой, и токен песочницы
не подойдет), то же делает переменная TBANK_SANDBOX=true. Токен берется из окружения или из файла .env рядом с программой.
async def on_the_sandbox() -> None:
# TBANK_TOKEN is the token of the sandbox. sandbox=True is the address of the sandbox (the real one is the default).
client = await brokapi.connect("tbank", sandbox=True)
try:
await first_order(client)
finally:
await client.close()
Печатается то же самое, но с живыми ценами песочницы. Заявка покупает, поэтому на счете песочницы должны быть деньги: пополните его на сумму
больше цены лота. Если счетов у токена несколько, укажите нужный: TBANK_ACCOUNT_ID или account_id= в connect.
Шаг 4. Боевой счет
Сюда идут, когда имитация и песочница прошли, а человек, чей это счет, сам выдал токен и сказал «можно». Первая заявка та же: лимитная, минимальное количество, цена далеко от рынка, сразу отмена.
| Брокер | Подключение | Переменные |
|---|---|---|
| БКС | await brokapi.connect("bcs") | BCS_REFRESH_TOKEN (обязательно), BCS_IS_WRITE (true - токен на запись). Песочницы нет |
| Т-Банк | await brokapi.connect("tbank") | TBANK_TOKEN, TBANK_ACCOUNT_ID (если у токена несколько счетов), TBANK_ENDPOINT, TBANK_APP_NAME. Без TBANK_SANDBOX адрес боевой |
| Финам | await brokapi.connect("finam") | FINAM_SECRET, FINAM_ACCOUNT_ID (обязательно), FINAM_API_URL. Песочницы нет |
| АЛОР | await brokapi.connect("alor") | ALOR_REFRESH_TOKEN (обязательно), ALOR_PORTFOLIO (обязательно, если токен называет несколько портфелей), ALOR_EXCHANGE (MOEX или SPBX), ALOR_TEST (true - тестовый контур) |
| Bybit | await brokapi.connect("bybit") | BYBIT_API_KEY и BYBIT_API_SECRET (обязательно), BYBIT_ENV (mainnet по умолчанию, demo - демо-счет с виртуальными деньгами, у него свои BYBIT_DEMO_API_KEY и BYBIT_DEMO_API_SECRET) |
Где взять токен и номер счета у каждого брокера: раздел «Вход» на страницах БКС, Т-Банк, Финам, АЛОР, Bybit. Первый день на Финаме, где песочницы нет: Финам.
await client.instrument("SBER") у Т-Банка делает один запрос к справочнику и берет uid инструмента, у БКС, Финама, АЛОРа и Bybit идентификатор строится
по тексту, второй аргумент задает площадку (TQBR, MISX, у Bybit категорию linear). Особенности брокеров: БКС, Т-Банк,
Финам, АЛОР, Bybit.
Пример для БКС
У БКС нет песочницы, счет настоящий. Заявка ставится только по токену на запись, ее цена заведомо ниже рынка, количество минимальное, и она
снимается сразу. Токен без записи (BCS_IS_WRITE не true) дойдет только до чтения.
Настройки лежат в переменных окружения. Удобно файлом .env рядом с программой, пакет читает его сам. Токены не печатайте в логи и не кладите
в коммиты. Те же настройки можно передать аргументами: brokapi.connect("bcs", refresh_token="...", write=True). Что не передано, берется из окружения.
connect поднимает два коннектора, рыночных данных и счета, и возвращает клиента. Если токен не подошел, это Config или Transport прямо здесь,
а не на первом запросе.
# BCS_REFRESH_TOKEN and BCS_IS_WRITE come from the environment (a .env file works)
client = await brokapi.connect("bcs")
print("connected: bcs-md and bcs-ud started")
Должно напечататься connected: bcs-md and bcs-ud started. Не напечатало: Config называет, какой настройки нет, Transport говорит, что БКС не
отвечает или отверг токен.
Чтение: три запроса, которые ничего не меняют. Инструмент SBER на основной площадке БКС (TQBR) получает метод instrument, он собирает
идентификатор по тикеру.
sber = await client.instrument("SBER") # the ticker on the board TQBR
# 1. Portfolio: money and positions of the account.
portfolio = await client.portfolio()
print(f"portfolio: {len(portfolio.balances)} balance(s), {len(portfolio.positions)} position(s)")
for balance in portfolio.balances:
print(f" money {balance.currency}: {balance.available} free, {balance.blocked} blocked")
# 2. Order book: best bid and best ask.
book = await client.order_book(sber, depth=5)
best_bid = book.bids[0].price if book.bids else None
best_ask = book.asks[0].price if book.asks else None
print(f"order book SBER: best bid {best_bid}, best ask {best_ask}")
# 3. Candles: the last hour of five-minute bars.
now = datetime.now(timezone.utc)
bars = await client.candles(sber, brokapi.Timeframe.M5, now - timedelta(hours=1), now)
print(f"candles M5: {len(bars)} bar(s), last close {bars[-1].close if bars else None}")
Должно напечататься число балансов и позиций, лучшая цена покупки и продажи и число свечей. Цены и суммы здесь Decimal: цена печатается точной
десятичной записью, как ее отдал брокер, без хвостов вроде 0.30000000000000004.
Дальше программа пишет в счет. Если токен не на запись, она остановится здесь.
print("WARNING: BCS has no sandbox. The account is real, the order below goes to the exchange.")
Заявка далеко от рынка и отмена. Порядок важен, причины каждого шага разобраны в Круге жизни и Круге команд:
- Правила инструмента: шаг цены и минимальное количество (
trading_params). Метод заодно сообщает шаг цены коннектору, и цена не на шаге будет отвергнута сразу, до отправки. - Цена: 90% от лучшей покупки, округленная вниз до шага точной арифметикой (
round_to_step). - Подписка на статусы своих заявок, и только потом
reconcile: событие между снимком сверки и подпиской иначе потерялось бы. cidпридумывает бот. В настоящем боте здесь строка в журнал: «отправляю».place, события статусов,cancelи проверкаorder_state.
# The price: far below the best bid, a multiple of the price step. The quantity: the minimum.
params = await client.trading_params(sber)
reference = best_bid or (bars[-1].close if bars else None)
if reference is None:
raise SystemExit("no price to start from: the book and the candles are empty")
# The rounding is exact: the price is a whole number of steps, no 0.9999999 noise.
price = brokapi.round_to_step(reference * FAR_FROM_MARKET, params.price_step, brokapi.RoundMode.DOWN)
print(f"order: buy {params.min_qty} at {price} (best bid {reference}, price step {params.price_step})")
# The gate is closed until the first reconcile. The ledger of this new bot is empty. Subscribe first, so no
# event between the snapshot of the reconcile and the subscription is lost.
await client.subscribe_order_state()
report = await client.reconcile([])
print(f"reconcile: {len(report.foreign)} foreign order(s) on the account")
# The cid is made by the bot and belongs in its journal before the order is sent.
cid = brokapi.new_cid()
# Here the bot writes `cid` to its journal as "sending".
print(f"cid {cid}")
ack = await client.place(
cid=cid,
instrument=sber,
side=brokapi.Side.BUY,
qty=params.min_qty,
price=price,
)
print(f"accepted: broker order id {ack.broker_id}")
await wait_for_order(client, cid, brokapi.OrderStateKind.ACKED)
# Cancel at once and look at the final state.
await client.cancel(cid)
await wait_for_order(client, cid, brokapi.OrderStateKind.CANCELLED)
last = await client.order_state(cid)
print(f"order_state: {last.state.kind.value}")
Должно напечататься: accepted: broker order id ..., события Acked, затем Cancelled, в конце order_state: Cancelled.
Событие это уведомление, а доказательство - ответ order_state, поэтому программа не падает, если события не пришли.
Если place вернул Rejected, у ошибки есть code: price_off_step (цена не на шаге), missing_price (у лимитной заявки нет цены),
отказ брокера с его текстом. Если Unknown, заявка могла встать: не повторяйте, спросите order_state(cid)
(Если что-то пошло не так, общая логика одна для всех языков).
Остановка:
await client.close()
print("stopped")
Заявки на бирже при остановке не снимаются. Если cancel не прошел, заявка останется. Как остановить бота так, чтобы на счете не осталось
лишнего: Аварийная остановка бота.
Вся программа
Текст программы crates/python/examples/quickstart_tbank.py (имитация и песочница)
"""Quick start: the first run without a token and without money, then the sandbox of T-Bank.
This program is the code of the page "Быстрый старт" of the Python section of the book. The book includes the marked
parts below, so the text and the code that is tested cannot drift apart.
Without a token (the imitation of T-Bank in your own process; no account, no network)::
python quickstart_tbank.py
On the sandbox of T-Bank (a token of the sandbox and an account of the sandbox are needed)::
TBANK_TOKEN=... python quickstart_tbank.py sandbox
The imitation (`brokapi.testing.mock_tbank`) is a server on 127.0.0.1 that speaks the protocol of T-Bank. The client is
the same `brokapi.Client` that `brokapi.connect("tbank", ...)` returns, so `first_order` below is the code of a real
run: it works with a client of any broker.
"""
import asyncio
import sys
from decimal import Decimal
import brokapi
from brokapi.testing import mock_tbank
# ANCHOR: first_order
async def first_order(client: brokapi.Client) -> None:
"""Reads the market, puts one order far below it and takes it off at once."""
# 1. What to trade and by what rules: the step of the price and the lot.
sber = await client.instrument("SBER")
params = await client.trading_params(sber)
# 2. The quote: the best bid and the best ask are the top of the order book.
book = await client.order_book(sber, depth=1)
best_bid = book.bids[0].price
print(f"SBER: bid {best_bid}, ask {book.asks[0].price}, lot {params.lot_size}, price step {params.price_step}")
# 3. Subscribe to the states of your orders, and only then reconcile: no event is lost between the two.
await client.subscribe_order_state()
report = await client.reconcile([]) # a new bot has an empty ledger. Until now `place` is NotReconciled
print(f"reconcile: {len(report.foreign)} foreign order(s) on the account")
# 4. An order far from the market: 90% of the best bid, rounded down to the price step, one lot.
price = brokapi.round_to_step(best_bid * Decimal("0.9"), params.price_step, brokapi.RoundMode.DOWN)
cid = brokapi.new_cid() # a real bot writes the cid to its journal here, before `place`
ack = await client.place(cid=cid, instrument=sber, side=brokapi.Side.BUY, qty=params.lot_size, price=price)
print(f"placed: broker order id {ack.broker_id}, price {price}")
# 5. Take it off and ask the broker what became of it. The answer of `order_state` is the proof.
await client.cancel(cid)
state = await client.order_state(cid)
print(f"after cancel: {state.state.kind.value}, orders alive: {len(await client.active_orders())}")
# ANCHOR_END: first_order
# ANCHOR: mock
async def on_the_imitation() -> None:
# The block starts the imitation and stops it at the end. `mock.connect()` is `brokapi.connect(...)` to it.
async with await mock_tbank() as mock:
client = await mock.connect()
await first_order(client)
# The price of the imitation stands still: it has no market behind it.
book = await client.order_book(await client.instrument("SBER"), depth=1)
print(f"the price stands still: bid {book.bids[0].price}")
# ANCHOR_END: mock
# ANCHOR: sandbox
async def on_the_sandbox() -> None:
# TBANK_TOKEN is the token of the sandbox. sandbox=True is the address of the sandbox (the real one is the default).
client = await brokapi.connect("tbank", sandbox=True)
try:
await first_order(client)
finally:
await client.close()
# ANCHOR_END: sandbox
if __name__ == "__main__":
asyncio.run(on_the_sandbox() if sys.argv[1:] == ["sandbox"] else on_the_imitation())
Текст программы crates/python/examples/quickstart_bcs.py (боевой счет БКС)
"""Quick start on BCS: read first, then one order far from the market, cancelled at once.
This program is the code of the page "Быстрый старт" of the Python section of the book. The book includes the marked
parts below, so the text and the code that is tested cannot drift apart.
Run it with a real account (BCS has no sandbox, the account is real)::
BCS_REFRESH_TOKEN=... BCS_IS_WRITE=true python quickstart_bcs.py
The tests run `run()` against the imitation of BCS (`brokapi.testing`): no network, no account, no money.
"""
import asyncio
import os
from datetime import datetime, timedelta, timezone
from decimal import Decimal
import brokapi
# The price of the test order is this share of the best bid. Far enough that it does not fill in a normal session,
# near enough that the exchange does not refuse it as being outside the allowed corridor.
FAR_FROM_MARKET = Decimal("0.9")
async def wait_for_order(client: brokapi.Client, cid, kind: brokapi.OrderStateKind) -> None:
"""Reads the events until the order `cid` reaches `kind`, and prints every state it goes through.
An event is a notice. The answer of `order_state` is the proof, so a missing event is not an error here."""
async def read() -> None:
async for envelope in client.events:
order = envelope.payload
if isinstance(order, brokapi.OrderSnapshot) and order.cid == cid:
print(f"event: order {cid} is {order.state.kind.value}")
if order.state.kind is kind:
return
try:
await asyncio.wait_for(read(), 5)
except asyncio.TimeoutError:
print(f"no event {kind.value} within 5 s, the state is checked with order_state below")
async def run(client: brokapi.Client, *, can_trade: bool) -> None:
"""The whole quick start on a connected client."""
# ANCHOR: read
sber = await client.instrument("SBER") # the ticker on the board TQBR
# 1. Portfolio: money and positions of the account.
portfolio = await client.portfolio()
print(f"portfolio: {len(portfolio.balances)} balance(s), {len(portfolio.positions)} position(s)")
for balance in portfolio.balances:
print(f" money {balance.currency}: {balance.available} free, {balance.blocked} blocked")
# 2. Order book: best bid and best ask.
book = await client.order_book(sber, depth=5)
best_bid = book.bids[0].price if book.bids else None
best_ask = book.asks[0].price if book.asks else None
print(f"order book SBER: best bid {best_bid}, best ask {best_ask}")
# 3. Candles: the last hour of five-minute bars.
now = datetime.now(timezone.utc)
bars = await client.candles(sber, brokapi.Timeframe.M5, now - timedelta(hours=1), now)
print(f"candles M5: {len(bars)} bar(s), last close {bars[-1].close if bars else None}")
# ANCHOR_END: read
if not can_trade:
print("the token is read-only (BCS_IS_WRITE is not true): the order step is skipped")
return
# ANCHOR: warning
print("WARNING: BCS has no sandbox. The account is real, the order below goes to the exchange.")
# ANCHOR_END: warning
# ANCHOR: order
# The price: far below the best bid, a multiple of the price step. The quantity: the minimum.
params = await client.trading_params(sber)
reference = best_bid or (bars[-1].close if bars else None)
if reference is None:
raise SystemExit("no price to start from: the book and the candles are empty")
# The rounding is exact: the price is a whole number of steps, no 0.9999999 noise.
price = brokapi.round_to_step(reference * FAR_FROM_MARKET, params.price_step, brokapi.RoundMode.DOWN)
print(f"order: buy {params.min_qty} at {price} (best bid {reference}, price step {params.price_step})")
# The gate is closed until the first reconcile. The ledger of this new bot is empty. Subscribe first, so no
# event between the snapshot of the reconcile and the subscription is lost.
await client.subscribe_order_state()
report = await client.reconcile([])
print(f"reconcile: {len(report.foreign)} foreign order(s) on the account")
# The cid is made by the bot and belongs in its journal before the order is sent.
cid = brokapi.new_cid()
# Here the bot writes `cid` to its journal as "sending".
print(f"cid {cid}")
ack = await client.place(
cid=cid,
instrument=sber,
side=brokapi.Side.BUY,
qty=params.min_qty,
price=price,
)
print(f"accepted: broker order id {ack.broker_id}")
await wait_for_order(client, cid, brokapi.OrderStateKind.ACKED)
# Cancel at once and look at the final state.
await client.cancel(cid)
await wait_for_order(client, cid, brokapi.OrderStateKind.CANCELLED)
last = await client.order_state(cid)
print(f"order_state: {last.state.kind.value}")
# ANCHOR_END: order
async def main() -> None:
# ANCHOR: connect
# BCS_REFRESH_TOKEN and BCS_IS_WRITE come from the environment (a .env file works)
client = await brokapi.connect("bcs")
print("connected: bcs-md and bcs-ud started")
# ANCHOR_END: connect
try:
await run(client, can_trade=os.environ.get("BCS_IS_WRITE") == "true")
finally:
# ANCHOR: stop
await client.close()
print("stopped")
# ANCHOR_END: stop
if __name__ == "__main__":
asyncio.run(main())
Что произошло по шагам на БКС
connectподнял оба коннектора. У БКС, Финама, АЛОРа и Bybit он сразу проходит авторизацию, поэтому неверный токен виден на этом шаге.- Чтение ничего не меняет и не требует сверки.
subscribe_order_stateдал подписку на события своих заявок и вернулся, когда поток открыт. Она сделана до сверки нарочно.reconcile([])открыл шлюз команд. До негоplaceвернул быNotReconciled.placeвернулOrderAck: «брокер принял». Статусы приходят событиями вclient.events, порядок между ответом и событием не гарантирован.cancelснял заявку,closeостановил коннекторы.
Дальше: торговый бот на событиях, стоп-заявки, аварийная остановка. Если вы
пришли с tinkoff-investments: таблица соответствий.
Если вы пришли с tinkoff-investments
Справка для тех, у кого бот уже написан на SDK tinkoff-investments (gRPC API Т-Банка, T-Invest API) и кто переносит его на brokapi. Цель: показать, что в каком
месте меняется, чтобы перенос не превратился в двойную заявку. Левая колонка описывает API Т-Банка, на котором стоит SDK, названия методов сверяйте с версией своего SDK.
Идея одна: brokapi берет на себя то, что в SDK делал ваш код (сверка, лимиты, перевод единиц), и взамен требует порядка, который в SDK был по желанию. Поэтому перенос - это не замена вызовов один к одному.
Таблица соответствий
| Тема | В SDK | В brokapi |
|---|---|---|
| Количество в заявке | лоты: quantity=2 значит два лота | штуки: qty=params.lot_size * 2. Не кратное лоту, дробное, нулевое - Rejected с кодом invalid_qty до отправки |
| Как назвать инструмент | figi (устаревает) или instrument_id (uid), строкой | await client.instrument("SBER") возвращает InstrumentId (ключ SBER@TQBR и uid внутри). Везде передается он, а не строка. У Т-Банка instrument делает один запрос к справочнику |
| Номер заявки | order_id: строка, ключ идемпотентности, вы его придумываете | cid: brokapi.new_cid(), UUID, в журнал на диск до place. Повтор того же cid библиотека не отправит: Rejected с кодом duplicate_place |
| Потоки событий | отдельный поток на рыночные данные, на сделки, на статусы заявок, на портфель, каждый со своей подпиской | один поток client.events: async for envelope in client.events. В конверте source и типизированный payload (стакан, OrderSnapshot, Execution, Reconnected…). Подписки - subscribe_* |
| Сверка после старта и обрыва | своя, как вы напишете | обязательный reconcile(журнал) до первой торговой команды, иначе NotReconciled. После обрыва потока своих заявок (Reconnected), Overflow и ошибки Degraded торговля закрыта до новой сверки. Подписка на статусы - до reconcile |
| Цена и деньги | Quotation(units, nano) и MoneyValue(currency, units, nano), переводы своими функциями | Decimal везде. float отвергается (TypeError). Цена округляется до шага функцией round_to_step |
| Стоп-заявки | post_stop_order, get_stop_orders, cancel_stop_order, количество в лотах | place_stop(kind=, qty=, stop_price=, limit_price=, expiry=), active_stop_orders(), cancel_stop(...), количество в штуках. Событий о срабатывании нет: узнавать только опросом. После рестарта cid стопа Т-Банк не возвращает, снимать надо по broker_id (Стоп-заявки, Python) |
| Ошибки | исключение с кодом gRPC и номером Т-Банка, разбор на вас | иерархия по исходу: Rejected (точно не сделано, поле code), Unknown (мог сделать, не повторять, спросить order_state(cid)), RateLimited (подождать retry_after), Transport (сеть, для чтения можно повторить), Config (настройка). Автоповтора команд нет и не будет (Ошибки) |
| Лимиты запросов | считаете сами, ждете ответа об ошибке | один счетчик на пользователя Т-Банка (BrokerLimiter), команда без квоты не уходит: RateLimited. Несколько клиентов одного брокера делят счетчик через limiter= |
| Счет | account_id в каждом вызове | клиент привязан к одному счету (account_id= в connect). Несколько счетов - несколько клиентов с общим limiter= (Несколько брокеров и счетов) |
| Песочница | свой клиент или адрес песочницы, счет открываете OpenSandboxAccount | connect("tbank", sandbox=True), счет песочницы по-прежнему открываете сами (Т-Банк, где взять токен) |
| Длинная история свечей | постраничные запросы, лимит периода на вас | candle_pages и load_candles режут период на окна по лимитам брокера (История свечей). Один candles на длинный период - Rejected |
| Синхронный клиент | есть синхронный и асинхронный | только asyncio |
Что и как переносить
Два преобразования, без которых не обходится ни один перенос. Лоты в штуки: старый код ставил quantity=lots, новому нужны единицы инструмента.
Quotation в Decimal: пригодится для данных, которые вы хранили в файлах (в самом brokapi цены уже Decimal).
async def place_lots(
client: brokapi.Client, ticker: str, lots: int, price: Decimal, side: brokapi.Side = brokapi.Side.BUY
) -> uuid.UUID:
"""The order of the old code was `quantity=lots`. Here the quantity is in units of the instrument: lots times the lot size."""
instrument = await client.instrument(ticker) # an InstrumentId (the uid inside), not a figi string
params = await client.trading_params(instrument)
cid = brokapi.new_cid() # the old `order_id`: made by the bot, in its journal before the call
await client.place(cid=cid, instrument=instrument, side=side, qty=params.lot_size * lots, price=price)
return cid
def quotation_to_decimal(units: int, nano: int) -> Decimal:
"""`Quotation(units, nano)` and the `units` and `nano` of `MoneyValue` of the old code, for the data you keep in files.
Exact: no float on the way. Negative prices have both parts negative, as in the API of the broker."""
return Decimal(units) + Decimal(nano) / Decimal(1_000_000_000)
Порядок переноса, который не оставляет дыр:
- Подключите клиента к песочнице и прогоните старую логику через
connect("tbank", sandbox=True). Первая ступень вообще без токена: Быстрый старт. - Замените номер заявки:
order_id->cidиз журнала, записанного до вызова. Это главное отличие, на нем держится безопасность повторов. - Добавьте подписку на статусы и
reconcileв начале работы. Бот, у которого в журнале есть живыеcid, передает их вreconcile. - Разберите исходы
placeпо таблице из торгового бота:UnknownиTransportна команде не повторяются. - Если у бота есть стопы, добавьте проверку на старте: Бот со стопом: старт и рестарт.
- Перед боевым счетом - Перед боевым счетом и Аварийная остановка бота.
Если вы пришли с QUIK
brokapi не подключается к QUIK и не заменяет терминал: он говорит с API брокера напрямую (БКС, Т-Банк, Финам, АЛОР, Bybit), и ни терминал, ни запущенный на машине QUIK не нужны.
Скрипты QUIK (Lua, QUIK# и им подобные) живут внутри терминала и реагируют на его события (OnOrder, OnTrade, OnTransReply) и таблицы, заявка там - транзакция с номером
TRANS_ID. В brokapi то же самое делают поток client.events (статусы заявок и сделки), portfolio() с позициями и cid, который бот придумывает сам и пишет в журнал
до place. Переносится логика стратегии, а слой связи с терминалом заменяется целиком. Что изменится по порядку работы, описано в таблице выше: сверка до торговли, единицы в штуках,
Decimal вместо float.
Как написать торгового бота на событиях (Python)
Инструкция. Цель: бот, который читает стакан, по сигналу ставит заявку, следит за ее статусами, выходит из неясного исхода
(Unknown) и аккуратно останавливается. Правила, на которых он стоит, собраны в Контракте для агентов,
причины - в Круге команд и Круге жизни. Код ниже - куски программы trading_bot.py, весь ее
текст в конце страницы. Она запускается тестом против имитации БКС, поэтому текст и проверенный код не расходятся.
Стратегия в примере заглушка: когда пришел стакан и своей заявки нет, купить минимальное количество на 1% ниже лучшей цены покупки.
Все остальное (журнал, сверка, исходы каждой команды, Resync, Overflow, Reconnected, остановка) нужно любому настоящему боту.
Что получится
Один клиент, один поток событий и один цикл. Бот решает по событию, а исходы place разбирает на месте.
flowchart TB BR[Брокер] --> C[Client: рыночные данные и счет] C --> Q[client.events: один поток] Q --> BOT[TradingBot.on_event] BOT -- стакан, заявки нет --> P[place, cid в журнал до вызова] P -- ответ OrderAck --> W[working: ждем событий заявки] P -- Rejected --> D[done: отказ] P -- RateLimited или шлюз закрыт --> N[не отправлено: позже, новый cid] P -- Unknown --> U[order_state по тому же cid] U -- заявка есть --> W U -- брокер не знает --> D2[done: не ставилась]
Из схемы следует: ответ на place и статусы приходят разными путями (результат вызова и события), Unknown разбирается запросом,
а не повтором, и каждая развилка заканчивается записью в журнал.
Журнал
Журнал - память бота. Библиотека не пишет на диск ничего. Каждое изменение записывается в файл и сбрасывается на диск до
вызова, которому оно нужно: журнал, записанный после place, теряет заявку, которую оборвал сбой между отправкой и записью.
Последняя строка по cid главная. Битая последняя строка (сбой посреди записи) пропускается.
class Journal:
"""The memory of the bot. The library writes nothing to disk, so the bot does.
One JSON line per change, flushed to disk BEFORE the call that needs it: a journal written after `place` loses
the order that a crash cuts in between. The last line of a cid wins.
"""
def __init__(self, path: Optional[Path] = None) -> None:
self.path = path
self.orders: dict[uuid.UUID, tuple[str, str]] = {}
if path is not None and path.exists():
for line in path.read_text().splitlines():
try:
row = json.loads(line)
except json.JSONDecodeError:
continue # a line cut by a crash carries no complete change
self.orders[uuid.UUID(row["cid"])] = (row["state"], row.get("detail", ""))
def set(self, cid: uuid.UUID, state: str, detail: str = "") -> None:
"""`state`: sending, working, unknown or done."""
self.orders[cid] = (state, detail)
if self.path is not None:
with self.path.open("a") as file:
file.write(json.dumps({"cid": str(cid), "state": state, "detail": detail}) + "\n")
file.flush()
os.fsync(file.fileno())
def alive(self) -> list[uuid.UUID]:
"""The ledger for `reconcile`: the orders the bot believes are alive."""
return [cid for cid, (state, _) in self.orders.items() if state != "done"]
Старт бота и события
start делает все по порядку: подписка на статусы и исполнения, reconcile со списком из журнала, правила инструмента, подписка
на стакан. Пока start не вернулся, бот не торгует. Подписки идут до сверки: иначе событие между снимком сверки и подпиской
пропало бы.
class TradingBot:
def __init__(self, client: brokapi.Client, ticker: str, journal: Journal, budget: Decimal) -> None:
self.client = client
self.ticker = ticker
self.journal = journal
self.budget = budget # how much to buy in all
self.filled = Decimal(0)
self.working: Optional[uuid.UUID] = None
self.top_bid: Optional[Decimal] = None
self.book_sub: Optional[int] = None
self.need_reconcile = False
self.placed = asyncio.Event() # set when the first order stands: a hook for the tests
@property
def done(self) -> bool:
return self.filled >= self.budget and self.working is None
async def start(self) -> None:
"""In this order: subscriptions, reconcile with the journal, the rules of the instrument, the book.
Until `start` returns, the bot does not trade."""
self.instrument = await self.client.instrument(self.ticker)
await self.client.subscribe_order_state()
await self.client.subscribe_executions()
await self.reconcile()
self.params = await self.client.trading_params(self.instrument)
self.book_sub = await self.client.subscribe_order_book(self.instrument, depth=1)
async def reconcile(self) -> None:
report = await self.client.reconcile(self.journal.alive())
for snapshot in report.confirmed:
self.on_order(snapshot)
for cid in report.closed_offline:
# The broker has no such order and it is not alive: it ended while the bot was away, or was never sent.
self.journal.set(cid, "done", "closed while the bot was away")
self.need_reconcile = False
async def on_event(self, envelope: brokapi.Envelope) -> None:
event = envelope.payload
if isinstance(event, brokapi.OrderBookSnapshot) and envelope.sub == self.book_sub:
self.top_bid = event.bids[0].price if event.bids else None
await self.try_buy()
elif isinstance(event, brokapi.Resync):
self.top_bid = None # the next snapshot replaces the book
elif isinstance(event, brokapi.OrderSnapshot):
self.on_order(event)
elif isinstance(event, brokapi.Overflow):
if event.sub == self.book_sub:
self.top_bid = None # a lost book is not a hole in the orders: take a new subscription
self.book_sub = await self.client.subscribe_order_book(self.instrument, depth=1)
else:
self.need_reconcile = True # the orders may have a hole: read the stream to the end, then reconcile
elif isinstance(event, brokapi.Reconnected):
# The stream of the orders or of the fills was cut and opened again: what happened in the gap never
# reached the bot. The gate is closed until the reconcile, so read the stream to the end, then reconcile.
self.need_reconcile = True
elif isinstance(event, brokapi.UnknownEvent):
# A kind this package does not know. It may be about lost data, like the two above: reconcile.
self.need_reconcile = True
def on_order(self, snapshot: brokapi.OrderSnapshot) -> None:
"""The state of an order comes from the events, not from the answer of `place` (it only means "accepted")."""
if snapshot.cid not in self.journal.orders:
return # an order placed by hand: the bot does not enter it into its journal
if snapshot.state.is_final:
self.journal.set(snapshot.cid, "done", snapshot.state.kind.value)
# The filled quantity is taken from the snapshot, not summed from `Execution` events: after an
# overflow the reconcile returns the right number by itself.
self.filled += snapshot.filled
if self.working == snapshot.cid:
self.working = None
else:
self.journal.set(snapshot.cid, "working")
self.working = snapshot.cid
Что видно в коде:
Resyncобнуляет сохраненный стакан, следующий снимок его заменит.Overflowбывает двух видов. Переполнилась подписка на стакан (ееsubизвестен боту): стакан объявляется недействительным и подписка оформляется заново. Переполнился поток своих заявок: нужна сверка, а торговля до нее закрыта (Degraded).Reconnectedзначит, что поток своих заявок или исполнений оборвался и открыт снова: за обрыв могли пройти события, а торговля закрыта (Degraded) до сверки. Бот дочитывает поток и сверяется (читаетactive_orders,order_state,executionsс запасом раньшеgap_from, зоветreconcile).- Событие неизвестного типа (
UnknownEvent) не ломает цикл. Оно может быть про потерю данных, как два предыдущих, поэтому бот на всякий случай сверяется. - Состояние заявки берется из событий (
on_order), а не из ответа наplace: ответ значит «брокер принял». - В поток статусов попадают все заявки счета, в том числе поставленные руками. Заявок, которых нет в журнале, бот не трогает (Контракт, правило 11).
- Сколько исполнено, бот берет из поля
filledснимков, а не складывает событияExecution: послеOverflowсверка сама вернет правильное число.
Сигнал и заявка со всеми исходами
async def try_buy(self) -> None:
"""The signal, and the order with every outcome handled."""
if self.need_reconcile:
await self.reconcile()
if self.working is not None or self.top_bid is None or self.filled >= self.budget:
return
# Exact arithmetic. Round DOWN for a buy so the price never exceeds the plan, and to the step.
price = brokapi.round_to_step(self.top_bid * BUY_AT, self.params.price_step, brokapi.RoundMode.DOWN)
cid = brokapi.new_cid()
self.journal.set(cid, "sending") # BEFORE the call
try:
await self.client.place(
cid=cid,
instrument=self.instrument,
side=brokapi.Side.BUY,
qty=self.params.min_qty,
price=price,
)
except brokapi.Rejected as error:
self.journal.set(cid, "done", f"rejected: {error.code}") # the broker said no: no order exists
except (brokapi.NotReconciled, brokapi.Degraded):
self.journal.set(cid, "done", "not sent: gate closed")
self.need_reconcile = True
except brokapi.RateLimited as error:
self.journal.set(cid, "done", "not sent: rate limit")
await asyncio.sleep(error.retry_after.total_seconds()) # then the next book tries again with a new cid
except brokapi.Unknown:
# Nobody knows whether the order exists. NEVER send it again: ask the broker.
self.journal.set(cid, "unknown")
await self.resolve_unknown(cid)
else:
self.working = cid
self.journal.set(cid, "working")
self.placed.set()
async def resolve_unknown(self, cid: uuid.UUID) -> None:
"""The way out of `Unknown`: `order_state` by the same cid."""
try:
snapshot = await self.client.order_state(cid)
except brokapi.Rejected as error:
if error.code != "not_found":
raise
# The broker has no such order. A broker can be late: a real bot asks again after a pause, and only then
# writes "not placed". Here it is written at once and the next signal uses a new cid.
self.journal.set(cid, "done", "unknown, not found at the broker")
return
self.on_order(snapshot)
self.placed.set()
Что случилось с place | Что значит | Что делает бот |
|---|---|---|
вернул OrderAck | брокер принял, заявка стоит | запись working, ждет статусов |
Rejected | брокер точно отказал, заявки нет | запись done, новая попытка только с новым cid |
NotReconciled, Degraded | шлюз закрыт, ничего не ушло | пометка «сверить», запись done: не отправлено |
RateLimited | квоты нет, ничего не ушло | пауза на retry_after, потом новый cid |
Unknown | неизвестно, дошла ли | запись unknown, order_state по тому же cid, не повторять |
Цена считается Decimal и округляется вниз до шага (round_to_step): покупка не должна выйти за план, а цена должна лежать на
сетке брокера. Деньги и цены в боте нигде не проходят через float.
Цикл и остановка
Поток событий читает одно место. Если бот делает долгую работу, он выносит ее в отдельную задачу и передает события своей очередью: пока поток не читается, подписки без потерь копят события и при переполнении закрывают торговлю.
async def run(bot: TradingBot) -> None:
"""Starts the bot and reads events until the budget is bought. The one place that reads the stream."""
await bot.start()
try:
async for envelope in bot.client.events:
await bot.on_event(envelope)
if bot.done:
break
finally:
# Orders at the exchange are not cancelled by a stop. This bot leaves none: it stops when its order is done.
if bot.working is not None:
await bot.client.cancel(bot.working)
Остановка клиента не снимает заявок на бирже. Бот из примера не оставляет ни одной: он останавливается, когда его заявка исполнена, а если вышел раньше, снимает ее сам.
Вся программа
Текст программы crates/python/examples/trading_bot.py
"""A trading bot on events: it reads the book, buys once, follows the order and leaves cleanly.
The code of the page "Как написать торгового бота на событиях" of the Python section of the book. The strategy is a
stub: when a book arrives and the bot has no order, buy the minimal quantity 1% below the best bid. Everything else
(the journal, the reconcile, the outcome of every command, `Resync`, `Overflow`, `Reconnected`, the stop) any real bot needs.
The tests run it against the imitation of BCS.
"""
import asyncio
import json
import os
import uuid
from decimal import Decimal
from pathlib import Path
from typing import Optional
import brokapi
BUY_AT = Decimal("0.99")
# ANCHOR: journal
class Journal:
"""The memory of the bot. The library writes nothing to disk, so the bot does.
One JSON line per change, flushed to disk BEFORE the call that needs it: a journal written after `place` loses
the order that a crash cuts in between. The last line of a cid wins.
"""
def __init__(self, path: Optional[Path] = None) -> None:
self.path = path
self.orders: dict[uuid.UUID, tuple[str, str]] = {}
if path is not None and path.exists():
for line in path.read_text().splitlines():
try:
row = json.loads(line)
except json.JSONDecodeError:
continue # a line cut by a crash carries no complete change
self.orders[uuid.UUID(row["cid"])] = (row["state"], row.get("detail", ""))
def set(self, cid: uuid.UUID, state: str, detail: str = "") -> None:
"""`state`: sending, working, unknown or done."""
self.orders[cid] = (state, detail)
if self.path is not None:
with self.path.open("a") as file:
file.write(json.dumps({"cid": str(cid), "state": state, "detail": detail}) + "\n")
file.flush()
os.fsync(file.fileno())
def alive(self) -> list[uuid.UUID]:
"""The ledger for `reconcile`: the orders the bot believes are alive."""
return [cid for cid, (state, _) in self.orders.items() if state != "done"]
# ANCHOR_END: journal
# ANCHOR: bot
class TradingBot:
def __init__(self, client: brokapi.Client, ticker: str, journal: Journal, budget: Decimal) -> None:
self.client = client
self.ticker = ticker
self.journal = journal
self.budget = budget # how much to buy in all
self.filled = Decimal(0)
self.working: Optional[uuid.UUID] = None
self.top_bid: Optional[Decimal] = None
self.book_sub: Optional[int] = None
self.need_reconcile = False
self.placed = asyncio.Event() # set when the first order stands: a hook for the tests
@property
def done(self) -> bool:
return self.filled >= self.budget and self.working is None
async def start(self) -> None:
"""In this order: subscriptions, reconcile with the journal, the rules of the instrument, the book.
Until `start` returns, the bot does not trade."""
self.instrument = await self.client.instrument(self.ticker)
await self.client.subscribe_order_state()
await self.client.subscribe_executions()
await self.reconcile()
self.params = await self.client.trading_params(self.instrument)
self.book_sub = await self.client.subscribe_order_book(self.instrument, depth=1)
async def reconcile(self) -> None:
report = await self.client.reconcile(self.journal.alive())
for snapshot in report.confirmed:
self.on_order(snapshot)
for cid in report.closed_offline:
# The broker has no such order and it is not alive: it ended while the bot was away, or was never sent.
self.journal.set(cid, "done", "closed while the bot was away")
self.need_reconcile = False
async def on_event(self, envelope: brokapi.Envelope) -> None:
event = envelope.payload
if isinstance(event, brokapi.OrderBookSnapshot) and envelope.sub == self.book_sub:
self.top_bid = event.bids[0].price if event.bids else None
await self.try_buy()
elif isinstance(event, brokapi.Resync):
self.top_bid = None # the next snapshot replaces the book
elif isinstance(event, brokapi.OrderSnapshot):
self.on_order(event)
elif isinstance(event, brokapi.Overflow):
if event.sub == self.book_sub:
self.top_bid = None # a lost book is not a hole in the orders: take a new subscription
self.book_sub = await self.client.subscribe_order_book(self.instrument, depth=1)
else:
self.need_reconcile = True # the orders may have a hole: read the stream to the end, then reconcile
elif isinstance(event, brokapi.Reconnected):
# The stream of the orders or of the fills was cut and opened again: what happened in the gap never
# reached the bot. The gate is closed until the reconcile, so read the stream to the end, then reconcile.
self.need_reconcile = True
elif isinstance(event, brokapi.UnknownEvent):
# A kind this package does not know. It may be about lost data, like the two above: reconcile.
self.need_reconcile = True
def on_order(self, snapshot: brokapi.OrderSnapshot) -> None:
"""The state of an order comes from the events, not from the answer of `place` (it only means "accepted")."""
if snapshot.cid not in self.journal.orders:
return # an order placed by hand: the bot does not enter it into its journal
if snapshot.state.is_final:
self.journal.set(snapshot.cid, "done", snapshot.state.kind.value)
# The filled quantity is taken from the snapshot, not summed from `Execution` events: after an
# overflow the reconcile returns the right number by itself.
self.filled += snapshot.filled
if self.working == snapshot.cid:
self.working = None
else:
self.journal.set(snapshot.cid, "working")
self.working = snapshot.cid
# ANCHOR_END: bot
# ANCHOR: place
async def try_buy(self) -> None:
"""The signal, and the order with every outcome handled."""
if self.need_reconcile:
await self.reconcile()
if self.working is not None or self.top_bid is None or self.filled >= self.budget:
return
# Exact arithmetic. Round DOWN for a buy so the price never exceeds the plan, and to the step.
price = brokapi.round_to_step(self.top_bid * BUY_AT, self.params.price_step, brokapi.RoundMode.DOWN)
cid = brokapi.new_cid()
self.journal.set(cid, "sending") # BEFORE the call
try:
await self.client.place(
cid=cid,
instrument=self.instrument,
side=brokapi.Side.BUY,
qty=self.params.min_qty,
price=price,
)
except brokapi.Rejected as error:
self.journal.set(cid, "done", f"rejected: {error.code}") # the broker said no: no order exists
except (brokapi.NotReconciled, brokapi.Degraded):
self.journal.set(cid, "done", "not sent: gate closed")
self.need_reconcile = True
except brokapi.RateLimited as error:
self.journal.set(cid, "done", "not sent: rate limit")
await asyncio.sleep(error.retry_after.total_seconds()) # then the next book tries again with a new cid
except brokapi.Unknown:
# Nobody knows whether the order exists. NEVER send it again: ask the broker.
self.journal.set(cid, "unknown")
await self.resolve_unknown(cid)
else:
self.working = cid
self.journal.set(cid, "working")
self.placed.set()
async def resolve_unknown(self, cid: uuid.UUID) -> None:
"""The way out of `Unknown`: `order_state` by the same cid."""
try:
snapshot = await self.client.order_state(cid)
except brokapi.Rejected as error:
if error.code != "not_found":
raise
# The broker has no such order. A broker can be late: a real bot asks again after a pause, and only then
# writes "not placed". Here it is written at once and the next signal uses a new cid.
self.journal.set(cid, "done", "unknown, not found at the broker")
return
self.on_order(snapshot)
self.placed.set()
# ANCHOR_END: place
# ANCHOR: loop
async def run(bot: TradingBot) -> None:
"""Starts the bot and reads events until the budget is bought. The one place that reads the stream."""
await bot.start()
try:
async for envelope in bot.client.events:
await bot.on_event(envelope)
if bot.done:
break
finally:
# Orders at the exchange are not cancelled by a stop. This bot leaves none: it stops when its order is done.
if bot.working is not None:
await bot.client.cancel(bot.working)
# ANCHOR_END: loop
async def main() -> None:
client = await brokapi.connect("bcs")
try:
bot = TradingBot(client, "SBER", Journal(Path("journal.jsonl")), budget=Decimal(1))
await run(bot)
finally:
await client.close()
if __name__ == "__main__":
asyncio.run(main())
Как проверить
Для этой команды нужен клон репозитория: тесты и примеры в пакет pip install brokapi не входят. Версия пакета должна быть той же, что у клона.
git clone https://github.com/runinfra/brokapi && cd brokapi
pip install brokapi pytest pytest-asyncio
cd crates/python
pytest tests/test_examples.py -k trading_bot
Тесты проверяют: бот покупает один раз и останавливается, когда имитация исполнила заявку. Бот, у которого ответ на place
потерялся (Unknown), спрашивает order_state и не шлет заявку второй раз. Отказ и закрытый шлюз разбираются по таблице выше.
Неизвестное событие включает сверку.
Как собрать инфраструктуру на несколько брокеров и счетов (Python)
Инструкция. Цель: один процесс держит несколько брокеров и несколько счетов, читает события всех одним циклом, ведет один
журнал заявок в файле и после рестарта сверяет каждый счет с этим журналом. Правила: Контракт для агентов,
логика счетов и лимитов: Несколько счетов и брокеров. Код ниже - куски программы infrastructure.py.
Она запускается тестом против имитаций брокеров: две жизни процесса, между ними одна заявка исполняется.
В примере несколько счетов: БКС (один), Т-Банк (два, один токен), Финам (один) и АЛОР (один).
Что получится
Один клиент на счет. Клиенты одного брокера делят счетчик лимитов, клиенты разных брокеров нет.
flowchart TB
subgraph B[БКС: счетчик лимитов токена]
A1[клиент bcs]
end
subgraph T[Т-Банк: один счетчик на двоих]
A2[клиент tbank-a]
A3[клиент tbank-b]
end
subgraph F[Финам: счетчик пользователя]
A4[клиент finam]
end
subgraph L[АЛОР: счетчик логина]
A5[клиент alor]
end
B --> M[brokapi.merge: один цикл событий]
T --> M
F --> M
L --> M
M --> J[(журнал заявок в файле)]
Из схемы следует: брокер считает лимит на токен или пользователя, поэтому два клиента одного токена обязаны делить счетчик
(limiter=), а чужого брокера счетчик не касается. События всех клиентов идут в один цикл, и поле source говорит, чье это событие.
Журнал заявок в файле
Текстовый файл, строка на изменение: счет, cid, состояние, подробность. Последняя строка по cid главная. Каждая строка
сбрасывается на диск до вызова, которому нужна: строка «отправляю» пишется до place, иначе сбой между отправкой и записью
теряет заявку.
class Journal:
"""The order journal: a text file, one line per change, `account TAB cid TAB state TAB detail`. The last line of
a cid wins. Every line is flushed to disk before the call that needs it."""
def __init__(self, path: Path) -> None:
self.path = path
self.orders: dict[uuid.UUID, tuple[str, str]] = {} # cid -> (account, state)
if path.exists():
for line in path.read_text().splitlines():
parts = line.split("\t", 3)
if len(parts) < 3:
continue # a line cut by a crash carries no complete change
try:
self.orders[uuid.UUID(parts[1])] = (parts[0], parts[2])
except ValueError:
continue
def set(self, account: str, cid: uuid.UUID, state: str, detail: str = "") -> None:
"""`state`: sending (written BEFORE place), working, or done."""
self.orders[cid] = (account, state)
with self.path.open("a") as file:
file.write(f"{account}\t{cid}\t{state}\t{detail}\n")
file.flush()
os.fsync(file.fileno())
def live(self, account: str) -> list[uuid.UUID]:
"""The ledger of one account for `reconcile`: the orders it believes are alive."""
return [c for c, (a, s) in self.orders.items() if a == account and s != "done"]
Подключение, подписки, сверка
Подключение идет по порядку: клиенты, подписки на статусы и исполнения каждого счета, и только потом сверка каждого счета со
своей частью журнала. Второй счет Т-Банка берет счетчик первого: limiter=clients["tbank-a"].limiter. Передать счетчик
другого брокера нельзя: connect вернет Config. У каждого клиента свое имя (name=), оно попадает в source событий.
Сверка не сдается на первой же заминке: RateLimited и Transport значат «ответа пока нет», поэтому сверка ждет и спрашивает
снова, несколько раз. Любая другая ошибка поднимается, повтор дал бы то же самое.
class Infra:
"""All the clients of the process and the journal."""
def __init__(self, clients: dict[str, brokapi.Client], journal: Journal) -> None:
self.clients = clients
self.journal = journal
self.degraded: set[str] = set() # accounts that lost events (Overflow, Reconnected): reconcile them before trading
@classmethod
async def start(cls, settings: dict[str, dict], journal_path: Path) -> "Infra":
"""Connects everything, subscribes, reconciles every account with the journal, and only then returns."""
clients: dict[str, brokapi.Client] = {}
# One limiter per area where the broker counts a limit. T-Bank counts per user: both accounts share one.
# BCS counts per token, Finam per user, ALOR per login. The brokers never share.
clients["bcs"] = await brokapi.connect("bcs", name="bcs", **settings.get("bcs", {}))
clients["tbank-a"] = await brokapi.connect("tbank", name="tbank-a", **settings.get("tbank-a", {}))
clients["tbank-b"] = await brokapi.connect(
"tbank", name="tbank-b", limiter=clients["tbank-a"].limiter, **settings.get("tbank-b", {})
)
clients["finam"] = await brokapi.connect("finam", name="finam", **settings.get("finam", {}))
clients["alor"] = await brokapi.connect("alor", name="alor", **settings.get("alor", {}))
infra = cls(clients, Journal(journal_path))
# Subscribe to the statuses and fills of every account FIRST, then reconcile it with ITS part of the journal.
for client in clients.values():
await client.subscribe_order_state()
await client.subscribe_executions()
for name in ACCOUNTS:
await infra.reconcile(name)
return infra
async def reconcile(self, name: str) -> brokapi.ReconcileReport:
"""A reconcile that does not give up on the first hiccup: a limit or a network error means "no sealed
answer yet", so wait and ask again, a few times. Any other error is raised: a retry would fail the same way."""
pause = 0.2
for attempt in range(1, 6):
try:
report = await self.clients[name].reconcile(self.journal.live(name))
except brokapi.RateLimited as error:
if attempt == 5:
raise
await asyncio.sleep(error.retry_after.total_seconds())
except brokapi.Transport:
if attempt == 5:
raise
await asyncio.sleep(pause)
pause *= 2
else:
for snapshot in report.confirmed:
self.journal.set(name, snapshot.cid, "working")
for cid in report.closed_offline:
self.journal.set(name, cid, "done", "closed while away or never sent")
self.degraded.discard(name)
return report
raise AssertionError("unreachable")
async def place(
self, name: str, instrument: brokapi.InstrumentId, qty: Decimal, price: Decimal
) -> tuple[uuid.UUID, Optional[brokapi.BrokapiError]]:
"""One order on one account. The journal line goes first; the outcome is written after."""
cid = brokapi.new_cid()
self.journal.set(name, cid, "sending")
try:
await self.clients[name].place(
cid=cid, instrument=instrument, side=brokapi.Side.BUY, qty=qty, price=price
)
except brokapi.Unknown as error:
return cid, error # the journal says "sending": the next reconcile or order_state settles it
except brokapi.BrokapiError as error:
self.journal.set(name, cid, "done", f"not placed: {error}") # nothing was sent, or the broker refused
return cid, error
self.journal.set(name, cid, "working")
return cid, None
async def close(self) -> None:
for client in self.clients.values():
await client.close()
place пишет строку в журнал до вызова. Если ответ потерялся (Unknown), строка остается «отправляю», а решает ее следующая сверка
или order_state. Отказ и ошибка до отправки закрывают строку сразу: заявки нет.
Один цикл событий
brokapi.merge читает потоки всех клиентов как один. Заявка ушла в конец (исполнена, снята) - строка журнала закрывается.
Overflow и Reconnected (поток заявок оборвался и открыт снова) помечают счет: поток надо дочитать, потом сверить именно этот счет.
async def watch(infra: Infra) -> None:
"""One loop over the events of all the accounts. `source` says whose each event is."""
async for envelope in brokapi.merge(*infra.clients.values()):
event = envelope.payload
name = envelope.source.removesuffix("-ud").removesuffix("-md")
if isinstance(event, brokapi.OrderSnapshot) and event.state.is_final:
infra.journal.set(name, event.cid, "done", event.state.kind.value)
elif isinstance(event, (brokapi.Overflow, brokapi.Reconnected)):
# Events were lost (a full buffer, or a stream of the orders that was cut and opened again). Read the
# stream to the end, then reconcile. The loop does both: it reads, and marks the account.
infra.degraded.add(name)
merge кончается, когда закрыты все клиенты. Не бросайте чтение, пока работаете с этими клиентами: событие, которое цикл только
что достал, при выходе теряется.
Рестарт
Что делает тест, это и есть сценарий. Первая жизнь: на каждом из четырех счетов ставится заявка, плюс в журнале строка для заявки,
которая так и не ушла. Процесс уходит, не снимая заявок. Пока его нет, одна заявка исполняется. Вторая жизнь: подключение со
сверкой. Заявка, которой не было у брокера, закрывается как «не уходила», остальные подтверждаются, исполненная находится через
order_state.
Чего библиотека не делает за бота: журнал, решение, что делать с чужими заявками (foreign), поиск итога по заявкам из
closed_offline.
Вся программа
Текст программы crates/python/examples/infrastructure.py
"""One process, several brokers and accounts: one event stream, one order journal in a file, and a restart that
reconciles every account against that journal.
The code of the page "Как собрать инфраструктуру на несколько брокеров и счетов" of the Python section of the book.
The accounts are BCS (one), T-Bank (two, one token), Finam (one) and ALOR (one). The tests play two lives of the process against
the imitations. Nothing here touches the network or money.
"""
import asyncio
import os
import uuid
from decimal import Decimal
from pathlib import Path
from typing import Optional
import brokapi
ACCOUNTS = ["bcs", "tbank-a", "tbank-b", "finam", "alor"]
# ANCHOR: journal
class Journal:
"""The order journal: a text file, one line per change, `account TAB cid TAB state TAB detail`. The last line of
a cid wins. Every line is flushed to disk before the call that needs it."""
def __init__(self, path: Path) -> None:
self.path = path
self.orders: dict[uuid.UUID, tuple[str, str]] = {} # cid -> (account, state)
if path.exists():
for line in path.read_text().splitlines():
parts = line.split("\t", 3)
if len(parts) < 3:
continue # a line cut by a crash carries no complete change
try:
self.orders[uuid.UUID(parts[1])] = (parts[0], parts[2])
except ValueError:
continue
def set(self, account: str, cid: uuid.UUID, state: str, detail: str = "") -> None:
"""`state`: sending (written BEFORE place), working, or done."""
self.orders[cid] = (account, state)
with self.path.open("a") as file:
file.write(f"{account}\t{cid}\t{state}\t{detail}\n")
file.flush()
os.fsync(file.fileno())
def live(self, account: str) -> list[uuid.UUID]:
"""The ledger of one account for `reconcile`: the orders it believes are alive."""
return [c for c, (a, s) in self.orders.items() if a == account and s != "done"]
# ANCHOR_END: journal
# ANCHOR: infra
class Infra:
"""All the clients of the process and the journal."""
def __init__(self, clients: dict[str, brokapi.Client], journal: Journal) -> None:
self.clients = clients
self.journal = journal
self.degraded: set[str] = set() # accounts that lost events (Overflow, Reconnected): reconcile them before trading
@classmethod
async def start(cls, settings: dict[str, dict], journal_path: Path) -> "Infra":
"""Connects everything, subscribes, reconciles every account with the journal, and only then returns."""
clients: dict[str, brokapi.Client] = {}
# One limiter per area where the broker counts a limit. T-Bank counts per user: both accounts share one.
# BCS counts per token, Finam per user, ALOR per login. The brokers never share.
clients["bcs"] = await brokapi.connect("bcs", name="bcs", **settings.get("bcs", {}))
clients["tbank-a"] = await brokapi.connect("tbank", name="tbank-a", **settings.get("tbank-a", {}))
clients["tbank-b"] = await brokapi.connect(
"tbank", name="tbank-b", limiter=clients["tbank-a"].limiter, **settings.get("tbank-b", {})
)
clients["finam"] = await brokapi.connect("finam", name="finam", **settings.get("finam", {}))
clients["alor"] = await brokapi.connect("alor", name="alor", **settings.get("alor", {}))
infra = cls(clients, Journal(journal_path))
# Subscribe to the statuses and fills of every account FIRST, then reconcile it with ITS part of the journal.
for client in clients.values():
await client.subscribe_order_state()
await client.subscribe_executions()
for name in ACCOUNTS:
await infra.reconcile(name)
return infra
async def reconcile(self, name: str) -> brokapi.ReconcileReport:
"""A reconcile that does not give up on the first hiccup: a limit or a network error means "no sealed
answer yet", so wait and ask again, a few times. Any other error is raised: a retry would fail the same way."""
pause = 0.2
for attempt in range(1, 6):
try:
report = await self.clients[name].reconcile(self.journal.live(name))
except brokapi.RateLimited as error:
if attempt == 5:
raise
await asyncio.sleep(error.retry_after.total_seconds())
except brokapi.Transport:
if attempt == 5:
raise
await asyncio.sleep(pause)
pause *= 2
else:
for snapshot in report.confirmed:
self.journal.set(name, snapshot.cid, "working")
for cid in report.closed_offline:
self.journal.set(name, cid, "done", "closed while away or never sent")
self.degraded.discard(name)
return report
raise AssertionError("unreachable")
async def place(
self, name: str, instrument: brokapi.InstrumentId, qty: Decimal, price: Decimal
) -> tuple[uuid.UUID, Optional[brokapi.BrokapiError]]:
"""One order on one account. The journal line goes first; the outcome is written after."""
cid = brokapi.new_cid()
self.journal.set(name, cid, "sending")
try:
await self.clients[name].place(
cid=cid, instrument=instrument, side=brokapi.Side.BUY, qty=qty, price=price
)
except brokapi.Unknown as error:
return cid, error # the journal says "sending": the next reconcile or order_state settles it
except brokapi.BrokapiError as error:
self.journal.set(name, cid, "done", f"not placed: {error}") # nothing was sent, or the broker refused
return cid, error
self.journal.set(name, cid, "working")
return cid, None
async def close(self) -> None:
for client in self.clients.values():
await client.close()
# ANCHOR_END: infra
# ANCHOR: events
async def watch(infra: Infra) -> None:
"""One loop over the events of all the accounts. `source` says whose each event is."""
async for envelope in brokapi.merge(*infra.clients.values()):
event = envelope.payload
name = envelope.source.removesuffix("-ud").removesuffix("-md")
if isinstance(event, brokapi.OrderSnapshot) and event.state.is_final:
infra.journal.set(name, event.cid, "done", event.state.kind.value)
elif isinstance(event, (brokapi.Overflow, brokapi.Reconnected)):
# Events were lost (a full buffer, or a stream of the orders that was cut and opened again). Read the
# stream to the end, then reconcile. The loop does both: it reads, and marks the account.
infra.degraded.add(name)
# ANCHOR_END: events
async def main() -> None:
# Every setting comes from the environment: BCS_REFRESH_TOKEN, BCS_IS_WRITE, TBANK_TOKEN, FINAM_SECRET,
# ALOR_REFRESH_TOKEN, ALOR_PORTFOLIO, ...
# The two T-Bank accounts differ in account_id.
settings = {
"tbank-a": {"account_id": os.environ["TBANK_ACCOUNT_ID"]},
"tbank-b": {"account_id": os.environ["TBANK_ACCOUNT_ID_2"]},
}
infra = await Infra.start(settings, Path("orders.journal"))
try:
await watch(infra)
finally:
await infra.close()
if __name__ == "__main__":
asyncio.run(main())
Как проверить
Для этой команды нужен клон репозитория (git clone https://github.com/runinfra/brokapi, затем pip install brokapi pytest pytest-asyncio): тесты и примеры в пакет pip install brokapi не входят.
cd crates/python
pytest tests/test_examples.py -k infrastructure
Один токен в двух процессах делит одну квоту брокера, а счетчик лимитов у каждого процесса свой.
Как скачать историю свечей
Инструкция. Цель: выгрузить длинную историю свечей по нескольким бумагам в файлы, не нарушая лимитов брокера, и после сбоя
продолжить с того места, где остановились. Код - куски программы history.py, она запускается тестом против имитации БКС.
Как библиотека читает длинную историю
Один запрос свечей брокер принимает лишь в пределах: у БКС не больше 1440 баров, у Т-Банка предел зависит от таймфрейма, у Финама
он осторожный, АЛОР предела не называет (вся история одним запросом, если не задать окно). Год часовых баров поэтому не один запрос, а несколько, и каждый тратит квоту. candle_pages режет период на окна
по пределу брокера и читает их по одному на каждом шаге async for:
- на
RateLimitedждетretry_afterи читает окно снова - на
Transportчитает окно еще раз с растущей паузой (чтение безопасно повторять, команды нет) - на любой другой отказ останавливается: тот же запрос даст то же
- склеивает окна без дублей: бар, который вернули оба соседних окна, остается один раз
- страница бывает пустой (выходные, торги стояли). Это не конец, конец -
StopAsyncIteration
async for page in client.candle_pages(instrument, brokapi.Timeframe.H1, start, end):
... # page: список Candle, по порядку времени
Простой client.candles(...) читает один запрос, и если период длиннее предела, это Rejected. Для нескольких тысяч баров есть
load_candles, он собирает все в память одним вызовом, а для больше пишите по страницам.
Файл сам себе контрольная точка
Один файл на бумагу: текст, строка на бар (время, открытие, максимум, минимум, закрытие, объем). Продолжение идет с конца файла:
отдельного состояния нет, поэтому оно не может разойтись с данными. Сбой посреди записи оставляет оборванную строку, ее отрезают
перед продолжением, иначе следующая страница приклеится к ней.
def last_bar(path: Path) -> datetime | None:
"""The open time of the last COMPLETE line of the file, or None. A line cut by a crash is ignored."""
if not path.exists():
return None
last = None
for line in path.read_text().splitlines():
parts = line.split(",")
if len(parts) == 6:
last = datetime.fromisoformat(parts[0])
return last
def cut_broken_tail(path: Path) -> None:
"""A crash can leave a half line at the end. Cut it off, or the next page would be glued to it."""
if path.exists():
text = path.read_text()
keep = text[: text.rfind("\n") + 1]
if keep != text:
path.write_text(keep)
def write_page(path: Path, bars: list[brokapi.Candle]) -> None:
"""A page goes to the file with one write and is flushed before the next page is asked for."""
text = "".join(
f"{b.open_ts.isoformat()},{b.open},{b.high},{b.low},{b.close},{b.volume}\n" for b in bars
)
with path.open("a") as file:
file.write(text)
file.flush()
Выгрузка
async def download(
client: brokapi.Client,
ticker: str,
timeframe: brokapi.Timeframe,
start: datetime,
end: datetime,
folder: Path,
*,
attempts: int = 5,
) -> int:
"""Downloads [start, end) into `<folder>/<ticker>.csv` and returns the bars written in this run."""
instrument = await client.instrument(ticker)
path = folder / f"{ticker}.csv"
written = 0
stuck = 0
while True:
cut_broken_tail(path)
# Go on after the last bar in the file (nothing is read twice), or from `start` for a new file.
done = last_bar(path)
begin = start if done is None else done + timedelta(seconds=timeframe.seconds)
if begin >= end:
return written
pages = client.candle_pages(instrument, timeframe, begin, end)
try:
async for page in pages:
write_page(path, page)
written += len(page)
stuck = 0
return written
except brokapi.BrokapiError as error:
if error.error_class is not brokapi.ErrorClass.RETRYABLE:
raise # the same read fails again: check the instrument, the timeframe and the period
stuck += 1
if stuck >= attempts:
raise
# RateLimited or Transport after the reader's own patience: wait and go on from the end of the file.
wait = error.retry_after.total_seconds() if isinstance(error, brokapi.RateLimited) else 0.5
await asyncio.sleep(wait)
Ошибки разбираются по классу (error.error_class):
| Класс | Примеры | Что делает выгрузка |
|---|---|---|
RETRYABLE | RateLimited после всех ожиданий, Transport после повторов | ждет и продолжает с конца файла, до пяти попыток подряд без продвижения |
FATAL | Rejected, Config, Unsupported | та же ошибка повторится: поднимается наверх, надо проверить бумагу, таймфрейм и период |
Любое чтение тратит квоту, не только свечи: instrument у Т-Банка, last_trades, подписка. Любое может вернуть RateLimited.
Если выгружаете несколько бумаг, делайте это по очереди на один клиент: у брокера один счетчик лимитов на все.
Вся программа
Текст программы crates/python/examples/history.py
"""A long candle history into files, within the rate limit, that goes on where it stopped.
The code of the page "Как скачать историю свечей" of the Python section of the book. One file per instrument: text,
one line per bar. The file is its own checkpoint: where it ends, the download goes on. The tests run it against the
imitations.
"""
import asyncio
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from pathlib import Path
import brokapi
# ANCHOR: files
def last_bar(path: Path) -> datetime | None:
"""The open time of the last COMPLETE line of the file, or None. A line cut by a crash is ignored."""
if not path.exists():
return None
last = None
for line in path.read_text().splitlines():
parts = line.split(",")
if len(parts) == 6:
last = datetime.fromisoformat(parts[0])
return last
def cut_broken_tail(path: Path) -> None:
"""A crash can leave a half line at the end. Cut it off, or the next page would be glued to it."""
if path.exists():
text = path.read_text()
keep = text[: text.rfind("\n") + 1]
if keep != text:
path.write_text(keep)
def write_page(path: Path, bars: list[brokapi.Candle]) -> None:
"""A page goes to the file with one write and is flushed before the next page is asked for."""
text = "".join(
f"{b.open_ts.isoformat()},{b.open},{b.high},{b.low},{b.close},{b.volume}\n" for b in bars
)
with path.open("a") as file:
file.write(text)
file.flush()
# ANCHOR_END: files
# ANCHOR: download
async def download(
client: brokapi.Client,
ticker: str,
timeframe: brokapi.Timeframe,
start: datetime,
end: datetime,
folder: Path,
*,
attempts: int = 5,
) -> int:
"""Downloads [start, end) into `<folder>/<ticker>.csv` and returns the bars written in this run."""
instrument = await client.instrument(ticker)
path = folder / f"{ticker}.csv"
written = 0
stuck = 0
while True:
cut_broken_tail(path)
# Go on after the last bar in the file (nothing is read twice), or from `start` for a new file.
done = last_bar(path)
begin = start if done is None else done + timedelta(seconds=timeframe.seconds)
if begin >= end:
return written
pages = client.candle_pages(instrument, timeframe, begin, end)
try:
async for page in pages:
write_page(path, page)
written += len(page)
stuck = 0
return written
except brokapi.BrokapiError as error:
if error.error_class is not brokapi.ErrorClass.RETRYABLE:
raise # the same read fails again: check the instrument, the timeframe and the period
stuck += 1
if stuck >= attempts:
raise
# RateLimited or Transport after the reader's own patience: wait and go on from the end of the file.
wait = error.retry_after.total_seconds() if isinstance(error, brokapi.RateLimited) else 0.5
await asyncio.sleep(wait)
# ANCHOR_END: download
async def main() -> None:
client = await brokapi.connect("bcs")
try:
end = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0)
folder = Path("bars")
folder.mkdir(exist_ok=True)
for ticker in ("SBER", "GAZP"):
count = await download(client, ticker, brokapi.Timeframe.H1, end - timedelta(days=365), end, folder)
print(f"{ticker}: {count} bars")
finally:
await client.close()
if __name__ == "__main__":
asyncio.run(main())
Как проверить
Для этой команды нужен клон репозитория (git clone https://github.com/runinfra/brokapi, затем pip install brokapi pytest pytest-asyncio): тесты и примеры в пакет pip install brokapi не входят.
cd crates/python
pytest tests/test_examples.py -k history
Тест выгружает 100 дней часовых баров (2400 штук, БКС отдает 1440 за запрос), проверяет, что баров ровно 2400 без пропусков и дублей, что второй запуск ничего не просит у брокера, и что после обрезанного файла с битой строкой выгрузка дописывает ровно недостающее. Пределы брокеров взяты из их документации.
Стоп-заявки, Python
Инструкция. Цель: поставить стоп-заявку, посмотреть, что она ждет, найти ее после потерянного ответа и снять. Что такое стоп-заявка,
чем различаются брокеры и почему так, написано в общей части: Стоп-заявки (примеры там на Rust, таблицы и
правила одни и те же). Здесь то, что зависит от Python. Код ниже - куски программы stops.py, она запускается тестом против имитаций
всех брокеров.
Стоп-заявка лежит у брокера и ждет цены. Пока цена не дошла до заданной, на бирже ее нет. Когда дошла, брокер выставляет обычную
заявку, рыночную или лимитную. Она есть у всех брокеров: (await client.caps()).ud.supports_stop_orders.
Методы
| Метод | Что делает |
|---|---|
await client.place_stop(cid=, instrument=, side=, kind=, qty=, stop_price=, limit_price=None, expiry=None) | ставит стоп-заявку, возвращает OrderAck с номером брокера |
await client.cancel_stop(stop) | снимает не сработавшую. stop - StopOrderRef.cid(cid) (голый uuid.UUID значит то же) или StopOrderRef.broker(номер) |
await client.active_stop_orders() | список StopOrderSnapshot: стоп-заявки, которые ждут срабатывания |
await client.subscribe_stop_orders() | поток статусов стопов. У всех брокеров Unsupported (capability == "stop_order_stream") |
Виды (StopOrderKind): STOP_LOSS (защита позиции), TAKE_PROFIT (фиксация прибыли), STOP_LIMIT (как стоп-лосс, но после срабатывания
всегда лимитная заявка, limit_price обязателен). Срок (StopExpiry): good_till_cancel(), end_of_day(), good_till_date(момент).
Без expiry стоп живет до отмены. Цены и количество - Decimal, float отвергается, как везде. Количество в штуках инструмента, не в лотах.
Поставить
async def protect(
client: brokapi.Client, instrument: brokapi.InstrumentId, stop_price: Decimal
) -> tuple[uuid.UUID, brokapi.OrderAck]:
"""Puts a stop-loss that sells one lot if the price falls to `stop_price`, at market."""
params = await client.trading_params(instrument) # the unit of the quantity is the instrument, not the lot
cid = brokapi.new_cid() # made by the bot, and written to its journal BEFORE the call, as for any order
ack = await client.place_stop(
cid=cid,
instrument=instrument,
side=brokapi.Side.SELL,
kind=brokapi.StopOrderKind.STOP_LOSS,
qty=params.lot_size,
stop_price=stop_price,
# limit_price=None: a market order at the trigger. expiry=None: until cancelled.
)
# Keep ack.broker_id too: after a restart T-Bank cannot find a stop by its cid, only by the id of the broker.
return cid, ack
Правила те же, что у обычной заявки. cid придумывает бот и записывает в свой журнал до вызова. Он уходит не больше одного раза:
повтор с тем же cid вернет Rejected с кодом duplicate_place, второго стопа у брокера не появится. Стопы стоят за тем же шлюзом:
до reconcile будет NotReconciled. Проверки без сети (нет цены у стоп-лимита, срок, которого нет у брокера, количество не кратно лоту)
библиотека делает сама до отправки, они не тратят квоту и дают Rejected.
Что ждет и что сработало
async def waiting(client: brokapi.Client) -> list[brokapi.StopOrderSnapshot]:
"""The stops that have not triggered yet. This list is the only way to learn that one has: there is no event."""
return [stop for stop in await client.active_stop_orders() if stop.state.kind is brokapi.StopOrderStateKind.ACTIVE]
reconcile и active_orders стопов не показывают, у них свой список. О срабатывании узнать можно только опросом: события для стопов нет
(потока статусов не дает ни один брокер, библиотека сама не опрашивает). Когда стоп сработал, брокер ставит обычную заявку, и ее ведет обычный
поток заявок. Связь между стопом и порожденной заявкой библиотека не строит.
Потерянный ответ
Unknown у place_stop значит то же, что у place: стоп мог дойти до брокера. Повторять с тем же cid нельзя, ставить новый стоп наугад тоже:
выйдет два. Смотрите в список. Есть три исхода, их возвращает protect_once:
- Нашелся стоп с вашим
cid(Placement.stop). Он ваш, ничего больше не делайте. БКС, Финам, АЛОР и Bybit возвращаютcidсами, Т-Банк - нет, его коннектор узнает стоп по тому, что вы просили, и по времени (подробности в Т-Банк). - Нашлись стопы без
cidна той же бумаге и в ту же сторону (Placement.unclear). Один из них может быть вашим, сказать наверняка нельзя: у Т-Банка так выглядит и стоп прошлого запуска, и стоп, поставленный вручную. Второй стоп не ставьте. Посмотрите их сами и, если один ваш, дальше работайте с ним поbroker_id. - Ничего нет (оба поля пустые). Стопа у брокера нет, можно вызвать
protect_onceеще раз: он возьмет новыйcid. Рецепт сначала ждет несколько секунд (wait) и смотрит второй раз, потому что медленный брокер принимает стоп с опозданием.
@dataclass(frozen=True)
class Placement:
"""What is known about the stop after `protect_once`. At most one of the two fields is filled."""
stop: brokapi.StopOrderSnapshot | None = None
"""Our stop is at the broker, found by its `cid`."""
unclear: tuple[brokapi.StopOrderSnapshot, ...] = ()
"""Stops without a `cid` on the same instrument and side. One of them may be ours, there is no way to tell. Do not
place another one: a person looks, or the bot takes one of them by its `broker_id` if it knows it is its own."""
# Both empty: there is no stop at the broker. It is safe to call `protect_once` again (it makes a NEW cid).
async def protect_once(
client: brokapi.Client,
instrument: brokapi.InstrumentId,
stop_price: Decimal,
wait: float = 5.0,
*,
qty: Decimal | None = None,
) -> Placement:
"""Puts the stop; when the answer is lost, looks for it at the broker instead of putting it again.
`qty` is in units of the instrument; without it the stop covers one lot."""
cid = brokapi.new_cid()
side = brokapi.Side.SELL
try:
await client.place_stop(
cid=cid,
instrument=instrument,
side=side,
kind=brokapi.StopOrderKind.STOP_LOSS,
qty=qty if qty is not None else (await client.trading_params(instrument)).lot_size,
stop_price=stop_price,
)
except brokapi.Rejected:
return Placement() # the broker said no: there is no stop
except (brokapi.Unknown, brokapi.Transport):
pass # it may be there. NEVER place again under this cid: look.
async def look() -> Placement:
stops = await client.active_stop_orders()
ours = next((stop for stop in stops if stop.cid == cid), None)
if ours is not None:
return Placement(stop=ours)
unclear = (
stop
for stop in stops
if stop.cid is None and stop.instrument.key == instrument.key and stop.side is side
)
return Placement(unclear=tuple(unclear))
found = await look()
if found.stop is None and not found.unclear:
await asyncio.sleep(wait) # a slow broker takes the stop late: look once more before saying "nothing"
found = await look()
return found
Как проверить свой код на этом сбое: mock.lose_stop_answers() у имитации любого брокера. Брокер принимает стоп, а ответ теряется.
Бот со стопом: старт и рестарт
Бот держит позицию под защитой стопа. Бот упал или его перезапустили, пока позиция была открыта, и теперь неизвестно, стоит ли стоп: его могли не успеть
поставить, ответ на place_stop мог потеряться, стоп мог сработать или истечь. Позиция без стопа - самое опасное, что остается на счете после рестарта. Поэтому на каждом
старте, первом и после рестарта, бот сначала проверяет защиту и только потом торгует:
- Подписки и
reconcile(журнал): стопы стоят за тем же шлюзом, что и заявки. positions()и список ждущих стопов (waiting). Позиции чужие тоже видны (купленные руками), поэтому бот работает только с теми, для которых знает цену стопа.- По каждой своей длинной позиции складываются количества стопов на продажу по этому инструменту. Хватает - позиция защищена, второй стоп не ставят. Нет ни одного - ставят стоп на всю позицию. Есть, но на часть - решает человек: остаток бот сам не ставит.
- Стоп ставит
protect_once. Если ответ наplace_stopпотерялся (Unknown), стоп ищут вactive_stop_orders, а не ставят заново: как в разделе «Потерянный ответ» выше. Сверка стопов идет по инструменту и стороне, а не поcid: после рестарта Т-Банкcidстопа не возвращает. - Позиции, которые остались без стопа (брокер отказал, стоп не нашелся), функция возвращает списком. Для них бот не открывает новых сделок и сообщает человеку.
async def protect_on_start(
client: brokapi.Client,
ledger: Sequence[uuid.UUID],
stop_price_of: Callable[[brokapi.Position], Decimal | None],
) -> list[brokapi.Position]:
"""Called once, right after the subscriptions, on every start of the bot (the first one and every restart).
`stop_price_of(position)` is the stop price the bot wants for its own position, or None for a position that is not
the bot's (the account may hold others, bought by hand): those are left alone.
Returns the positions that are NOT protected after it. For them the bot does not trade and tells a person."""
await client.reconcile(ledger) # the gate of the commands opens; stop orders are behind it too
unprotected = []
stops = await waiting(client) # the stops of every earlier run, whoever put them
for position in await client.positions():
stop_price = stop_price_of(position)
if position.qty <= 0 or stop_price is None:
continue # nothing to protect, or not ours (a short position needs a stop on the other side: not in this recipe)
covered = sum(
stop.qty
for stop in stops
if stop.instrument.key == position.instrument.key and stop.side is brokapi.Side.SELL
)
if covered >= position.qty:
continue # the stop of the earlier run is there: do not put a second one
if covered > 0:
unprotected.append(position) # a stop for a part: a person decides what to do with the rest
continue
# No stop at all. `protect_once` puts it for the whole position. At `Unknown` it looks for the stop at the
# broker (active_stop_orders) and never sends the same cid again.
placed = await protect_once(client, position.instrument, stop_price, qty=position.qty)
if placed.stop is None:
unprotected.append(position) # refused, or the stop is not found, or there is one without a cid to look at
return unprotected
Цену стопа знает бот: он берет ее из своего журнала (цена входа и правило), поэтому она приходит параметром stop_price_of. Для позиции, которая не бота, функция
возвращает None, и такую позицию рецепт не трогает. О срабатывании стопа события нет: если позиции или стопа нет на старте, это не ошибка, а возможный
результат срабатывания, и бот проверяет это по positions() и по порожденной заявке. Короткие позиции (стоп на покупку) в рецепт не входят.
Снять
async def release(client: brokapi.Client, stop: brokapi.StopOrderSnapshot) -> None:
"""Takes the stop off, by the id of the broker: that works after a restart for every broker."""
await client.cancel_stop(brokapi.StopOrderRef.broker(stop.broker_id))
Снимать надежнее по номеру брокера (OrderAck.broker_id, StopOrderSnapshot.broker_id): его знают все брокеры и после перезапуска.
StopOrderRef.cid(cid) Т-Банк после перезапуска не найдет и вернет Rejected с кодом unknown_stop_order: он не возвращает cid, связь
хранится в памяти процесса, который поставил заявку. Повторная отмена ничего не создает. Неизвестный стоп или уже сработавший - Rejected.
Что отличается у брокеров
Подробная таблица на странице Стоп-заявки, коротко:
- Срок. «До отмены» есть у всех (у Bybit только оно). «До конца дня» у Финама и АЛОРа, «до даты» у Т-Банка и АЛОРа (у Финама только для тейк-профита). Чего нет, то
Rejectedс кодомunsupported_expiry. - Цена после срабатывания тейк-профита. У Финама нет:
Rejected,unsupported_limit_price. У БКС, Т-Банка, АЛОРа и Bybit есть. cidв списке. У Т-Банка пока работает процесс, который поставил заявку. После перезапускаStopOrderSnapshot.cidпуст. Исключение - стоп с потерянным ответом: егоcidконнектор восстанавливает, если такой стоп один.
Как проверить
Для этих команд нужен клон репозитория: тесты и примеры в пакет pip install brokapi не входят. Версия пакета должна быть той же, что у клона (последний выпуск и его тег).
git clone https://github.com/runinfra/brokapi && cd brokapi
pip install brokapi pytest pytest-asyncio
cd crates/python
pytest tests/test_stops.py tests/test_examples.py tests/test_guides.py
Тесты идут на имитациях всех брокеров: стоп далеко от рынка ждет в списке и снимается, повтор с тем же cid не дублирует, стоп-лимит
без цены отвергается до отправки, потерянный ответ ищется в списке (все четыре брокера, и случай, когда рядом стоит такой же стоп без cid). Срабатывание стопа проверено на имитациях БКС и Финама
(mock.fill(cid, ...): стоп уходит из списка, заявка исполнена). Имитация Т-Банка срабатывание стопа не умеет: mock.fill для него
вернет ValueError, а стоп останется в списке.
Любой метод брокера, Python
Инструкция. Общий интерфейс закрывает то, что умеют все брокеры. Но у каждого брокера есть много своего: купоны и дивиденды, отчеты,
избранное, корпоративные действия, свежие методы. Чтобы они не оказались закрыты, у клиента есть await client.raw(...). Он вызывает
любой метод API брокера с теми же токеном, адресом и счетчиком лимитов, что у самого клиента. Общая часть, одна для Rust и Python:
Любой метод брокера: raw. Там примеры на Rust и правила квоты. Здесь то, как это выглядит в Python. Код ниже - куски
программы raw.py, она запускается тестом против имитаций.
reply = await client.raw(method, args=None, *, command=False) # reply: dict (или list, как ответит брокер)
В Rust метод вызывается замыканием над типизированным клиентом. В Python замыкания нет, поэтому метод называют строкой, а аргументы передают словарем. Ответ приходит словарем.
Как называть метод и что передавать
| Брокер | method | args |
|---|---|---|
| Т-Банк | имя метода из описания API: "GetLastPrices", "GetUserTariff". Если у двух сервисов одно имя, "UsersService/GetAccounts" | сообщение запроса словарем, имена полей как в .proto (snake_case, camelCase тоже принимается) |
| Финам | то же: "LastQuote", "Clock" | то же |
| БКС | имя из файла лимитов: "GetQuotes", "GetPortfolio", "GetOperationHistory", или "GET /путь", "POST /путь" для любого другого метода | query (параметры URL), body (JSON для POST), для GetOrderStatus еще path ({"id": ...}) |
| АЛОР | глагол и путь из описания API: "GET /md/v2/time", "GET /md/v2/Clients/MOEX/D12345/summary", "POST /commandapi/...". Глаголы GET, POST, PUT, DELETE. Каждый вызов тратит квоту по умолчанию | query (параметры URL), body (JSON), request_id (X-REQID команды: если не дать, коннектор сделает новый) |
| Bybit | глагол и путь из описания API V5: "GET /v5/market/time", "GET /v5/account/wallet-balance", "POST /v5/position/set-leverage". Глаголы GET и POST. Пути /v5/market/... читаются без подписи, остальные подписываются ключом | query (параметры GET), body (JSON для POST) |
Т-Банк и Финам говорят по gRPC. Описание всех сервисов и сообщений вшито в библиотеку, метод ищется в нем по имени, словарь становится
сообщением запроса, а ответ снова словарем. Поэтому годится любой метод API, в том числе тот, которого нет в общем интерфейсе. Формат
как у протокола брокера: 64-разрядные числа записаны строками ("units": "274"), время - строка RFC 3339, деньги Т-Банка -
{"units": "274", "nano": 50000000}, число Финама - {"value": "100.05"}. Имена полей в ответе - как в .proto. Лишнее поле в запросе
дает ошибку, а не молча пропадает.
БКС говорит по REST. Методы, которые библиотека знает по имени, лежат в таблице (глагол и путь), остальные вызываются как "GET /путь"
или "POST /путь". Числа с дробной частью в ответе БКС приходят как Decimal, float в Python не попадает нигде.
async def tbank_prices(client: brokapi.Client) -> dict[str, str]:
"""T-Bank (gRPC): the method is named as in the API description, the arguments are the request message."""
reply = await client.raw("GetLastPrices", {"instrument_id": ["SBER_TQBR", "GAZP_TQBR"]})
# int64 is text, money is {"units": "274", "nano": 50000000}: the wire form of the broker.
return {p["ticker"]: f'{p["price"]["units"]}.{p["price"]["nano"]:09d}' for p in reply["last_prices"]}
async def bcs_quotes(client: brokapi.Client) -> dict[str, Any]:
"""BCS (REST): a name from its rate limit file, `query` for the URL and `body` for the JSON of a POST."""
reply = await client.raw("GetQuotes", {"body": {"instruments": [{"ticker": "SBER", "classCode": "TQBR"}]}})
return {record["ticker"]: record["last"] for record in reply["records"]} # prices are Decimal
async def finam_quote(client: brokapi.Client) -> str:
"""Finam (gRPC): the same as T-Bank. `google.type.Decimal` is {"value": "100.05"}."""
reply = await client.raw("LastQuote", {"symbol": "SBER@MISX"})
return reply["quote"]["last"]["value"]
АЛОР говорит по REST, как БКС, но методы называются глаголом и путем из описания API брокера: "GET /md/v2/time". Числа с дробной
частью приходят как Decimal. Команде заявки передают request_id: брокер отвечает на повторный идентификатор копией первого
ответа и второй заявки не создает (по его документации).
async def alor_summary(client: brokapi.Client) -> Any:
"""ALOR (REST): the verb and the path of the API description. `query` is for the URL, `body` for a POST or PUT."""
reply = await client.raw("GET /md/v2/Clients/MOEX/D00001/summary") # your own portfolio in the path
return reply["portfolioEvaluation"] # prices are Decimal
В аргументах raw Decimal уходит текстом, UUID текстом, datetime с поясом строкой RFC 3339, float отвергается (TypeError).
Что происходит при вызове
- Берется квота. Имя метода ищется в файле лимитов брокера. Нет такого имени - тратится общее ведро (маршрут
"*"). Бесплатно не уходит ни один вызов. Квоты нет - вызов не отправляется, и этоRateLimitedсretry_after. Счетчик общий с клиентом и с клиентами на том жеlimiter=:rawиportfolio()тратят одно ведро, если у них один маршрут. - Идет вызов с токеном клиента и его таймаутом.
- Ошибка брокера переводится в те же исключения, что везде (Ошибки).
async def raw_read(client: brokapi.Client, method: str, args: dict[str, Any], tries: int = 3) -> Any:
"""A read through `raw` spends the same quota as every other call: when it is spent, wait and ask again."""
for _ in range(tries - 1):
try:
return await client.raw(method, args)
except brokapi.RateLimited as error:
await asyncio.sleep(error.retry_after.total_seconds())
return await client.raw(method, args)
Чтение и команда
command=False(по умолчанию) - для чтения. Сетевой сбой и таймаут -Transport, вызов можно повторить.command=True- для всего, что меняет счет. Нет ответа (таймаут, обрыв, ошибка сервера) -Unknown: брокер мог выполнить команду. Библиотека такой вызов не повторяет. Как иplace, команда доходит до конца, даже если вы перестали ее ждать (отмена задачи, таймаут).
Если сомневаетесь, меняет ли метод счет, ставьте command=True.
async def raw_command(client: brokapi.Client, method: str, args: dict[str, Any]) -> Any:
"""Anything that changes the account goes with command=True: a lost answer is Unknown and it is never repeated."""
try:
return await client.raw(method, args, command=True)
except brokapi.Unknown:
raise # ask the broker what became of it (a report, a list): do not call this again
Чего raw не делает
raw обходит гарантии общего интерфейса.
- Он не знает про заявки клиента. Заявка, поставленная через
raw, не ведется коннектором как своя: ни учета «в пути», ни шлюзаreconcile, ни проверок (шаг цены, лот, цена у стоп-лимита). Заявки ставьтеplaceиplace_stop. Черезrawвызывайте то, чего в общем интерфейсе нет. - Он не добавляет счет в ваш запрос:
account_idкладите сами. - Потоки не отдаются. Метод, который открывает поток (server-streaming), дает
Unsupportedсcapability == "raw_streaming". В Rust квота покрывает открытие, а поток читает ваш код. Python так передать не может. Потоки читайте общим интерфейсом (subscribe_*). - Неизвестный метод и аргументы, которые не подходят методу, дают
Config. Ничего не отправлено, квота не потрачена. - У БКС и АЛОРа WebSocket-каналы через
rawне открываются: это есть в Rust (connect_ws, у АЛОРа ещеconnect_cws), в Python нет.
Как проверить
Для этой команды нужен клон репозитория (git clone https://github.com/runinfra/brokapi, затем pip install brokapi pytest pytest-asyncio): тесты и примеры в пакет pip install brokapi не входят.
pytest crates/python/tests/test_raw.py
На имитации всех брокеров: чтение возвращает ответ словарем, неизвестный метод и лишнее поле отвергаются без отправки, потоковый метод
отвергается, квота тратится общая с интерфейсом и с соседним клиентом на том же limiter, команда с отказом брокера не становится Unknown.
Запись событий и проигрыш, Python
Инструкция. Цель: записать поток событий в файл и проигрывать его из файла столько раз, сколько нужно для разбора, одним и тем же кодом разбора
для живого потока и для файла. Формат и правила общие для Rust и Python: файл один и тот же, журнал, записанный в Rust, читается в Python и
наоборот (Как собрать исследовательский движок). Код ниже - куски программы
record.py, она запускается тестом против имитации БКС.
Модуль называется brokapi.record.
| Что нужно | Что взять |
|---|---|
| Дописывать события в файл | record.EnvelopeWriter(path), или record.record_events(потока, path) вокруг потока |
| Прочитать их по порядку | record.EnvelopeReader(path): обычный итератор |
Отдать их коду, который читает client.events, быстро или в темпе записи | record.replay(path, record.ReplaySpeed.instant()) или ReplaySpeed.paced(factor, max_gap) |
Файл: строка-заголовок {"brokapi_log":1,"envelope_schema":1} и по строке JSON на событие (конверт). Цены, количества и деньги записаны
строками, поэтому возвращаются точно (250.25 остается 250.25).
Один разбор на живой поток и на файл
replay отдает Envelope так же, как client.events, поэтому функция разбора читает любой из них.
@dataclass
class Stats:
kinds: Counter[str] = field(default_factory=Counter)
traded: Decimal = Decimal(0) # the sum of price * qty of your fills
async def analyze(events: AsyncIterator[brokapi.Envelope], stop_after: int | None = None) -> Stats:
"""Reads events and counts them. It does not know whether they are live or from a file."""
stats = Stats()
async for envelope in events:
payload = envelope.payload
stats.kinds[type(payload).__name__] += 1
if isinstance(payload, brokapi.Execution):
stats.traded += payload.price * payload.qty
if stop_after is not None and sum(stats.kinds.values()) >= stop_after:
break
return stats
Записать
async def record_run(client: brokapi.Client, path: Path, count: int) -> Stats:
"""The live stream, written to `path` as it is read, and analysed."""
async with contextlib.aclosing(record.record_events(client.events, path)) as events:
return await analyze(events, stop_after=count)
record_events пропускает события дальше и перед этим пишет каждое в файл. Каждое событие сбрасывается на диск сразу, поэтому программа,
которая остановилась посреди работы (сбой, break), оставляет журнал, полный до последнего прочитанного события. Он записывает то, что
прочитал ваш код, то есть поток таким, каким его видел бот. Файл закрывается, когда поток кончился или генератор закрыт (contextlib.aclosing).
Новый файл создается при создании писателя. Если такой файл есть, это LogError (kind == "io"): запись одного запуска не должна молча
затираться вторым. Нужно заменить файл - overwrite=True.
Писать можно только конверт, который пришел от коннектора или из журнала: он помнит строку, из которой сделан. Envelope, собранный руками,
записать нельзя (TypeError).
Проиграть
async def analyze_file(path: Path) -> Stats:
"""The same analysis over the file. A replay waits for the reader, so nothing is lost."""
return await analyze(record.replay(path))
Скорость: ReplaySpeed.instant() (насколько быстро читает потребитель, по умолчанию) или ReplaySpeed.paced(factor, max_gap): темп записи,
деленный на factor. Темп - разница rx_ts соседних конвертов, пауза не длиннее max_gap, чтобы ночь между двумя сессиями не останавливала
проигрыш.
Отличие от живого потока. Проигрыш ждет потребителя, поэтому медленный потребитель замедляет проигрыш и ничего не теряется. Живой коннектор
ждать не может и применяет режим подписки. Значит, проигрыш никогда не даст Overflow: код, который разбирает Overflow, нужно проверять отдельно.
Прочитать без asyncio
def sources(path: Path) -> set[str]:
"""Reading a log without asyncio: an iterator of Envelope. A bad line raises brokapi.LogError and ends it."""
return {envelope.source for envelope in record.EnvelopeReader(path)}
Что файл хранит и чего нет
- Монотонные часы получения (
rx_monoв Rust) не хранятся, а в Python их нет совсем. Для порядка и замеров используйтеTimestamps.rx_nsиbroker_ns. - Поврежденная строка останавливает чтение:
brokapi.LogErrorсkind == "format"и номером строки (заголовок - строка 1). Пропустить строку молча нельзя. Проигрыш отдает конверты до нее и потом поднимает эту ошибку. - Файл более новой версии формата или с более новыми конвертами читать нельзя:
LogErrorсkind == "schema"(found,supported). Сбой диска или пути -kind == "io". Текст ошибки побуквенно равен текстуLogErrorв Rust. - Это журнал потока, а не каталог: индекса по инструменту и времени нет, фильтруйте при чтении. Для лет данных нужен каталог (в Rust-части страницы есть сравнение с NautilusTrader).
Как проверить
Для этой команды нужен клон репозитория (git clone https://github.com/runinfra/brokapi, затем pip install brokapi pytest pytest-asyncio): тесты и примеры в пакет pip install brokapi не входят.
pytest crates/python/tests/test_record.py
Главная проверка: записанные события, прочитанные из файла и проигранные из файла, дают ту же последовательность, что видел бот, на имитациях всех трех брокеров. Еще проверено: журнал в формате Rust читается и записывается обратно байт в байт, поврежденная строка и новая версия отвергаются с номером строки, второй запуск не затирает первый журнал, медленный читатель проигрыша ничего не теряет.
Как тестировать код против имитаций брокеров (Python)
Инструкция. Цель: проверить своего бота без брокера, сети и денег. В пакете есть модуль brokapi.testing с имитациями
брокеров. Каждый пример этой книги идет именно на них.
Имитацию можно запустить и обычным скриптом через asyncio.run, не только тестом: так начинается Быстрый старт.
Что такое имитация
Это настоящий сервер на 127.0.0.1 со случайным портом, который говорит на протоколе брокера: HTTP и WebSocket у БКС, gRPC у
Т-Банка и Финама. Клиент brokapi.Client подключается к нему так же, как к брокеру, поэтому тест идет по тому же коду, что и боевой
запуск, а не по подмене. Сервер хранит состояние: поставленные заявки видны в active_orders, снимаются, исполняются.
Чего имитация не доказывает: что настоящий брокер отвечает так же. Имитации написаны по документации брокеров.
Минимальный тест
import brokapi
from brokapi.testing import mock_bcs
async def test_the_bot_reads_a_book():
async with await mock_bcs() as mock: # сервер стартовал
client = await mock.connect() # клиент к нему подключен
sber = await client.instrument("SBER")
book = await client.order_book(sber, depth=5)
assert book.bids[0].price < book.asks[0].price
# блок кончился: клиенты закрыты, сервер остановлен
Тесты асинхронные. Подойдет pytest-asyncio (asyncio_mode = "auto") или anyio, в самом пакете тесты на первом.
| Функция | Что поднимает | Счета |
|---|---|---|
mock_bcs() | БКС | один, токен на запись |
mock_tbank() | Т-Банк | два: acc-a (по умолчанию) и acc-b |
mock_finam() | Финам | два: ACC1 (по умолчанию) и ACC2 |
mock_alor() | АЛОР | один портфель D00001 (synthetic_candles не нужен: ряд всегда под заданный период) |
mock_bybit() | Bybit | один счет, тестовый контракт MOCKUSDT (шаг количества и самое малое количество 10, шаг цены 0.01), еще есть BTCUSDT. synthetic_candles не нужен: ряд всегда под заданный период |
У всех есть synthetic_candles=True: candles тогда отвечает рядом под заданный период, по бару на шаг таймфрейма, и отвергает
период длиннее предела брокера, как настоящий. Выключено, отвечает тремя фиксированными барами.
mock.connect(**kw) принимает те же аргументы, что brokapi.connect: name, limiter, account_id и другие. Так делают два
клиента на один счет или второй счет, как в инфраструктуре.
Исполнить заявку
Статусы и исполнения приходят событиями. Чтобы проверить, что бот делает при исполнении, имитацию просят исполнить заявку:
mock.fill(cid, qty=params.min_qty, price=Decimal("100.00"))
У БКС, Финама, АЛОРа и Bybit нужны qty и price (на БКС qty меньше объема заявки дает частичное исполнение). Т-Банк исполняет заявку целиком
по ее цене и эти аргументы не читает. Т-Банку и Финаму можно указать account.
Ждать события, а не время
В тестах нет sleep. Ждите событие или ответ, а время не отсчитывайте: пауза либо лишняя, либо коротка.
async def wait_event(client, kind, **match):
async for envelope in client.events:
if isinstance(envelope.payload, kind) and all(
getattr(envelope.payload, k) == v for k, v in match.items()
):
return envelope
Оберните чтение в asyncio.wait_for(..., 10): предел нужен, чтобы тест, у которого событие не пришло, упал, а не повис. Порядок
событий у разных брокеров разный: в имитациях БКС и Финама сначала приходит событие заявки, а у Т-Банка исполнение пришло раньше статуса
Filled. Не рассчитывайте на порядок между двумя видами событий, собирайте их, пока не набрались оба.
Гейтвей в тесте
Бот, который работает через гейтвей, тоже проверяется без брокера и сети. mock.start_gateway() поднимает в вашем процессе настоящий гейтвей на имитации,
а gateway.connect(bot="my-bot") дает клиента, которого делает и brokapi.connect_gateway:
async def test_the_bot_behind_a_gateway():
async with await mock_bcs() as mock:
gateway = await mock.start_gateway("bcs-main")
client = await gateway.connect(bot="my-bot") # тот же Client, что у mock.connect()
...
await gateway.restart() # гейтвей умер, на его месте новый процесс: бот получит brokapi.Gap
restart() - тот сбой, который бот за гейтвеем обязан пережить. Клиенты остаются подключены, находят новый процесс, и в их потоке появляется Gap с причиной new_epoch.
Заявки, стоящие у имитации, никуда не деваются: новый гейтвей сверяет с ними свой журнал до первой команды. Гейтвей останавливается вместе с имитацией. Пример с перезапуском:
Подключить бота.
Как устроены тесты самого пакета
В crates/python/tests/: чтение и заявки на каждом брокере (test_brokers.py), общие правила (test_bcs.py), общие счетчики лимитов,
слияние потоков и история (test_infrastructure.py), точные числа и ошибки (test_codec.py, test_errors.py, test_numbers.py),
примеры книги (test_examples.py, test_rules.py, test_guides.py). Тесты лежат в клоне репозитория (git clone https://github.com/runinfra/brokapi), в пакет pip install brokapi они не входят. Запуск:
cd crates/python
maturin develop # собрать расширение в текущее окружение (нужен Rust)
pytest tests
Rust не нужен, если поставить из PyPI пакет той же версии, что у клона: pip install brokapi pytest pytest-asyncio, и сразу pytest tests.
Как Python связан с ядром
Объяснение. Страница про то, как устроена обертка, чем она отличается от Rust и почему сделано именно так. Чтобы писать бота, она не нужна, но отвечает на вопросы «а что под капотом» и «почему тут не так, как в Rust».
Из чего состоит пакет
Пакет brokapi - тонкая обертка. Всю работу (протоколы брокеров, сверку, лимиты, правила заявок) делает ядро на Rust, то же,
что у Rust-пути. Нативный модуль собран PyO3 в один файл на все версии Python от 3.10 (abi3). Сверху лежит слой на Python: типы,
ошибки, понятный async for и подсказки для редактора.
flowchart TB BOT[Ваш бот на Python] --> PY[Слой Python: Client, типы, ошибки] PY --> NAT[Нативный модуль на Rust: PyO3] NAT --> CORE[Ядро brokapi: коннекторы, сверка, лимиты] CORE --> BR[БКС, Т-Банк, Финам, АЛОР, Bybit]
Из схемы следует: правил в Python-слое нет. Он только переводит вызовы и данные туда и обратно, поэтому поведение у Python и Rust одно, а ошибка в правиле исправляется в одном месте.
Тот же путь у бота за гейтвеем: brokapi.connect_gateway ставит под тот же Client не коннекторы, а клиента гейтвея на Rust (RemoteClient),
вызовы по имени идут через тот же код, а поток событий ведет Feed, который сам переоткрывает поток и сообщает о потере событий. Отдельного клиента gRPC на Python нет.
Мост между tokio и asyncio
Ядро работает на асинхронном рантайме tokio, бот на Python на цикле asyncio. Мост - библиотека pyo3-async-runtimes: каждый
асинхронный метод ядра становится ожидаемым объектом (awaitable) цикла, который сейчас работает. Метод запускается на потоках
tokio, а результат возвращается в тот же цикл asyncio, в котором его ждут.
Три следствия, о которых надо знать:
- Команда не отменяется вместе с ожиданием. Если задачу, которая ждет
place, отменить (или оборвать по таймауту), команда все равно доходит до конца внутри библиотеки. Иначе заявка, оборванная на полпути, могла бы уже лежать у брокера, а бот решил бы, что ее нет. Поэтому после отмены ожидания уplace,cancel,replaceиreconcileспрашивайте у брокера, чем кончилось (order_state, события). Чтение отменяется как обычно. - События читаются из одного канала. У клиента один поток событий, как у Rust один канал. Пока его не читают, подписки применяют свои правила (склеить, отбросить, копить без потерь), и копящая без потерь при переполнении закрывает торговлю. Поэтому поток читает одно место, а долгую работу бот выносит в свою очередь.
- Один клиент можно звать из многих задач. Вызовы идут параллельно, по одному инструменту команды выстраиваются в очередь внутри ядра (Круг команд).
Как проходят данные
Данные идут через границу текстом JSON: типы ядра уже умеют записываться в JSON, а слой Python превращает текст в классы с
Decimal и аккуратным временем. Цены, количества и деньги в JSON лежат строками, и точное число остается точным до самого
Decimal: ни на одном шаге оно не становится float.
- В вызов можно передать
Decimal,intили строку.floatотвергается сTypeError: из0.1никакое округление не вернет точное число. - Время -
datetimeс часовым поясом (UTC), точность до микросекунд. Точные наносекунды лежат вTimestamps.datetimeбез пояса отвергается: неясно, о каком времени речь. - Обход через JSON на вызов добавляет работу процессора, но рядом с сетью брокера она мала. Если это станет узким местом, текст можно заменить на прямые объекты, интерфейс при этом не изменится.
Ядро делает работу и держит правила, Python остается способом ими управлять. brokapi не торговый движок: в нем нет стратегий, учета позиций и бэктеста, только подключение к брокерам.
Ошибки
Класс исключения на каждый вариант Error ядра, с тем же именем и тем же текстом: Config, Transport, RateLimited, Rejected,
Unknown, Unsupported, NotReconciled, Degraded, ShuttingDown. Все они наследуют brokapi.BrokapiError. Текст исключения
(str(error)) побуквенно равен тексту Display в Rust, это проверяет тест: так описание на странице Ошибки верно и
для Python. Поля вариантов лежат в атрибутах: Rejected.code, Rejected.message, RateLimited.retry_after (timedelta),
Unsupported.capability. Ответ на вопрос «можно ли повторить» дает error.error_class: RETRYABLE, FATAL или UNKNOWN.
Ошибки журнала событий (файл, строка, версия) - еще один класс того же семейства, LogError, с тем же текстом, что LogError в Rust.
Два исключения вне семейства: ValueError (аргумент не годится, ничего не отправлено) и RuntimeError при вызове метода закрытого
клиента. Вариант ошибки, которого этот пакет еще не знает (ядро новее пакета), приходит как BrokapiError с текстом ядра, а событие
неизвестного вида - как UnknownEvent: цикл бота не падает.
Чем отличается от Rust
| Rust | Python | |
|---|---|---|
| Подключение | два коннектора, лимиты и канал собирает бот | await brokapi.connect(...): все собрано, лимиты делятся через limiter= |
| События | mpsc::Receiver<Envelope>, общий на сколько угодно коннекторов | client.events на клиента, brokapi.merge склеивает |
| Деньги | Price, Qty, Money | Decimal |
| Идентификатор заявки | ClientOrderId | uuid.UUID (brokapi.new_cid()) |
| Шаг цены коннектору | ud.price_grid().learn(¶ms) | делает trading_params |
| Состояния заявки | варианты OrderState | OrderState.kind (OrderStateKind) и поля |
| Стоп-заявки | StopOrderExecutor | place_stop, cancel_stop, active_stop_orders, subscribe_stop_orders |
raw() | замыкание над типизированным клиентом брокера, потоки отдаются | await client.raw(имя, словарь), ответ словарем, потоки не отдаются |
| Гейтвей | RemoteClient, Feed | await brokapi.connect_gateway(...): тот же Client, потеря событий приходит событием Gap |
| Запись и проигрыш событий | EnvelopeWriter, EnvelopeReader, replay | brokapi.record, файл тот же |
Что не меняется: порядок «подписка, сверка, торговля», cid от бота, Unknown без повтора, один счетчик лимитов на область.
Gateway
Gateway - программа brokapi: служба, которая держит связь с вашими счетами у брокеров за все боты сразу, и команды, которыми ею управляют.
Боты подключаются к службе, а не к брокеру. Бесплатно, ставится у себя, код открыт.
Это один продукт, а не два: brokapi up запускает службу, остальные команды (ps, top, status, portfolio, cancel-all и другие)
ею управляют и читают счет. Отдельной «командной строки» рядом нет, brokapi и есть командная строка.
В книге слово «гейтвей» означает процесс этой службы, который держит одно подключение к одному счету. Слово «шлюз» в других страницах значит проверку внутри коннектора (шлюз торговых команд), это разные вещи.
Когда он нужен
| Ситуация | Что брать |
|---|---|
| Один бот на Rust или Python, один счет | Библиотеку напрямую (Rust, Python). Лишний процесс не нужен |
| Несколько ботов на одном счете | Gateway. Один счетчик лимитов на всех. Без него каждый бот считает лимит сам, вместе они превышают квоту брокера, и брокер начинает отказывать |
| Бот не на Rust и не на Python | Gateway: бот подключается по gRPC, на любом языке, где gRPC есть |
| Бот на Python, которому нужен общий счетчик лимитов или связь, переживающая перезапуск бота | Gateway: brokapi.connect_gateway вместо brokapi.connect, остальной код бота тот же (Подключить бота) |
| Боты часто перезапускаются, а связь с брокером нужна постоянно | Gateway: связь живет дольше ботов, переподключение к брокеру и сверка заявок остаются за службой |
| Посмотреть счет или стакан из терминала, скриптом, ИИ-агентом | Команды brokapi (portfolio, book, quote, ключ --json). Бот для этого не нужен |
Если бот один и он на Rust или Python, Gateway ничего не добавит, кроме отдельного процесса.
Как устроено
Схема показывает три слоя и то, кто с кем разговаривает. Команды и боты - клиенты, служба запускает гейтвеи, гейтвей один на счет и единственный разговаривает с брокером.
flowchart TB
cmd["команды brokapi<br/>человек, скрипт, ИИ-агент"] --> svc["служба brokapi up"]
bots["боты на Rust, Python, Go<br/>и любом другом языке"] --> gws
svc -. "запускает и следит" .-> gws
subgraph gws["гейтвеи: по процессу на счет"]
g1["bcs-main"]
g2["tbank-sandbox"]
end
g1 --> br1[("БКС")]
g2 --> br2[("Т-Банк")]
- Гейтвей - один процесс на одно подключение (брокер, счет, токен). Он держит соединение с брокером, переподключается, сверяет заявки, ведет счетчик лимитов, общий для всех его ботов, и журнал заявок. Наружу отдает gRPC для ботов.
- Служба - фоновый процесс
brokapi up. Читает конфиг, запускает гейтвеи отдельными процессами, перезапускает упавшие, собирает их журналы и состояние, отвечает командам. - Команды -
brokapi ...: клиенты службы и гейтвеев. Список: Команды.
Почему процессы, а не потоки. Если коннектор одного брокера упал или завис, остальные гейтвеи продолжают работать, а служба перезапускает упавший. У каждого гейтвея своя память и свой токен. Будь все гейтвеи потоками одного процесса, паника или зависание в одном остановили бы все.
Служба не обязана жить, пока живут гейтвеи. Если процесс службы умер, гейтвеи продолжают работать и боты не замечают. Новая служба
(brokapi up с тем же конфигом) находит их по pid-файлам и сокетам и берет под присмотр.
Несколько служб на одной машине. Конфиг задает свой каталог состояния и свой сокет, поэтому brokapi -c prod.toml up и brokapi -c test.toml up
работают независимо. Увидеть все службы машины: brokapi services.
Что гарантирует гейтвей
У каждой гарантии указано, что будет, если ее нет.
- Один счетчик лимитов на всех ботов счета. Два бота, у каждого свой счетчик, вместе выбирают квоту брокера, и брокер начинает отказывать
обоим. Бот, который просит сверх бюджета, получает
RateLimited, как если бы он был один. Очереди между ботами нет: бот, который занял всю квоту, оставит остальным отказы, пока ведро не пополнится (Лимиты запросов). - События с номерами. Каждое событие гейтвея имеет номер. Бот, который переподключился, называет номер последнего обработанного события
и получает пропущенное. Если пропущенного уже нет, он получает явный кадр
Gapи обязан сделать сверку: без этого бот решил бы, что ничего не пропустил. Подробно: События и сверка после перезапуска. - Медленный бот не тормозит остальных. У каждого бота свой буфер. Переполнился - поток этого бота закрывается кадром
Lagged, остальные ничего не замечают. Без этого один зависший бот копил бы события в памяти гейтвея, пока не остановил бы всех. - Перезапуск с открытыми заявками. Номер заявки (
cid) записывается в журнал гейтвея на диск до отправки заявки. После перезапуска гейтвей сверяет журнал с брокером и только потом принимает команды. Пока сверка идет,Healthотвечаетstarting, а остальные вызовы получаютUNAVAILABLE: так не случится, что бот выставил заявку, а гейтвей не знает про предыдущую. Это закрывает случай «упал после отправки, до ответа». - Подписки с владельцами. Одинаковая подписка двух ботов - одна подписка у брокера. Снять ее может только тот, кто открыл, а когда бот пропал, его подписки снимаются через тридцать секунд. Иначе подписки копились бы у брокера и занимали его квоту.
- Только эта машина. Гейтвей слушает сокет в каталоге состояния (на Windows -
127.0.0.1), а каждый вызов должен нести ключ из файлаaccess.keyс правами 600. Другой адрес гейтвей не откроет. Бот на другой машине подключиться не может: шифрования (TLS) у гейтвея нет.
Чем отличается от библиотеки
Правила заявок, сверки и ошибок те же, что в общих страницах, но дорога у них другая, и это дает несколько отличий: команда без ответа, номера событий, общий лимит, подписки. Все отличия с причинами: Чем гейтвей отличается от прямого подключения.
Что есть и чего пока нет
| Есть | БКС, Т-Банк, Финам, АЛОР и Bybit за гейтвеем. Служба с перезапуском и подхватом гейтвеев. Несколько служб на машине. Команды из справочника. Клиент для ботов на Rust (RemoteClient) и на Python (brokapi.connect_gateway, тот же Client, что при прямом подключении). Контракт для любого языка: файлы .proto, справочник Gateway (gRPC) |
| Пока нет | Клиента для ботов на Go: бот на этом языке подключается по .proto сам (Подключить бота). Очереди между ботами. Шифрования и доступа с других машин. Установки службы в систему одной командой: образец юнита systemd есть в Запуске и обслуживании |
Куда дальше
- Попробовать за десять минут: Быстрый старт.
- Подключить своего бота: Подключить бота.
- Понять поток событий: События и сверка после перезапуска.
- Держать службу постоянно: Запуск и обслуживание.
- Справочники: Конфиг, Команды, Gateway (gRPC).
Быстрый старт: служба brokapi
Что получится за десять минут: служба brokapi с одним гейтвеем на песочнице Т-Банка, и вы читаете из нее стакан.
Заявок мы не ставим, только читаем, поэтому на этом пути нельзя навредить счету.
Что нужно заранее:
- Программа
brokapiустановлена: Установка. Проверка:brokapi --versionпечатает номер версии. - Токен песочницы Т-Банка и номер счета в песочнице. Где их взять: Т-Банк, раздел «Вход».
1. Конфиг
Создайте каталог для службы и образец конфига в нем:
$ brokapi init
wrote /home/me/bots/brokapi.toml
state directory /home/me/bots/brokapi.state
Next: put the account and the token of a broker in the file, then `brokapi check`, then `brokapi up`.
init пишет brokapi.toml с комментариями и рядом каталог brokapi.state, в нем служба держит свои файлы. Если brokapi.toml уже
есть, команда откажется и ничего не затрет (затереть можно флагом --force).
Откройте brokapi.toml. В образце уже есть раздел гейтвея на песочнице Т-Банка, вам нужно подставить номер счета:
[[gateway]]
name = "tbank-sandbox"
broker = "tbank"
account = "2000000000"
sandbox = true
token_env = "TBANK_TOKEN"
name - имя гейтвея, по нему к нему обращаются все команды. token_env - имя переменной окружения, в которой лежит токен. Сам токен в файл
не пишется: конфиг попадет в копию или в репозиторий, а токен туда попадать не должен. Остальные поля: Конфиг brokapi.toml.
2. Проверка
Передайте токен службе через окружение и проверьте конфиг:
$ export TBANK_TOKEN=токен-песочницы
$ brokapi check
config /home/me/bots/brokapi.toml
state directory /home/me/bots/brokapi.state (writable)
GATEWAY BROKER CONFIG ACCESS
tbank-sandbox tbank ok ok
check разбирает файл и читает счет у брокера (портфель и активные заявки). Он ничего не ставит. В колонке ACCESS должно быть ok.
Если там FAILED с причиной, команда завершится с кодом 1: исправьте причину и повторите. Частые причины: в конце страницы.
3. Запуск
$ brokapi up -d
service started in the background, pid 4121
log /home/me/bots/brokapi.state/logs/service.log
Служба запущена в фоне и запустила гейтвей. Посмотрите на него:
$ brokapi ps
NAME BROKER STATE PID UPTIME RESTARTS BOTS EVENTS/S ORDERS LIMIT LAST ERROR
tbank-sandbox tbank running 4133 3s 0 0 0.0 0 0%
Колонка STATE должна показать running. Если там starting, гейтвей еще сверяет свой журнал с брокером, повторите команду через секунду.
Что значат остальные колонки: Команды.
4. Читаем котировку
$ brokapi quote tbank-sandbox SBER
SBER TQBR
bid 281.35 x 10
ask 281.40 x 12
last 281.35
Команда спросила гейтвей tbank-sandbox, а гейтвей - брокера. Если на месте цен прочерки (-), стакан сейчас пуст: так бывает, когда торгов нет.
Стакан целиком и портфель читаются командами book и portfolio, ключ --json делает вывод пригодным для скрипта.
5. Остановка
$ brokapi down
service stopped
Служба остановила гейтвей и вышла. Заявок на бирже down не трогает, а в этом пути их и нет.
Если что-то пошло не так
| Что видно | Что значит | Что делать |
|---|---|---|
no config file found | Команда не нашла brokapi.toml | Запускайте команды в каталоге с файлом или укажите путь: brokapi -c путь/к/brokapi.toml ... |
the environment variable TBANK_TOKEN (token_env) is not set | Переменной с токеном нет в этом терминале | export TBANK_TOKEN=... в том же терминале, где запускаете brokapi |
В ACCESS написано FAILED | Брокер не принял токен или номер счета | Сверьте токен песочницы и account. Токен боевого счета в песочницу не подходит и наоборот |
no service is running for ... (код 3) | Служба не запущена | brokapi up -d |
ps показывает failed | Гейтвей несколько раз подряд упал, служба остановила попытки | В колонке LAST ERROR причина, подробности в brokapi logs tbank-sandbox. Исправьте причину и запустите brokapi start tbank-sandbox |
Дальше
- Подключить бота к этому гейтвею: Подключить бота.
- Узнать, зачем нужен гейтвей и что он гарантирует: Gateway.
- Все команды: Команды. Все поля конфига: Конфиг.
- Держать службу постоянно, на нескольких счетах: Запуск и обслуживание.
Подключить бота
Как подключить бота к гейтвею: что нужно знать о доступе, в каком порядке вызывать, как жить с обрывами. Бот на Rust берет готовый клиент RemoteClient,
бот на Python берет тот же Client, что при прямом подключении к брокеру, бот на любом другом языке генерирует клиент из .proto. Гейтвей должен быть
запущен: Быстрый старт.
Что нужно знать о доступе
Гейтвей слушает только эту машину и пускает только по ключу. Все, что нужно боту, лежит в каталоге гейтвея <state_dir>/gateways/<имя>/:
| Файл | Что в нем |
|---|---|
gateway.addr | Где слушает гейтвей: unix:/путь/gateway.sock на Linux и macOS, tcp:127.0.0.1:порт на Windows. Служба пишет файл при запуске гейтвея |
access.key | Ключ доступа. Права 600, читать его может только владелец. Менять не нужно: ключ создается один раз |
gateway.sock | Сокет Unix. Если путь до него длиннее предела системы, сокет лежит в другом месте, а настоящий адрес всегда в gateway.addr |
journal.jsonl | Журнал заявок гейтвея. Боту не нужен, не трогайте |
Каждый вызов несет два заголовка (metadata gRPC):
| Заголовок | Значение | Зачем |
|---|---|---|
authorization | Bearer <содержимое access.key> | Без него или с чужим ключом - UNAUTHENTICATED |
x-brokapi-bot | Имя бота: от 1 до 64 символов, буквы, цифры, ., _, - | Попадает в журнал гейтвея и в brokapi status. Нужно, чтобы фильтр own_orders_only знал, чьи заявки ваши. Без имени бот называется anonymous |
Имя придумываете вы. Два бота с одним именем гейтвей не различает: у них общие заявки в фильтре own_orders_only.
Бот на Rust
Клиент RemoteClient лежит в крейте brokapi-gateway. Боту нужна только библиотека, без программы brokapi, поэтому зависимость берется с
default-features = false:
cargo add brokapi-gateway --no-default-features
Самая свежая версия, из репозитория: cargo add --git https://github.com/runinfra/brokapi brokapi-gateway --no-default-features.
RemoteClient умеет те же трейты ядра, что коннектор, поэтому код для прямого подключения почти не меняется: Торговый бот на событиях, Rust.
Отличия, с причинами: Чем гейтвей отличается от прямого подключения.
Порядок вызовов такой, и у каждого шага есть причина:
- Подключиться по каталогу гейтвея и имени. Клиент сам читает адрес и ключ.
- Сверить (
reconcile) со своим списком заявок до любой торговой команды. Так требует контракт гейтвея, и причина та же, что в библиотеке: пока бот не знает, какие его заявки стоят у брокера, новая команда может задвоить заявку. - Открыть поток событий (
events) и только потом подписаться. Подписка раньше потока теряет события из щели между ними. - Торговать. Перед
placeбот записываетcidв свой журнал, как при прямом подключении.
// The directory holds the address of the gateway (`gateway.addr`) and the access key (`access.key`). The name is
// yours: the gateway shows it in `brokapi status` and in its log.
let bot = RemoteClient::connect_dir(dir, "my-bot").await?;
Вы должны увидеть: connect_dir вернул клиент без ошибки. Transport с текстом cannot connect to unix:... значит, что гейтвей не запущен или каталог не тот: brokapi ps.
Гейтвей, который еще сверяет журнал, принимает соединение, но вызовы получают GATEWAY_STARTING: подождите, пока Health не ответит ready.
// Before any command: tell the gateway which orders you placed and have not seen end. The report has three lists, as
// when the bot talks to a broker itself.
let journal: Vec<ClientOrderId> = Vec::new(); // in a real bot: read from your own file
let report = bot.reconcile(&journal).await?;
println!(
"reconciled: {} confirmed, {} closed while away, {} not ours",
report.confirmed.len(),
report.closed_offline.len(),
report.foreign.len()
);
Три списка отчета те же, что у коннектора (Круг жизни). Отличие одно: заявки других ботов этого гейтвея вернутся как foreign, потому что в вашем списке их нет
(флаг include_journal в .proto добавляет к списку журнал гейтвея, клиент Rust его не выставляет). Сверка с пустым списком на первом запуске нормальна.
let sber = bot.resolve_instrument("SBER", Some("TQBR")).await?;
let book = bot.order_book(&sber, 5).await?;
println!("order book: {book:?}");
// The stream is open before the order goes out, so the first state of the order cannot be missed.
let mut stream = bot.events(None, true).await?;
bot.subscribe_order_state().await?;
let cid = ClientOrderId::new_v4();
// Write `cid` to your journal here, before the call. After a crash it is the only way back to this order.
bot.place(far_buy(&sber, cid)).await?;
Заявка уходит к брокеру. Если запускать пример на настоящем счете, заявка настоящая: он выставляет покупку одного лота по цене 1.0, далеко от рынка, и сразу
снимает ее, но на счете БКС песочницы нет. Без BROKAPI_LIVE=1 пример только читает.
bot.cancel(cid).await?;
// The states of the order come on the stream, the bot does not poll for them.
while let Some(next) = stream.next().await {
let RemoteEvent::Event { envelope, .. } = next? else {
continue; // a gap or a cut: see `follow` below
};
if let Event::Order(order) = envelope.payload
&& order.cid == cid
{
let ended = matches!(order.state, OrderState::Cancelled { .. });
seen.states.push(order.state);
if ended {
break;
}
}
}
Вы должны увидеть состояния заявки из потока: принята, затем снята. Бот не опрашивает заявку, состояния приходят сами.
Если поток оборвался
Поток обрывается: перезапуск гейтвея, потеря связи, бот не успевал читать. Что делать в каждом случае - События и сверка после перезапуска. Цикл, который это делает, целиком:
/// Reads the events of the gateway until `done` is true, and does what the stream asks for on the way.
///
/// `resume` is where the previous stream stopped (`None` at the first start). `journal` is the bot's own list of the orders
/// it placed and has not seen end.
pub async fn follow(
bot: &RemoteClient,
mut resume: Option<Resume>,
journal: &[ClientOrderId],
mut on_event: impl FnMut(&Envelope) -> bool,
) -> Result<u32, Error> {
let mut gaps = 0;
loop {
// Open the stream first: from the moment `events` returns, the gateway keeps what happens for this bot.
let mut stream = bot.events(resume, true).await?;
// Then subscribe. Asking again is harmless, the gateway counts the request once for the bot.
bot.subscribe_order_state().await?;
while let Some(next) = stream.next().await {
match next? {
RemoteEvent::Event { envelope, .. } => {
if on_event(&envelope) {
return Ok(gaps);
}
}
RemoteEvent::Gap(gap) => {
// The gateway no longer has the events after our number. Whatever they said, the account is
// not what we last saw: ask the broker. A new epoch means a new process, which holds no subscriptions.
gaps += 1;
if gap.reason == pb::gap::Reason::NewEpoch as i32 {
bot.subscribe_order_state().await?;
}
bot.reconcile(journal).await?;
}
RemoteEvent::Lagged { .. } => break, // we read too slowly: reconnect from the resume point
}
}
// The stream ended (the gateway restarted, the connection broke) or we lagged: continue where we stopped.
resume = Some(stream.resume_point());
}
}
Пример follow запускается как тест: ему подсовывают номер из другого запуска гейтвея, и он встречает Gap повторной подпиской и сверкой.
Весь файл: crates/gateway/examples/bot.rs в репозитории. Запуск на живом гейтвее (читает, заявок не ставит):
cargo run -p brokapi-gateway --example bot -- brokapi.state/gateways/tbank-sandbox. Пример написан под БКС (SBER на TQBR), для другого брокера поменяйте тикер и площадку.
Бот на Python
Пакет brokapi для Python (установка: brokapi для Python) подключается к гейтвею тем же классом Client, что и к брокеру. Бот, который
уже работает с брокером напрямую, переезжает на гейтвей заменой одной строки. Остальные вызовы остаются прежними: place, reconcile, order_book, raw,
async for envelope in client.events и все остальные. Отдельного клиента gRPC на Python нет и не нужен: под Client лежит тот же код на Rust, что и в RemoteClient,
поэтому события, пропуски и сверка работают так же, как у бота на Rust.
async def connect() -> brokapi.Client:
"""The one line that differs from a bot at the broker: `brokapi.connect("bcs")` there.
`"bcs-main"` is the name of the gateway in `brokapi.toml`. The directory of its state is `brokapi.state` here, or what
`BROKAPI_STATE` says. `bot` is the name of this bot: the gateway shows it in `brokapi status`.
"""
return await brokapi.connect_gateway("bcs-main", bot="my-bot")
"bcs-main" - имя гейтвея в brokapi.toml ([[gateway]], поле name). Каталог его состояния пакет находит сам: <state_dir>/gateways/<имя>. Если state_dir
не задан в конфиге, это brokapi.state рядом с brokapi.toml, туда и смотрит пакет, когда запущен в том же каталоге. Иначе укажите каталог состояния службы
аргументом state= или переменной окружения BROKAPI_STATE. Можно назвать сам каталог гейтвея: dir=.
bot - имя бота, как в заголовке x-brokapi-bot: от 1 до 64 символов, буквы, цифры, ., _, -. Оно обязательно: два бота с одним именем гейтвей не различает.
Остальные аргументы: ready_timeout (сколько секунд ждать гейтвей, который еще сверяет журнал, по умолчанию 30), event_buffer (сколько событий ждут читателя) и
own_orders_only=True (в потоке только события заявок этого бота).
Вы должны увидеть: connect_gateway вернул клиент, client.gateway называет гейтвей, а client.broker - его брокера. Ошибка Config с текстом no gateway address in ...
значит, что гейтвей не запущен или каталог не тот (brokapi ps). Config с текстом refused the access - ключ не тот (в каталоге лежит access.key другого гейтвея).
Transport с текстом still starting - гейтвей не успел сверить журнал за ready_timeout, причина в тексте.
Порядок вызовов тот же, что у бота на Rust: поток событий уже открыт (его открывает connect_gateway), поэтому сначала подписки, потом сверка, потом торговля.
Бот целиком, вместе с тем, как он переживает перезапуск гейтвея:
class Bot:
def __init__(self, client: brokapi.Client, ticker: str = "SBER") -> None:
self.client = client
self.ticker = ticker
# The orders the bot believes are alive. A real bot keeps them in a file and writes the cid BEFORE `place`.
self.ledger: set[uuid.UUID] = set()
self.cid: Optional[uuid.UUID] = None
self.ended: Optional[brokapi.OrderStateKind] = None
self.placed = asyncio.Event() # set when the order stands: a hook for the tests
self.mended = asyncio.Event() # set after the bot has got over a Gap: a hook for the tests
async def setup(self) -> None:
"""What a gateway has to be told: the subscriptions first, then the reconcile.
It runs at the start, and again when a new gateway process has taken the place of the old one (it holds none of the
subscriptions of this bot). The subscriptions go BEFORE the reconcile: an event between the snapshot of the reconcile
and the subscription would be lost.
"""
self.instrument = await self.client.instrument(self.ticker)
await self.client.subscribe_order_state()
await self.reconcile()
async def reconcile(self) -> None:
report = await self.client.reconcile(sorted(self.ledger))
for snapshot in report.confirmed:
self.on_order(snapshot)
for cid in report.closed_offline:
# The broker has no such order: it ended while the bot was away, or it was never sent.
self.ledger.discard(cid)
self.ended = self.ended or brokapi.OrderStateKind.CANCELLED
def on_order(self, snapshot: brokapi.OrderSnapshot) -> None:
if snapshot.cid in self.ledger and snapshot.state.is_final:
self.ledger.discard(snapshot.cid)
self.ended = snapshot.state.kind
async def on_event(self, envelope: brokapi.Envelope) -> None:
event = envelope.payload
if isinstance(event, brokapi.OrderSnapshot):
self.on_order(event)
elif isinstance(event, brokapi.Gap):
# Events were lost between the gateway and the bot. A new gateway process has no subscriptions of ours.
if event.subscriptions_lost:
await self.client.subscribe_order_state()
await self.reconcile()
self.mended.set()
elif isinstance(event, (brokapi.Overflow, brokapi.Reconnected)):
# The same loss, found by the connector of the gateway: the same remedy as at a broker.
await self.reconcile()
async def place(self) -> None:
"""One lot, far below the market: the order rests. The cid is in the ledger before the call."""
params = await self.client.trading_params(self.instrument)
cid = brokapi.new_cid()
self.ledger.add(cid)
await self.client.place(
cid=cid, instrument=self.instrument, side=brokapi.Side.BUY, qty=params.min_qty, price=Decimal("100.00")
)
self.cid = cid
self.placed.set()
async def run(bot: Bot) -> None:
"""Sets up, places the order, and reads the events until the order has ended."""
await bot.setup()
await bot.place()
async for envelope in bot.client.events:
await bot.on_event(envelope)
if bot.ended is not None:
break
Пример запускается тестом против имитации БКС: бот ставит заявку, гейтвей перезапускают, бот получает Gap, подписывается заново и сверяется, заявку исполняют, и бот
видит исполнение через новый процесс гейтвея. Тот же бот в другом тесте работает с брокером напрямую, без гейтвея. Весь файл: crates/python/examples/gateway_bot.py.
Что так же, что иначе
| Через гейтвей | |
|---|---|
Методы Client: рынок, счет, заявки, стоп-заявки, история свечей, raw | Те же, с теми же типами и ошибками. Decimal для денег, Unknown для потерянного ответа на команду |
| Торговля до сверки | Закрыта: place, cancel, replace, place_stop, cancel_stop дают NotReconciled до первой успешной reconcile. Клиент отказывает сам, команда не уходит. Гейтвей этого не требует, правило то же, что у коннектора |
Поток client.events | Тот же async for. В source стоят <гейтвей>-md и <гейтвей>-ud: соединение с брокером назвал гейтвей. После close() дочитывается то, что уже пришло, потом цикл кончается |
Обрыв связи с гейтвеем, медленный бот (Lagged) | Клиент сам открывает поток заново и продолжает с номера последнего события. Если гейтвей жив и кольцо не обогнало, бот не замечает обрыва |
| Потеря событий | Новое событие brokapi.Gap в client.events. Что это и что делать - ниже |
client.limiter, limiter= | Нет, Unsupported. Счетчик лимитов один на гейтвей, общий для всех его ботов, и живет не в вашем процессе |
close() | Закрывает только соединение бота. Гейтвей работает, заявки на бирже стоят, подписки бота гейтвей отпустит через bot_grace. Отчетов остановки нет: список пустой |
raw() | Работает так же, квота гейтвея. Метод со стримом отвергается, как и при прямом подключении |
Запись событий (brokapi.record) | record_events пропускает Gap дальше, но в файл его не пишет: файл хранит события коннектора |
| Тесты | mock.start_gateway() в brokapi.testing поднимает гейтвей на имитации: Тесты против имитаций |
Если потерялись события: Gap
Gap приходит как событие с полями reason, requested_after, first_available. Это то же, что Overflow и Reconnected у коннектора, и правило то же:
торговля закрыта (Degraded) до следующей reconcile, а поток продолжается с текущего места, переоткрывать ничего не нужно. Бот читает состояние счета
(active_orders, order_state, executions) и сверяется.
reason бывает трех видов, как у кадра Gap в События и сверка после перезапуска:
"too_old": бота не было дольше, чем гейтвей хранит события. Подписки на месте."new_epoch": гейтвей перезапустили, это новый процесс. Подписок бота у него нет,gap.subscriptions_lostравноTrue: подпишитесь заново (номера подписок новые), потом сверьтесь."ahead": номер бота впереди гейтвея. Сверьтесь.
Подписки после new_epoch клиент сам не восстанавливает: набор подписок знает бот, и он же решает, какие из них еще нужны. Удобно собрать их в одну функцию и звать
ее и на старте, и на Gap, как setup в примере.
Бот на другом языке
Готового клиента для Go пока нет (для Python он есть, выше). Любой язык, в котором есть gRPC, подключается сам: контракт - файлы .proto в каталоге crates/gateway/proto/ репозитория (они же лежат в пакете brokapi-gateway на crates.io), справочник
по ним - Gateway (gRPC). Что нужно сделать:
- Сгенерировать клиент из
proto/brokapi/gateway/v1/gateway.proto(он подключаетtypes.proto) обычным для вашего языка инструментом (protocс плагином gRPC илиbuf). - Подключиться по адресу из
gateway.addr. Адрес берите из файла, путь до сокета самим не собирайте, и читайте файл заново при каждом переподключении: перезапущенный гейтвей на Windows слушает уже на другом порту. gRPC понимает адрес сокета Unix в видеunix:/путь/к/сокету. Шифрования нет: канал открытый, но локальный. - Слать два заголовка с каждым вызовом:
authorization: Bearer <ключ>иx-brokapi-bot: <имя>. - Соблюдать порядок: открыть
Events, дождаться кадраReady, подписаться, вызватьReconcile, торговать. - Читать значения как договорено: цена, количество и деньги - строки с десятичным числом (не
double), время - целое число наносекунд в полях*_ns,cid- UUID с дефисами, который придумывает бот. - Разбирать ошибки из двоичного заголовка ответа
brokapi-error-bin: это сообщениеErrorDetail. Код статуса gRPC - подсказка общим инструментам, решает деталь.
Адрес unix: и точное имя для вашего языка смотрите в документации gRPC (формат имен: github.com/grpc/grpc/blob/master/doc/naming.md): здесь это не проверялось на Go и на grpcio для Python
(пакет brokapi для Python ими не пользуется). Клиент для Go появится как часть этого раздела.
Ошибки
Ошибка библиотеки приходит как статус gRPC с заголовком brokapi-error-bin. Код статуса показывает общий вид, настоящий вид - в поле kind детали.
kind в детали | Код gRPC | Команда выполнялась? | Что делать |
|---|---|---|---|
CONFIG | INVALID_ARGUMENT | нет | Исправить запрос или настройку. Повтор не поможет |
TRANSPORT | UNAVAILABLE | для чтения неважно, для команды нет, если ответ потерян | Подождать и повторить чтение. Команду выясняйте через OrderState |
RATE_LIMITED | RESOURCE_EXHAUSTED | нет | Подождать retry_after_ns и повторить |
REJECTED | NOT_FOUND или FAILED_PRECONDITION | нет | Брокер отказал. Код отказа в поле code (not_found, price_off_step, …) |
UNKNOWN | UNKNOWN | неизвестно | Не повторять. Выяснить через OrderState и сверку: Если что-то пошло не так |
UNSUPPORTED | UNIMPLEMENTED | нет | У брокера нет такой возможности, ее имя в поле capability |
NOT_RECONCILED | FAILED_PRECONDITION | нет | Сделать Reconcile и повторить |
DEGRADED | ABORTED | нет | Данные о заявках могли потеряться: Reconcile |
SHUTTING_DOWN | UNAVAILABLE | нет | Гейтвей останавливается |
GATEWAY_STARTING | UNAVAILABLE | нет | Гейтвей еще сверяет журнал. Ждать Health = READY |
BAD_REQUEST | INVALID_ARGUMENT | нет | Запрос не разобран: плохой cid, плохое число |
Две ошибки не про брокера: UNAUTHENTICATED (нет заголовка с ключом или ключ чужой) и INVALID_ARGUMENT с текстом про имя бота (имя не из допустимых символов).
Значение каждой ошибки библиотеки: Ошибки и что с ними делать.
Потерянный ответ на команду. Если гейтвей не ответил на команду (связь оборвалась, вышел срок), клиент возвращает Unknown и не повторяет вызов: гейтвей мог
команду выполнить. Если же гейтвей ответил отказом GATEWAY_STARTING или BAD_REQUEST, команда точно не выполнялась, и ее можно повторить.
Запуск и обслуживание
Инструкции для того, кто держит службу brokapi постоянно: как запустить ее вместе с системой, что она делает при сбоях, как остановить гейтвей и снять заявки,
какие файлы где лежат и что делать, когда что-то не запускается. Первый запуск по шагам: Быстрый старт.
Постоянный запуск (systemd)
brokapi up без -d работает на переднем плане и печатает, что запустилась: это ровно то, что нужно менеджеру служб. Когда systemd посылает SIGTERM, служба
останавливает гейтвеи (каждому дается stop_timeout, по умолчанию 10 секунд) и выходит. Образец юнита /etc/systemd/system/brokapi.service:
[Unit]
Description=brokapi service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=brokapi
ExecStart=/usr/local/bin/brokapi -c /etc/brokapi/brokapi.toml up
EnvironmentFile=/etc/brokapi/tokens.env
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
Три замечания к образцу:
- Токены лежат в
/etc/brokapi/tokens.envстрокамиTBANK_TOKEN=...с правами 600 и владельцем изUser=. В конфиге на них ссылаетсяtoken_env. Либоtoken_fileна файл с теми же правами. -cс полным путем. У юнита нет рабочего каталога сbrokapi.toml, а относительныйstate_dirсчитается от каталога конфига, так что-cдостаточно.- Пользователь без прав входа. Служба хранит в каталоге состояния ключи доступа ботов. Этот каталог должен принадлежать тому же пользователю и никому больше.
Это образец, а не команда установки: подстройте пути и пользователя под себя. Установки службы одной командой пока нет, ни для systemd, ни для launchd на macOS и службы Windows.
На macOS и Windows службу можно запустить в фоне командой brokapi up -d, после перезагрузки машины ее придется запустить заново.
Несколько служб на одной машине
Каждый конфиг - своя служба: свой каталог состояния, свой сокет, свои гейтвеи. brokapi -c prod.toml up -d и brokapi -c test.toml up -d не мешают друг другу, а brokapi services
показывает обе:
$ brokapi services
PID GATEWAYS UPTIME CONFIG STATE DIRECTORY
4121 2/2 running 3h02m /home/me/bots/prod.toml /home/me/bots/prod.state
4377 1/1 running 12m07s /home/me/bots/test.toml /home/me/bots/test.state
Две службы на один каталог состояния не пустит ни одна: вторая откажется запускаться и назовет pid и конфиг первой. Так сделано, чтобы два процесса не делили
журнал заявок и сокеты. Если конфиги лежат рядом, у них разные имена файлов и значит разные каталоги по умолчанию (prod.state, test.state).
Что происходит при сбоях
| Что случилось | Что делает программа | Что делать вам |
|---|---|---|
| Гейтвей упал или убит | Служба запускает его заново: сначала через 0,5 секунды, потом через 1, 2, 4 и так до 30 секунд. После 5 остановок за минуту гейтвей получает состояние failed и служба оставляет его в покое | Если failed: brokapi logs <гейтвей> покажет причину, после исправления brokapi start <гейтвей>. Боты получат Gap и сверятся: События |
| Служба умерла | Гейтвеи продолжают работать, боты ничего не замечают. Новая служба (brokapi up с тем же конфигом) находит их по pid-файлам и сокетам и берет под присмотр | brokapi up -d. Гейтвей с именем brokapi, который не отвечает, будет убит: иначе он держал бы сокет |
| Машину перезагрузили | Ничего не работает, заявки на бирже остались | brokapi up -d. Гейтвей сверит журнал с брокером до приема команд. Боты получат Gap с причиной NEW_EPOCH |
| Гейтвей завис | ps показывает прочерки вместо цифр в колонках BOTS, EVENTS/S, ORDERS, LIMIT: гейтвей не отвечает | brokapi restart <гейтвей> |
| Токен перестал подходить | Гейтвей не может стартовать или переподключиться. Причина обычно видна в LAST ERROR команды ps и в строках ERROR журнала | Заменить токен, brokapi restart <гейтвей> |
Остановить гейтвей и снять заявки
Заявка на бирже живет дольше, чем процесс, который ее поставил. Поэтому остановка гейтвея заявок не снимает: ни stop, ни down, ни перезагрузка. Снять их можно двумя способами.
| Команда | Что делает |
|---|---|
brokapi stop <гейтвей> --cancel-orders | Сначала снимает активные и стоп-заявки счета, потом останавливает гейтвей. Если часть не снялась, команда перечисляет их и завершается с кодом 1 |
brokapi cancel-all <гейтвей> | Только снимает заявки, гейтвей продолжает работать. Это аварийная кнопка, когда бот сошел с ума. На отказ по лимиту ждет и повторяет |
Остановка без снятия: brokapi stop <гейтвей> останавливает один гейтвей и держит его остановленным до brokapi start. brokapi down останавливает службу и все гейтвеи,
brokapi down --leave-gateways - только службу, гейтвеи остаются работать (следующий up подхватит их).
На Linux и macOS гейтвею посылается SIGTERM, и если он не вышел за stop_timeout, его завершают принудительно. На Windows у процесса без окна нет просьбы остановиться,
поэтому он завершается сразу, и в его журнале не будет строки stopped. Журнал и сверка при старте рассчитаны на внезапный конец, заявки это не теряет.
Менять конфиг, не останавливая службу
Правьте brokapi.toml и применяйте:
brokapi reload- привести работающие гейтвеи в соответствие с файлом: новые запустить, убранные остановить, измененные перезапустить, остальные не трогать.brokapi add <гейтвей>- запустить один гейтвей из файла.brokapi remove <гейтвей>- остановить и забыть его в памяти службы.
remove не правит файл. Пока гейтвей остается в файле, reload вернет его. Чтобы убрать насовсем, удалите раздел из brokapi.toml. Изменения state_dir, раздела [service] и listen
без перезапуска службы не применяются.
Какие файлы где лежат
brokapi.state/ каталог состояния службы (state_dir)
service.key ключ доступа к службе, права 600
service.addr где служба слушает
service.sock сокет службы (Linux, macOS)
service.pid pid службы
logs/service.log журнал службы
logs/<гейтвей>.log журнал гейтвея
gateways/<гейтвей>/ один гейтвей
journal.jsonl журнал заявок
access.key ключ доступа ботов, права 600
gateway.addr где слушает гейтвей
gateway.sock сокет гейтвея (Linux, macOS)
gateway.pid pid гейтвея
Путь до сокета Unix не может быть длиннее 107 байт на Linux и 103 на macOS. Каталог состояния в ~/Library/Application Support/...
с длинным именем гейтвея легко получает путь длиннее. Тогда программа не отказывается стартовать: сокет кладется в личный каталог
$XDG_RUNTIME_DIR/brokapi-<uid> или /tmp/brokapi-<uid> (права 700, только ваш пользователь, чужой или подмененный каталог не берется),
а настоящий адрес записывается в service.addr и gateway.addr. Команды и боты читают адрес из этих файлов, так что ничего настраивать не нужно.
Сокет убирается, когда гейтвей или служба останавливаются. Если даже так не получилось, ошибка называет причину и предлагает короче state_dir
или свой короткий путь в listen. Если вы сами задали в listen слишком длинный путь, это тоже ошибка с пояснением: за вас его не меняют.
Журналы ротируются по размеру ([service.logs]): max_size_mb и keep. Каталог состояния не кладите в репозиторий и в общую копию: в нем ключи доступа к службе и гейтвеям.
Токены брокеров там не лежат, они только в переменной окружения или в token_file.
Реестр служб машины (его читает brokapi services) хранится отдельно: в ~/.local/state/brokapi/services на Linux, на macOS и Windows в каталоге локальных данных пользователя.
Если что-то пошло не так
| Что видно | Что значит | Что делать |
|---|---|---|
no config file found. Looked at: ... (код 2) | Конфиг не найден | Указать -c, перейти в каталог с файлом или сделать brokapi init |
the environment variable ... (token_env) is not set | В окружении службы нет переменной с токеном | Для systemd - EnvironmentFile=. В терминале - export перед brokapi up |
the state directory ... is used by another service (pid ..., config ...) | Для этого каталога служба уже работает | brokapi down для нее или другой state_dir в этом конфиге |
a service already runs for ... (see brokapi services) | up -d на уже запущенный конфиг | Ничего: служба работает |
no service is running for ... (код 3) | Службы нет | brokapi up -d |
the gateway ... is not running. Start the service with ..., or the gateway with brokapi start ... (код 4) | Гейтвей не запущен (остановлен, failed, autostart = false) | brokapi ps, потом brokapi start <гейтвей> |
the service refused the access key | Ключ в service.key не от этой службы | Команда ходит в тот же state_dir, что и служба? Проверьте -c |
the service ended at once (...) и хвост журнала | Служба при up -d сразу вышла | Хвост журнала называет причину, чаще всего это конфиг |
... has mode 644: the access key must be readable by its owner only (chmod 600) | У access.key чужие права, гейтвей с ним не стартует | chmod 600 на файл |
the address ... is not on this machine: the gateway listens on loopback only | В listen адрес не этой машины | Только 127.0.0.1 или сокет Unix |
another gateway is already listening at ... | Тот же сокет занят живым гейтвеем | Это второй процесс того же гейтвея: brokapi ps, лишний остановить |
У бота the access key is missing or wrong | Нет заголовка authorization или ключ не из access.key этого гейтвея | Подключить бота |
События и сверка после перезапуска
Объяснение: как гейтвей отдает боту события, как бот догоняет пропущенное после обрыва и что он обязан сделать, если догнать нельзя. Если вы пишете бота, прочитайте страницу целиком: на ней весь порядок действий после сбоя. Как подключиться и в каком порядке вызывать: Подключить бота.
Что приходит в потоке
Бот открывает вызов Events и читает кадры четырех видов:
| Кадр | Что значит |
|---|---|
Ready | Всегда первый. Поток зарегистрирован в гейтвее: все, что происходит после него, уже предназначено этому боту. Несет epoch, номер следующего события и число событий, которые сейчас придут из кольца |
Event | Событие: заявка, исполнение, стакан, портфель и так далее, как у коннектора. У каждого есть номер seq, он растет на единицу |
Gap | Пропущенное вернуть нельзя. Причина: NEW_EPOCH, TOO_OLD или AHEAD. Поток продолжается с текущего места |
Lagged | Бот читал слишком медленно, буфер переполнился, поток закрыт. Несет номер последнего доставленного события |
epoch - случайное число, новое при каждом запуске процесса гейтвея. seq - порядковый номер события в пределах этого запуска. Пара
epoch и seq однозначно называет место в потоке. Бот хранит ее после каждого обработанного события.
Кольцо. Гейтвей держит в памяти последние события: не больше 10 000 штук и не старше 5 минут (настраивается в [gateway.ring], см.
Конфиг). Из него и берется то, что бот пропустил, пока был отключен.
Сначала поток, потом подписки. Бот открывает Events, дожидается Ready и только потом подписывается. Кадр Ready означает, что с этого момента
поток записан в гейтвее. Подпишись бот раньше, события между подпиской и открытием потока не увидел бы никто, и заявка, поставленная в эту щель, осталась бы
без своих состояний.
Бот на Python этих кадров не видит. Пакет brokapi читает поток сам: Ready, номера, обрыв и Lagged он закрывает переоткрытием потока с последнего номера, а Gap отдает
событием brokapi.Gap в обычном client.events. Торговля после него закрыта (Degraded) до reconcile. Подробно: Подключить бота.
Что получает бот после обрыва
Бот переподключается к Events с epoch и last_seq последнего обработанного события. Схема показывает, что гейтвей ответит.
flowchart TB
open["бот открывает Events<br/>с epoch и last_seq"] --> same{"epoch<br/>тот же?"}
same -->|"нет"| g1["Gap: NEW_EPOCH"]
same -->|"да"| ring{"last_seq<br/>в кольце?"}
ring -->|"старше кольца"| g2["Gap: TOO_OLD"]
ring -->|"впереди гейтвея"| g3["Gap: AHEAD"]
ring -->|"да"| ok["пропущенное из кольца,<br/>потом живые события"]
g1 --> rec["Reconcile и чтение счета"]
g2 --> rec
g3 --> rec
Три ветки кончаются Gap, и во всех бот делает одно и то же: сверку. Единственная ветка без Gap - та, где сверка не нужна: бот получает пропущенные
события по порядку, а за ними живые, без дыр. Без epoch в запросе поток только живой: ничего из прошлого бот не получит и не ждет.
После Gap поток идет с текущего места: номера в нем продолжаются, но события между last_seq и этим местом бот не увидит никогда.
Поэтому бот не может считать, что знает состояние счета. Он делает Reconcile и заново читает то, что ему нужно (портфель, активные заявки).
Что случилось и что делать
| Что случилось | Что бот получит | Что делать |
|---|---|---|
| Связь с гейтвеем пропала на секунды | После переподключения с last_seq - пропущенные события из кольца, без Gap | Ничего. Продолжать |
| Бота не было дольше кольца (больше 5 минут или 10 000 событий) | Gap с причиной TOO_OLD | Reconcile, чтение счета |
Гейтвей перезапустили (упал, brokapi restart, перезагрузка машины) | Gap с причиной NEW_EPOCH. Подписок у нового процесса нет | Подписаться заново, Reconcile, чтение счета. Пока гейтвей сверяет журнал, вызовы получают UNAVAILABLE (GATEWAY_STARTING): ждите Health |
| Бот читал слишком медленно | Все, что накопилось в буфере, затем Lagged и закрытие потока | Переподключиться с last_seq из Lagged: догон придет из кольца. Читать быстрее или разгрузить обработчик |
Бота не было дольше 30 секунд (bot_grace) | Подписки бота сняты гейтвеем | Подписаться заново. Повторная подписка безвредна: одинаковые подписки считаются один раз |
last_seq впереди гейтвея (AHEAD) | Gap | Номер больше, чем гейтвей выдавал в этом запуске: бот хранит чужой или испорченный номер. Reconcile, дальше жить с номерами из Ready |
Перезапуск гейтвея и открытые заявки
Заявки, стоящие у брокера, перезапуск гейтвея не задевает. Что происходит:
- Перед отправкой каждой заявки гейтвей записывает ее
cidв журнал на диске (journal.jsonlв каталоге гейтвея). - После старта гейтвей сначала сверяет журнал с брокером и только потом принимает команды. До этого
Healthотвечаетstarting, остальные вызовы (кромеHealthиCaps) получаютUNAVAILABLEс причинойGATEWAY_STARTING. Если сверка не вышла (лимит, сеть), она повторяется с паузой от полсекунды до 30 секунд, а последняя ошибка видна вHealth.detailи вbrokapi status <гейтвей>. - Заявку закрывает в журнале то, что точно знает об ее конце: окончательный отказ брокера, финальное событие заявки или сверка (
closed_offline). Заявки, про которые неизвестно, чем они кончились, остаются открытыми. - Бот после
Gapделает свойReconcileсо своим спискомcid. Заявка, у которой не было ответа, находится там же: она в списке бота, и сверка скажет, стоит ли она у брокера.
Почему кольцо не записывается на диск. Пока процесс гейтвея мертв, события брокера никто не принимает, и диск их не вернет. Кольцо на диске
спасло бы только хвост между последним прочитанным ботом событием и падением, а сверку после перезапуска бот делает в любом случае: состояние счета берется
у брокера, заявки держит журнал. Цена диска - запись на каждое событие. Поэтому после перезапуска процесса бот получает Gap с причиной NEW_EPOCH и сверяется.
Чего сверка не возвращает. Исполнения заявок, которые прошли, пока гейтвея не было, сверка называет closed_offline и не пересказывает. Состояние
такой заявки спросите вызовом OrderState, а сделки - вызовом Executions за интервал простоя.
Как это выглядит в коде
Бот на Rust: открыть поток, подписаться, при Gap подписаться заново и сверить, при Lagged переподключиться с места остановки. Пример запускается как тест:
crates/gateway/examples/bot.rs, подробности и остальной код - в Подключить бота.
/// Reads the events of the gateway until `done` is true, and does what the stream asks for on the way.
///
/// `resume` is where the previous stream stopped (`None` at the first start). `journal` is the bot's own list of the orders
/// it placed and has not seen end.
pub async fn follow(
bot: &RemoteClient,
mut resume: Option<Resume>,
journal: &[ClientOrderId],
mut on_event: impl FnMut(&Envelope) -> bool,
) -> Result<u32, Error> {
let mut gaps = 0;
loop {
// Open the stream first: from the moment `events` returns, the gateway keeps what happens for this bot.
let mut stream = bot.events(resume, true).await?;
// Then subscribe. Asking again is harmless, the gateway counts the request once for the bot.
bot.subscribe_order_state().await?;
while let Some(next) = stream.next().await {
match next? {
RemoteEvent::Event { envelope, .. } => {
if on_event(&envelope) {
return Ok(gaps);
}
}
RemoteEvent::Gap(gap) => {
// The gateway no longer has the events after our number. Whatever they said, the account is
// not what we last saw: ask the broker. A new epoch means a new process, which holds no subscriptions.
gaps += 1;
if gap.reason == pb::gap::Reason::NewEpoch as i32 {
bot.subscribe_order_state().await?;
}
bot.reconcile(journal).await?;
}
RemoteEvent::Lagged { .. } => break, // we read too slowly: reconnect from the resume point
}
}
// The stream ended (the gateway restarted, the connection broke) or we lagged: continue where we stopped.
resume = Some(stream.resume_point());
}
}
Чем гейтвей отличается от прямого подключения
Объяснение для тех, кто уже пишет ботов на библиотеке и переносит бота на гейтвей. Правила те же: cid придумывает бот, команда без ответа
не повторяется, торговля после сверки (три правила). Клиент RemoteClient умеет те же трейты
ядра (OrderExecutor, OrderBookSource, PortfolioSource и остальные), поэтому код бота почти не меняется. Меняется дорога: между ботом и брокером
появился еще один процесс. Из-за этого есть отличия, и каждое объяснено.
Схема показывает, откуда берутся отличия. Ответ на команду может потеряться на каждом из двух участков дороги. В библиотеке дорога одна, участок Б.
flowchart TB
bot["бот"] -->|"участок А"| gw["гейтвей"]
gw -->|"участок Б"| br[("брокер")]
Потерялся ответ на участке Б: гейтвей не знает, дошла ли команда до брокера, и бот получает Unknown. Потерялся на участке А: не знает уже бот, а гейтвей
мог команду выполнить. Поэтому Unknown приходит и в этом случае, а клиент не повторяет команду ни на одном из участков.
| Что | В библиотеке | Через гейтвей | Почему |
|---|---|---|---|
| Ответ на команду потерян | Unknown, если потерян ответ брокера | Unknown и тогда, когда потерян ответ гейтвея: оборвалась связь с ним или вышел срок. Клиент не повторяет команду | Гейтвей мог ее выполнить. Повтор дал бы вторую заявку. Что делать: Если что-то пошло не так |
| Бот перестал ждать | Решает код бота | Команда на гейтвее выполняется до конца, даже если вызов бросили | Иначе отмена вызова оставила бы неизвестное состояние: заявка ушла к брокеру или нет |
| Журнал заявок | Ведет бот | Гейтвей ведет еще и свой журнал cid на диске | Чтобы после его перезапуска сверить заявки с брокером до приема команд. Свой журнал бот все равно ведет: сверка бота идет по его списку |
| События | Канал mpsc, Resync и Overflow | Поток Events с номерами, кадры Gap и Lagged | Между гейтвеем и ботом событие может потеряться, а нумерация и кольцо позволяют это заметить и догнать. Подробно: События |
| Подписки | Подписка принадлежит боту | Подписку открывает бот, но события подписки получают все боты, которые читают поток, а владельцев у подписки может быть несколько | Подписка стоит квоты у брокера, поэтому одинаковые подписки склеиваются. Фильтр own_orders_only оставляет в потоке только события ваших заявок |
| Лимиты | Счетчик в процессе бота | Один счетчик на гейтвей, общий для его ботов, очереди между ботами нет | Лимит брокера один на счет, а не на бота |
| Сверка бота | reconcile(ledger) сверяет ваш список с брокером | То же, но заявки других ботов этого гейтвея возвращаются как foreign. Флаг include_journal в запросе добавляет к вашему списку журнал гейтвея (клиент Rust его не выставляет, из других языков он доступен) | Гейтвей видит заявки всех своих ботов, бот - только свои |
| Старт | Бот сам открывает соединение | Гейтвей мог еще не закончить сверку: вызовы получают UNAVAILABLE с причиной GATEWAY_STARTING, команда точно не выполнялась | Пока журнал не сверен, принимать заявки нельзя. Ждать готовности: вызов Health |
| Остановка | shutdown не снимает заявки на бирже | Остановка гейтвея заявки тоже не трогает. Снять их: brokapi stop <гейтвей> --cancel-orders или brokapi cancel-all | Заявка на бирже переживает процесс, и это правильно: она может быть частью стратегии |
| Первый снимок портфеля БКС | Подписка на портфель присылает снимок | Подписка на портфель БКС присылает только изменения: гейтвей сверяется на старте, и первый снимок до подписки уже прошел. Начальное состояние бот читает вызовом Portfolio | Так ведет себя коннектор БКС при открытии сокета портфеля |
| Доступ | Токен брокера в процессе бота | Токен брокера только у гейтвея. Боту нужен ключ access.key и имя | Боты не видят токена брокера. Ключ дает доступ только к гейтвею и только с этой машины |
Если бот на Python
Клиент для Python - тот же Client, что при прямом подключении, и все отличия выше к нему относятся. Три добавки к ним, с причинами:
- Торговля до сверки закрыта и на стороне бота. Гейтвей сам этого не требует, но
Clientдержит правило библиотеки:NotReconciledдо первойreconcileиDegradedпосле потери событий до следующей. Иначе бот, перенесенный на гейтвей, молча торговал бы там, где у брокера получил бы отказ. - Потеря событий - событие
Gap, а не отдельный кадр потока: бот читает одинasync for, как раньше. Подробности: Подключить бота. - Нет
client.limiter. Счетчик один на гейтвей, он общий для всех ботов и не в вашем процессе.
Что не меняется
- Номер заявки бот придумывает сам и записывает до отправки.
- Ошибки те же:
Rejected,RateLimited,Unsupported,NotReconciled,Degraded,Unknown. Гейтвей добавляет две причины отказа, при которых команда точно не выполнялась:GATEWAY_STARTINGиBAD_REQUEST. Все: Ошибки и что с ними делать и таблица в «Подключить бота». - Деньги и цены - десятичные числа, на проводе строками.
- Любой метод брокера доступен через
Raw: имя метода и аргументы в JSON.
Чего гейтвей пока не делает
- Очередь между ботами по кругу. Лимитер отказывает сразу, очереди ожидания в нем нет: гейтвей не держит чужую команду и не меняет поведение «команда
не ждет». Один бот может занять квоту целиком, остальные получат
RateLimited. - Доступ с других машин и шифрование. Только этот компьютер.
Конфиг brokapi.toml
Справочник по файлу, который читает программа brokapi. Один файл - одна служба: в нем перечислены гейтвеи (по одному на счет) и то, как
служба за ними следит. Как работать с файлом по шагам: Быстрый старт. Команда brokapi init пишет образец с комментариями,
brokapi check --offline проверяет файл, не обращаясь к брокерам.
Пример
Два гейтвея на одной службе: песочница Т-Банка и счет АЛОР. Токены берутся из переменной и из файла:
state_dir = "state"
[service]
stop_timeout = "10s"
[service.restart]
initial = "500ms"
max_pause = "30s"
max_restarts = 5
window = "60s"
[[gateway]]
name = "tbank-sandbox"
broker = "tbank"
account = "2000000000"
sandbox = true
token_env = "TBANK_TOKEN"
[[gateway]]
name = "alor-main"
broker = "alor"
account = "D12345"
token_file = "/etc/brokapi/alor.token"
autostart = false
[gateway.ring]
size = 20000
max_age = "10m"
[gateway.limits.http]
rate = 50
per = "1s"
burst = 50
Разделы [gateway.ring] и [gateway.limits.*] относятся к ближайшему сверху [[gateway]], то есть в примере - к alor-main.
У Bybit пара из ключа и секрета, поэтому гейтвей читает два значения: секрет как токен (token_env или token_file), ключ из key_env или key_file. sandbox = true - демо-счет
(у него своя пара ключей, BYBIT_DEMO_API_KEY и BYBIT_DEMO_API_SECRET), без него гейтвей работает на основном контуре:
state_dir = "state"
[[gateway]]
name = "bybit-demo"
broker = "bybit"
sandbox = true
token_env = "BYBIT_DEMO_API_SECRET"
key_env = "BYBIT_DEMO_API_KEY"
Где лежит файл
Команда ищет конфиг по порядку: ключ -c <файл>, переменная BROKAPI_CONFIG, ./brokapi.toml, brokapi.toml в каталоге настроек
пользователя (на Linux ~/.config/brokapi, на macOS и Windows свои каталоги). Ничего не нашлось - код выхода 2 и список мест, где искали.
Служба определяется файлом: у двух файлов разные каталоги состояния и разные сокеты, значит, это две независимые службы. Две службы на один каталог состояния программа не пустит: вторая откажется запускаться и назовет pid и конфиг первой.
Верхний уровень
| Ключ | Тип | По умолчанию | Что значит |
|---|---|---|---|
state_dir | путь | <имя файла без .toml>.state рядом с конфигом | Каталог, где служба и гейтвеи держат свои файлы: журналы заявок, ключи доступа, сокеты, pid-файлы, логи. Относительный путь считается от каталога конфига |
[service] | раздел | см. ниже | Как служба следит за гейтвеями |
[[gateway]] | список разделов | пусто | Гейтвеи: по одному разделу на счет |
Имя по умолчанию берется из имени файла, поэтому prod.toml и test.toml в одном каталоге получают разные каталоги состояния
(prod.state и test.state) и не сталкиваются.
Любое неизвестное поле - ошибка с именем поля, а не молчаливый пропуск: опечатка в названии не превратится в настройку по умолчанию.
[service]
| Ключ | По умолчанию | Что значит |
|---|---|---|
listen | сокет в каталоге состояния (Linux, macOS; если путь длиннее, чем принимает система, то в личном коротком каталоге, см. Какие файлы где лежат), свободный порт 127.0.0.1 (Windows) | Где служба слушает команды: unix:<путь> или tcp:127.0.0.1:<порт>. Адрес должен быть на этом компьютере (127.0.0.1, ::1) |
check_interval | "1s" | Как часто служба проверяет свои гейтвеи |
stop_timeout | "10s" | Сколько гейтвею дается на остановку по просьбе, после этого его завершают принудительно |
[service.restart]
Что служба делает, когда гейтвей остановился сам (упал, был убит):
| Ключ | По умолчанию | Что значит |
|---|---|---|
initial | "500ms" | Пауза перед первым перезапуском. После каждого следующего пауза вдвое длиннее |
max_pause | "30s" | Дольше этого пауза не растет |
max_restarts | 5 | Сколько перезапусков за window разрешено. Не меньше 1 |
window | "60s" | Период, в котором перезапуски считаются |
Когда за window набралось max_restarts остановок, гейтвей получает состояние failed, служба перестает его запускать и ждет
brokapi start <имя>. Почему так: гейтвей, который падает сразу после старта (токен отозван, счет закрыт), без предела будет бесконечно
стучаться к брокеру. Гейтвей, который проработал дольше window, начинает счет с нуля. Запуск руками (start, restart) тоже обнуляет счет.
[service.logs]
| Ключ | По умолчанию | Что значит |
|---|---|---|
max_size_mb | 10 | При каком размере файл закрывается и начинается новый, в мегабайтах |
keep | 5 | Сколько старых файлов хранить рядом с текущим |
Журналы лежат в <state_dir>/logs/: service.log и по файлу на гейтвей (<имя>.log). Читать их удобнее командой brokapi logs.
[[gateway]]
Один раздел - одно подключение: брокер, счет и токен. Бот подключается к гейтвею, а не к брокеру.
| Ключ | Обязателен | Что значит |
|---|---|---|
name | да | Имя гейтвея. Команды обращаются к нему по имени, оно же имя каталога <state_dir>/gateways/<имя>/. От 1 до 32 символов: буквы, цифры, ., _, -. Имена не повторяются |
broker | да | bcs, tbank, finam, alor или bybit |
account | нет | Счет: account_id у Т-Банка и Финама, portfolio у АЛОР. У БКС и Bybit выбора счета нет (ключ Bybit принадлежит одному счету), поле с ним - ошибка |
sandbox | нет | true - песочница брокера (у Т-Банка sandbox, у АЛОР test, у Bybit демо-счет). У БКС и Финама песочницы нет, sandbox = true для них - ошибка |
token_env | один из двух | Имя переменной окружения с токеном |
token_file | один из двух | Путь к файлу с токеном (весь файл, без пробелов по краям) |
key_env, key_file | только Bybit, один из двух | Ключ API Bybit: имя переменной окружения или путь к файлу. У Bybit пара из ключа и секрета, секрет задают как токен (token_env или token_file), а ключ этими двумя полями |
autostart | нет, true | false - служба не запускает гейтвей на up, он ждет brokapi start |
bot_buffer | нет, 1024 | Сколько событий копится для одного бота. Бот, у которого буфер переполнился, считается слишком медленным: его поток закрывается кадром Lagged, остальные боты не замечают. См. События |
bot_grace | нет, "30s" | Сколько бот может отсутствовать, прежде чем гейтвей отпустит его подписки |
[gateway.ring] | нет | Кольцо последних событий: size (по умолчанию 10000) и max_age (по умолчанию "5m"). От него зависит, как давно бот может отключиться и все равно получить пропущенное |
[gateway.limits.<ведро>] | нет | Заменяет ведро лимита брокера: rate, per, burst, все три обязательны |
[gateway.settings] | нет | Остальные настройки коннектора, как в библиотеке: endpoint, write, exchange, app_name и другие |
Токен - ровно один из двух способов. Оба сразу или ни одного - ошибка конфига. Токен в [gateway.settings] (token, refresh_token,
secret) тоже ошибка: токен не должен попадать в файл, который хранят и копируют.
Файл с токеном должен читаться только вами (chmod 600). brokapi check предупреждает, если это не так (на Linux и macOS).
Путь к нему лучше писать абсолютный: относительный читается от рабочего каталога процесса, а не от каталога конфига.
БКС и заявки. Для торговли у БКС нужен токен на запись и настройка write = true в [gateway.settings], как в библиотеке
(BCS_IS_WRITE). Без этого брокер торговые команды не примет.
Ведра лимита
Гейтвей создает один счетчик лимитов брокера на всех ботов. В [gateway.limits.<ведро>] можно заменить значения одного ведра. Названия
ведер у каждого брокера свои. Их видно в таблице rate limit of the broker команды brokapi status <гейтвей>, а как ведра устроены
и зачем у отмены отдельное: Лимиты запросов. Неизвестное имя - ошибка конфига, в которой перечислены известные.
Длительности
Везде, где ждет время, строка с единицей: "500ms", "30s", "5m", "2h". Число без единицы - секунды.
Что дальше
- Проверить файл:
brokapi check --offline(только файл и токены),brokapi check(еще и чтение счета). - Применить изменения без остановки службы:
brokapi reload. Раздел изменился - гейтвей перезапускается, не изменился - нет. Измененияstate_dir,[service]иlistenбез перезапуска службы не применяются. - Настройки коннекторов: страницы брокеров БКС, Т-Банк, Финам, АЛОР.
Команды brokapi
Справочник по программе brokapi: все команды, общие флаги, коды выхода. Как начать работать: Быстрый старт. Зачем она нужна и как
устроена: Gateway.
Таблица ниже написана по задачам, а в конце страницы стоит точный текст brokapi <команда> --help каждой команды. Он собран из самой
программы: сборка книги падает, если команда или флаг изменились, а страница нет.
Общие правила
- Конфиг. Каждая команда берет конфиг из
-c <путь>. Без него: переменнаяBROKAPI_CONFIG, потом./brokapi.toml, потомbrokapi.tomlв каталоге настроек пользователя (на Linux~/.config/brokapi). Конфиг задает каталог состояния и сокет службы, поэтому на одной машине можно держать сколько угодно служб:brokapi -c prod.toml upиbrokapi -c test.toml upне мешают друг другу. --json. У каждой команды, которая что-то печатает, есть ключ--json: тот же ответ в JSON, для скрипта или ИИ-агента. Ключ ставится и до, и после команды.- Заявок команды не ставят. Из программы можно только читать счет и снимать заявки (
stop --cancel-orders,cancel-all). Ставят заявки боты: Подключить бота.
Какая команда для чего
| Команда | Что делает | Нужна ли служба |
|---|---|---|
brokapi init [путь] | Пишет образец brokapi.toml и каталог состояния. Существующий файл не затирает без --force | нет |
brokapi check [гейтвей ...] | Разбирает конфиг и читает счет у брокера (портфель и активные заявки). Ничего не ставит. --offline - только файл и токены, без брокера. Предупреждает, если файл с токеном читается другими пользователями | нет |
brokapi up [-d] | Запускает службу этого конфига со всеми ее гейтвеями. Без -d работает на переднем плане до Ctrl+C. С -d - в фоне, команда возвращается, когда служба ответила | это она |
brokapi down [--leave-gateways] | Останавливает службу и ее гейтвеи. С --leave-gateways гейтвеи остаются жить, следующий up подхватит их | да |
brokapi services | Все запущенные на машине службы, каждая со своим конфигом: pid, сколько гейтвеев работает, аптайм | нет |
brokapi ps | Гейтвеи службы: состояние, pid, аптайм, перезапуски, боты, расход лимита, последняя ошибка | да |
brokapi top [-i сек] [-n кадров] | То же вживую: таблица обновляется на месте, плюс CPU и память. В терминале перерисовывается, в трубе печатает кадры подряд, с --json - по объекту в строке | да |
brokapi status <гейтвей> | Подробно об одном гейтвее: процесс, журнал, подписки, боты, остатки ведер лимита | да |
brokapi start <гейтвей ...> [--wait] | Запускает остановленный или упавший (failed) гейтвей. --wait ждет, пока он начнет принимать команды | да |
brokapi stop <гейтвей ...> [--cancel-orders] | Останавливает гейтвей и держит его остановленным до start. Заявки на бирже не трогает. С --cancel-orders сначала снимает активные и стоп-заявки счета | да |
brokapi restart <гейтвей ...> [--wait] | Останавливает и запускает снова | да |
brokapi add <гейтвей> | Перечитывает конфиг и запускает этот гейтвей из него, не останавливая службу. Уже работающий и неизмененный - ошибка | да |
brokapi remove <гейтвей> | Останавливает гейтвей и забывает его до add или reload. Файл конфига не правит | да |
brokapi reload | Перечитывает конфиг целиком: новые гейтвеи запускает, исчезнувшие из файла останавливает, измененные перезапускает, остальные не трогает | да |
brokapi logs [гейтвей] [-f] [-n строк] | Журнал гейтвея, а без имени - службы. -f показывает, что дописывается. По умолчанию 50 последних строк | нет |
brokapi portfolio <гейтвей> | Деньги и позиции счета | нет, нужен запущенный гейтвей |
brokapi orders <гейтвей> | Активные заявки счета | нет, нужен запущенный гейтвей |
brokapi stops <гейтвей> | Активные стоп-заявки счета | нет, нужен запущенный гейтвей |
brokapi book <гейтвей> <тикер> [--board] [--depth] | Стакан, по умолчанию 5 уровней с каждой стороны | нет, нужен запущенный гейтвей |
brokapi quote <гейтвей> <тикер> [--board] | Лучшая покупка, лучшая продажа и последняя сделка | нет, нужен запущенный гейтвей |
brokapi direct <брокер> portfolio и orders, stops, book, quote | То же напрямую у брокера, без службы и гейтвея: проверить токен или задать разовый вопрос. --from <гейтвей> берет настройки и токен из раздела этого гейтвея в конфиге (брокер должен совпасть), без него - из переменных окружения библиотеки и файла .env | нет |
brokapi cancel-all <гейтвей> | Аварийная кнопка: снять все активные и стоп-заявки счета. На отказ по лимиту ждет и повторяет, пока не снимет | нет, нужен запущенный гейтвей |
brokapi gateway run <гейтвей> | Один гейтвей на переднем плане, без службы. Так его запускает и сама служба | нет |
Команды чтения (portfolio, orders, stops, book, quote, cancel-all) ходят прямо к гейтвею по каталогу из конфига, поэтому
работают и тогда, когда гейтвей запущен вручную через gateway run, а не службой. Для них в программе заводится бот с именем,
начинающимся на brokapi-: в ps он не считается ботом, в status помечен как (tool).
Колонки ps
| Колонка | Что значит |
|---|---|
NAME, BROKER | Имя гейтвея из конфига и брокер |
STATE | starting (процесс идет, гейтвей сверяет журнал с брокером), running (принимает команды), restarting (пауза перед новым запуском, в скобках сколько осталось), stopping, stopped (ждет start), failed (служба перестала его перезапускать, ждет start) |
PID, UPTIME | Процесс гейтвея и сколько он работает |
RESTARTS | Сколько раз служба запускала гейтвей заново после того, как он остановился сам |
BOTS | Сколько ботов сейчас читает его поток событий. Инструменты самой программы не считаются |
EVENTS/S | События в секунду за последние десять секунд |
ORDERS | Заявки в журнале гейтвея, про которые не известно, что они закончились |
LIMIT | Насколько заполнено самое занятое ведро лимита брокера, в процентах |
CPU, MEM | Только в top: нагрузка и память процесса |
LAST ERROR | Чем закончился последний запуск и последняя строка ERROR или WARN из его журнала |
Где значение неизвестно (гейтвей не отвечает), стоит -. Память процесса служба читает раз в секунду (check_interval), поэтому в первую секунду после запуска вместо нее тоже -, а не 0 B.
Коды выхода
Скрипт и ИИ-агент отличают причины по коду выхода, текст ошибки при этом читать не нужно.
| Код | Что значит | Что делать |
|---|---|---|
0 | Успех | |
1 | Команда не удалась: брокер или гейтвей отказал, check нашел проблему, файл не записался | Прочитать сообщение, оно называет причину |
2 | Неверный вызов или конфиг, который нельзя использовать | Исправить команду или brokapi.toml, brokapi check --offline покажет место |
3 | Для этого конфига нет службы | brokapi up -d |
4 | Нет такого гейтвея или он не запущен | Сверить имя с [[gateway]] в конфиге, посмотреть brokapi ps |
Эти же пять кодов и порядок поиска конфига печатает brokapi --help.
Примеры вывода
Службу и гейтвей из быстрого старта считаем запущенными. Числа и пути у вас будут свои.
$ brokapi services
PID GATEWAYS UPTIME CONFIG STATE DIRECTORY
4121 1/1 running 1m12s /home/me/bots/brokapi.toml /home/me/bots/brokapi.state
$ brokapi status tbank-sandbox
gateway tbank-sandbox (tbank, sandbox, account 2000000000)
state running
process pid 4133, up 1m12s, 0.3% cpu, 9.1 MB
restarts 0
listens at unix:/home/me/bots/brokapi.state/gateways/tbank-sandbox/gateway.sock
directory /home/me/bots/brokapi.state/gateways/tbank-sandbox
gateway says ready
version 0.1.0-alpha.5
events 0 so far, 0.0 per second, last number 0
journal 0 order(s) not known to have ended
bots
BOT CONNECTED CALLS QUEUED CUT OFF
brokapi-service (tool) no 3 0 0
subscriptions
SUB WHAT HELD BY
1 order_state (gateway)
rate limit of the broker
LIMIT BUCKET LEFT OF
accounts 100 100
default 100 100
global 50 50
...
Строка process в status показывает память так же, как top: 9.1 MB, а до первого чтения -.
В status три таблицы. bots - кто обращался к гейтвею и читает ли сейчас его поток событий (brokapi-service (tool) - сама программа, не ваш бот). subscriptions -
подписки у брокера и кто их держит: (gateway) - подписка самого гейтвея на состояния заявок, ее не снять. rate limit of the broker - сколько осталось в каждом ведре лимита
(таблица длиннее, чем показано: у Т-Банка ведер около двадцати, здесь первые строки).
$ brokapi portfolio tbank-sandbox
CURRENCY AVAILABLE BLOCKED
RUB 100000 0
INSTRUMENT QTY AVG PRICE PRICE
SBER TQBR 10 250.10 281.35
$ brokapi book tbank-sandbox SBER --depth 2
SBER TQBR
BID QTY BID ASK ASK QTY
10 281.35 281.40 12
5 281.30 281.45 8
$ brokapi orders tbank-sandbox
ORDER INSTRUMENT SIDE TYPE QTY FILLED PRICE STATE
С --json те же данные идут в JSON, числа и цены в нем строками, как везде в библиотеке:
$ brokapi quote tbank-sandbox SBER --json
{
"ask": {
"price": "281.40",
"qty": "12"
},
"bid": {
"price": "281.35",
"qty": "10"
},
"instrument": "SBER TQBR",
"last": "281.35"
}
Справка по каждой команде
brokapi
$ brokapi --help
The brokapi gateway: one process per broker account that many bots share. `brokapi up` runs the
service that keeps the gateways alive, the other commands look after them.
Usage: brokapi [OPTIONS] <COMMAND>
Commands:
init Write a sample config and make the state directory
check Check the config and the access of every gateway. Only reads: no order is placed
up Start the service of this config: it runs the gateways and keeps them alive
down Stop the service of this config, and its gateways
services List the services running on this machine, whatever their config
ps List the gateways of the service: state, pid, uptime, restarts, bots, events, orders,
limit use, last error
top Like `ps`, with CPU and memory, refreshed in place until Ctrl+C
start Start gateways that are stopped or failed
stop Stop gateways and keep them stopped. Orders at the exchange are not touched, unless
asked
restart Stop gateways and start them again
add Read the config again and start this gateway of it, without stopping the service
remove Stop a gateway and forget it, without stopping the service. `add` or `reload` bring it
back from the config
reload Read the config again: start the new gateways, stop the removed ones, restart the
changed ones
status Everything about one gateway: connection, journal, subscriptions, bots, limits
logs Show the log of a gateway, or of the service without a name
gateway Run one gateway in this terminal, without the service. The service starts its gateways
this way
portfolio Money and positions of the account behind a gateway
orders Active orders of the account behind a gateway
stops Active stop orders of the account behind a gateway
book The order book of an instrument, through a gateway
quote Best bid, best ask and the last trade of an instrument, through a gateway
direct Read straight from a broker, without a service or a gateway: to try a token, or for a
one-off question
cancel-all Emergency: cancel every active order and stop order of the account behind a gateway
Options:
-c, --config <FILE> The config file (default: $BROKAPI_CONFIG, ./brokapi.toml, then the config
directory of the user) [env: BROKAPI_CONFIG=]
--json Print JSON instead of text (every command that prints something)
-h, --help Print help
-V, --version Print version
Exit codes:
0 success
1 the command failed: a broker or a gateway refused, a check found a problem, a file could not be
written
2 wrong usage or a config that cannot be used
3 no service is running for this config (start it with `brokapi up`)
4 no such gateway, or the gateway is not running
The config is looked for in: -c FILE, the variable BROKAPI_CONFIG, ./brokapi.toml, then brokapi.toml
in the config
directory of the user (~/.config/brokapi on Linux). Two config files are two independent services,
each with its own
state directory and its own socket, so any number of services can run on one machine.
brokapi init
$ brokapi init --help
Write a sample config and make the state directory
Usage: brokapi init [OPTIONS] [PATH]
Arguments:
[PATH] Where to write the config (default ./brokapi.toml)
Options:
-c, --config <FILE> The config file (default: $BROKAPI_CONFIG, ./brokapi.toml, then the config
directory of the user) [env: BROKAPI_CONFIG=]
--force Replace the file if it is there
--json Print JSON instead of text (every command that prints something)
-h, --help Print help
brokapi check
$ brokapi check --help
Check the config and the access of every gateway. Only reads: no order is placed
Usage: brokapi check [OPTIONS] [NAMES]...
Arguments:
[NAMES]... Only these gateways
Options:
-c, --config <FILE> The config file (default: $BROKAPI_CONFIG, ./brokapi.toml, then the config
directory of the user) [env: BROKAPI_CONFIG=]
--offline Do not connect to the brokers, check the file and the tokens only
--json Print JSON instead of text (every command that prints something)
-h, --help Print help
brokapi up
$ brokapi up --help
Start the service of this config: it runs the gateways and keeps them alive
Usage: brokapi up [OPTIONS]
Options:
-c, --config <FILE> The config file (default: $BROKAPI_CONFIG, ./brokapi.toml, then the config
directory of the user) [env: BROKAPI_CONFIG=]
-d, --detach Run in the background (a process without a terminal) and return when the
service answers
--json Print JSON instead of text (every command that prints something)
-h, --help Print help
brokapi down
$ brokapi down --help
Stop the service of this config, and its gateways
Usage: brokapi down [OPTIONS]
Options:
-c, --config <FILE> The config file (default: $BROKAPI_CONFIG, ./brokapi.toml, then the config
directory of the user) [env: BROKAPI_CONFIG=]
--leave-gateways Leave the gateway processes running. A service started later takes them over
--json Print JSON instead of text (every command that prints something)
-h, --help Print help
brokapi services
$ brokapi services --help
List the services running on this machine, whatever their config
Usage: brokapi services [OPTIONS]
Options:
-c, --config <FILE> The config file (default: $BROKAPI_CONFIG, ./brokapi.toml, then the config
directory of the user) [env: BROKAPI_CONFIG=]
--json Print JSON instead of text (every command that prints something)
-h, --help Print help
brokapi ps
$ brokapi ps --help
List the gateways of the service: state, pid, uptime, restarts, bots, events, orders, limit use,
last error
Usage: brokapi ps [OPTIONS]
Options:
-c, --config <FILE> The config file (default: $BROKAPI_CONFIG, ./brokapi.toml, then the config
directory of the user) [env: BROKAPI_CONFIG=]
--json Print JSON instead of text (every command that prints something)
-h, --help Print help
brokapi top
$ brokapi top --help
Like `ps`, with CPU and memory, refreshed in place until Ctrl+C
Usage: brokapi top [OPTIONS]
Options:
-c, --config <FILE> The config file (default: $BROKAPI_CONFIG, ./brokapi.toml, then the
config directory of the user) [env: BROKAPI_CONFIG=]
-i, --interval <INTERVAL> Seconds between refreshes [default: 1]
--json Print JSON instead of text (every command that prints something)
-n, --count <COUNT> Stop after this many refreshes (default: until Ctrl+C)
-h, --help Print help
brokapi start
$ brokapi start --help
Start gateways that are stopped or failed
Usage: brokapi start [OPTIONS] <NAMES>...
Arguments:
<NAMES>... The gateways
Options:
-c, --config <FILE> The config file (default: $BROKAPI_CONFIG, ./brokapi.toml, then the config
directory of the user) [env: BROKAPI_CONFIG=]
--wait Wait until they take commands
--json Print JSON instead of text (every command that prints something)
-h, --help Print help
brokapi stop
$ brokapi stop --help
Stop gateways and keep them stopped. Orders at the exchange are not touched, unless asked
Usage: brokapi stop [OPTIONS] <NAMES>...
Arguments:
<NAMES>... The gateways
Options:
-c, --config <FILE> The config file (default: $BROKAPI_CONFIG, ./brokapi.toml, then the config
directory of the user) [env: BROKAPI_CONFIG=]
--cancel-orders Cancel the active orders and stop orders of the account first
--json Print JSON instead of text (every command that prints something)
-h, --help Print help
brokapi restart
$ brokapi restart --help
Stop gateways and start them again
Usage: brokapi restart [OPTIONS] <NAMES>...
Arguments:
<NAMES>... The gateways
Options:
-c, --config <FILE> The config file (default: $BROKAPI_CONFIG, ./brokapi.toml, then the config
directory of the user) [env: BROKAPI_CONFIG=]
--wait Wait until they take commands
--json Print JSON instead of text (every command that prints something)
-h, --help Print help
brokapi add
$ brokapi add --help
Read the config again and start this gateway of it, without stopping the service
Usage: brokapi add [OPTIONS] <NAME>
Arguments:
<NAME> The gateway
Options:
-c, --config <FILE> The config file (default: $BROKAPI_CONFIG, ./brokapi.toml, then the config
directory of the user) [env: BROKAPI_CONFIG=]
--json Print JSON instead of text (every command that prints something)
-h, --help Print help
brokapi remove
$ brokapi remove --help
Stop a gateway and forget it, without stopping the service. `add` or `reload` bring it back from the
config
Usage: brokapi remove [OPTIONS] <NAME>
Arguments:
<NAME> The gateway
Options:
-c, --config <FILE> The config file (default: $BROKAPI_CONFIG, ./brokapi.toml, then the config
directory of the user) [env: BROKAPI_CONFIG=]
--json Print JSON instead of text (every command that prints something)
-h, --help Print help
brokapi reload
$ brokapi reload --help
Read the config again: start the new gateways, stop the removed ones, restart the changed ones
Usage: brokapi reload [OPTIONS]
Options:
-c, --config <FILE> The config file (default: $BROKAPI_CONFIG, ./brokapi.toml, then the config
directory of the user) [env: BROKAPI_CONFIG=]
--json Print JSON instead of text (every command that prints something)
-h, --help Print help
brokapi status
$ brokapi status --help
Everything about one gateway: connection, journal, subscriptions, bots, limits
Usage: brokapi status [OPTIONS] <NAME>
Arguments:
<NAME> The gateway
Options:
-c, --config <FILE> The config file (default: $BROKAPI_CONFIG, ./brokapi.toml, then the config
directory of the user) [env: BROKAPI_CONFIG=]
--json Print JSON instead of text (every command that prints something)
-h, --help Print help
brokapi logs
$ brokapi logs --help
Show the log of a gateway, or of the service without a name
Usage: brokapi logs [OPTIONS] [NAME]
Arguments:
[NAME] The gateway
Options:
-c, --config <FILE> The config file (default: $BROKAPI_CONFIG, ./brokapi.toml, then the config
directory of the user) [env: BROKAPI_CONFIG=]
-f, --follow Keep showing what is added
--json Print JSON instead of text (every command that prints something)
-n, --lines <LINES> How many lines from the end [default: 50]
-h, --help Print help
brokapi gateway
$ brokapi gateway --help
Run one gateway in this terminal, without the service. The service starts its gateways this way
Usage: brokapi gateway [OPTIONS] <COMMAND>
Commands:
run Run the gateway until Ctrl+C or SIGTERM
Options:
-c, --config <FILE> The config file (default: $BROKAPI_CONFIG, ./brokapi.toml, then the config
directory of the user) [env: BROKAPI_CONFIG=]
--json Print JSON instead of text (every command that prints something)
-h, --help Print help
brokapi gateway run
$ brokapi gateway run --help
Run the gateway until Ctrl+C or SIGTERM
Usage: brokapi gateway run [OPTIONS] <NAME>
Arguments:
<NAME> The gateway
Options:
-c, --config <FILE> The config file (default: $BROKAPI_CONFIG, ./brokapi.toml, then the config
directory of the user) [env: BROKAPI_CONFIG=]
--log-file <FILE> Write the log to this file instead of the terminal
--json Print JSON instead of text (every command that prints something)
-h, --help Print help
brokapi portfolio
$ brokapi portfolio --help
Money and positions of the account behind a gateway
Usage: brokapi portfolio [OPTIONS] <GATEWAY>
Arguments:
<GATEWAY> The gateway
Options:
-c, --config <FILE> The config file (default: $BROKAPI_CONFIG, ./brokapi.toml, then the config
directory of the user) [env: BROKAPI_CONFIG=]
--json Print JSON instead of text (every command that prints something)
-h, --help Print help
brokapi orders
$ brokapi orders --help
Active orders of the account behind a gateway
Usage: brokapi orders [OPTIONS] <GATEWAY>
Arguments:
<GATEWAY> The gateway
Options:
-c, --config <FILE> The config file (default: $BROKAPI_CONFIG, ./brokapi.toml, then the config
directory of the user) [env: BROKAPI_CONFIG=]
--json Print JSON instead of text (every command that prints something)
-h, --help Print help
brokapi stops
$ brokapi stops --help
Active stop orders of the account behind a gateway
Usage: brokapi stops [OPTIONS] <GATEWAY>
Arguments:
<GATEWAY> The gateway
Options:
-c, --config <FILE> The config file (default: $BROKAPI_CONFIG, ./brokapi.toml, then the config
directory of the user) [env: BROKAPI_CONFIG=]
--json Print JSON instead of text (every command that prints something)
-h, --help Print help
brokapi book
$ brokapi book --help
The order book of an instrument, through a gateway
Usage: brokapi book [OPTIONS] <GATEWAY> <TICKER>
Arguments:
<GATEWAY> The gateway
<TICKER> The ticker, like SBER
Options:
--board <BOARD> The board or market, like TQBR (default: the broker's choice)
-c, --config <FILE> The config file (default: $BROKAPI_CONFIG, ./brokapi.toml, then the config
directory of the user) [env: BROKAPI_CONFIG=]
--depth <DEPTH> How many levels on each side [default: 5]
--json Print JSON instead of text (every command that prints something)
-h, --help Print help
brokapi quote
$ brokapi quote --help
Best bid, best ask and the last trade of an instrument, through a gateway
Usage: brokapi quote [OPTIONS] <GATEWAY> <TICKER>
Arguments:
<GATEWAY> The gateway
<TICKER> The ticker, like SBER
Options:
--board <BOARD> The board or market, like TQBR
-c, --config <FILE> The config file (default: $BROKAPI_CONFIG, ./brokapi.toml, then the config
directory of the user) [env: BROKAPI_CONFIG=]
--json Print JSON instead of text (every command that prints something)
-h, --help Print help
brokapi direct
$ brokapi direct --help
Read straight from a broker, without a service or a gateway: to try a token, or for a one-off
question
Usage: brokapi direct [OPTIONS] <BROKER> <COMMAND>
Commands:
portfolio Money and positions
orders Active orders
stops Active stop orders
book The order book
quote Best bid, best ask, last trade
Arguments:
<BROKER> The broker: bcs, tbank, finam, alor or bybit
Options:
-c, --config <FILE> The config file (default: $BROKAPI_CONFIG, ./brokapi.toml, then the config
directory of the user) [env: BROKAPI_CONFIG=]
--from <GATEWAY> Take the settings and the token of this gateway of the config (its broker
must be the same). Without it the settings come from the environment
variables of the library (BCS_REFRESH_TOKEN, TBANK_TOKEN, ...) and a .env
file in the directory
--json Print JSON instead of text (every command that prints something)
-h, --help Print help
brokapi direct portfolio
$ brokapi direct portfolio --help
Money and positions
Usage: brokapi direct <BROKER> portfolio [OPTIONS]
Options:
-c, --config <FILE> The config file (default: $BROKAPI_CONFIG, ./brokapi.toml, then the config
directory of the user) [env: BROKAPI_CONFIG=]
--from <GATEWAY> Take the settings and the token of this gateway of the config (its broker
must be the same). Without it the settings come from the environment
variables of the library (BCS_REFRESH_TOKEN, TBANK_TOKEN, ...) and a .env
file in the directory
--json Print JSON instead of text (every command that prints something)
-h, --help Print help
brokapi direct orders
$ brokapi direct orders --help
Active orders
Usage: brokapi direct <BROKER> orders [OPTIONS]
Options:
-c, --config <FILE> The config file (default: $BROKAPI_CONFIG, ./brokapi.toml, then the config
directory of the user) [env: BROKAPI_CONFIG=]
--from <GATEWAY> Take the settings and the token of this gateway of the config (its broker
must be the same). Without it the settings come from the environment
variables of the library (BCS_REFRESH_TOKEN, TBANK_TOKEN, ...) and a .env
file in the directory
--json Print JSON instead of text (every command that prints something)
-h, --help Print help
brokapi direct stops
$ brokapi direct stops --help
Active stop orders
Usage: brokapi direct <BROKER> stops [OPTIONS]
Options:
-c, --config <FILE> The config file (default: $BROKAPI_CONFIG, ./brokapi.toml, then the config
directory of the user) [env: BROKAPI_CONFIG=]
--from <GATEWAY> Take the settings and the token of this gateway of the config (its broker
must be the same). Without it the settings come from the environment
variables of the library (BCS_REFRESH_TOKEN, TBANK_TOKEN, ...) and a .env
file in the directory
--json Print JSON instead of text (every command that prints something)
-h, --help Print help
brokapi direct book
$ brokapi direct book --help
The order book
Usage: brokapi direct <BROKER> book [OPTIONS] <TICKER>
Arguments:
<TICKER> The ticker
Options:
--board <BOARD> The board
-c, --config <FILE> The config file (default: $BROKAPI_CONFIG, ./brokapi.toml, then the config
directory of the user) [env: BROKAPI_CONFIG=]
--from <GATEWAY> Take the settings and the token of this gateway of the config (its broker
must be the same). Without it the settings come from the environment
variables of the library (BCS_REFRESH_TOKEN, TBANK_TOKEN, ...) and a .env
file in the directory
--depth <DEPTH> How many levels on each side [default: 5]
--json Print JSON instead of text (every command that prints something)
-h, --help Print help
brokapi direct quote
$ brokapi direct quote --help
Best bid, best ask, last trade
Usage: brokapi direct <BROKER> quote [OPTIONS] <TICKER>
Arguments:
<TICKER> The ticker
Options:
--board <BOARD> The board
-c, --config <FILE> The config file (default: $BROKAPI_CONFIG, ./brokapi.toml, then the config
directory of the user) [env: BROKAPI_CONFIG=]
--from <GATEWAY> Take the settings and the token of this gateway of the config (its broker
must be the same). Without it the settings come from the environment
variables of the library (BCS_REFRESH_TOKEN, TBANK_TOKEN, ...) and a .env
file in the directory
--json Print JSON instead of text (every command that prints something)
-h, --help Print help
brokapi cancel-all
$ brokapi cancel-all --help
Emergency: cancel every active order and stop order of the account behind a gateway
Usage: brokapi cancel-all [OPTIONS] <GATEWAY>
Arguments:
<GATEWAY> The gateway
Options:
-c, --config <FILE> The config file (default: $BROKAPI_CONFIG, ./brokapi.toml, then the config
directory of the user) [env: BROKAPI_CONFIG=]
--json Print JSON instead of text (every command that prints something)
-h, --help Print help
Контракт для агентов
Все обязательные правила brokapi на одной странице. Страница для ИИ-агента, который пишет код на библиотеке, и для человека, который этот код проверяет. Нарушение правила не дает ошибки компиляции. Оно дает двойную заявку, торговлю по чужой картине счета или отказы брокера, поэтому правила строгие.
Для ИИ-агентов: llms.txt и llms-full.txt (вся книга одним файлом).
У каждого правила пять частей: правило, почему, что сломается при нарушении, как правильно (код) и
ссылка на подробное объяснение. Код в «как правильно» собирается вместе с библиотекой (examples/contract.rs),
поэтому он не отстает от API. Целые рабочие программы лежат в инструкциях: торговый бот,
несколько брокеров и счетов, исследовательский движок.
Правило 0: токен и первая боевая заявка только от человека
Правило. Это правило выше всех остальных, и оно про человека, а не про код.
- Боевой токен выдает человек, чей это счет. ИИ-агент не заходит в кабинет брокера и сам не выпускает токен с правом торговли.
- Флаги записи (
BCS_IS_WRITE=true,write=Trueвconnect, токен «для торговли и чтения») агент сам не включает. Их включает человек, когда решил. - Первую заявку на боевом счете агент ставит только со слова человека, на конкретный счет и конкретную заявку («можно, счет такой-то, одна заявка, столько-то»). Слово «можно» в начале сеанса на все последующие заявки не распространяется.
- До этого слова агент работает на имитациях (
brokapi.testing,brokapi-mocks), потом на песочнице Т-Банка. Боевой счет читают токеном только для чтения, если человек такой выдал.
Почему. Заявка на биржу - это деньги человека, и откатить ее нельзя. Токен с правом торговли равен доступу к счету. Библиотека проверяет порядок вызовов, но не может знать, хотел ли владелец счета эту заявку.
Что сломается. Агент, включивший запись «чтобы проверить», ставит настоящую заявку на настоящий счет. Ошибка в цене, количестве или счете стоит денег, а не упавшего теста.
Как правильно. Написать и проверить код на имитации, остановиться перед боевым счетом и попросить человека. Что попросить, можно отдать ему целиком:
Прошу у владельца счета:
- Токен песочницы Т-Банка и открытый, пополненный счет песочницы (для первого прогона).
- Токен только для чтения для боевого счета, если брокер такие выпускает (БКС, Т-Банк). Где его взять: раздел «Вход» на странице брокера в книге.
- Номер счета (портфеля):
TBANK_ACCOUNT_ID,FINAM_ACCOUNT_ID,ALOR_PORTFOLIO. У БКС счет определяет сам токен, у Bybit ключ и секрет API (лучше пара демо-счета или отдельного субсчета без права вывода).- Решение о первой боевой заявке письменно: счет, инструмент, сторона, количество, предельная цена, «можно».
- Только после пункта 4: токен с правом торговли и включенная запись.
Подробнее: Перед боевым счетом, Аварийная остановка бота, разделы «Вход» на страницах брокеров: БКС, Т-Банк, Финам, АЛОР.
Порядок любой программы
flowchart TB A[Шаг 1: один BrokerLimiter на область квоты,<br>один канал событий] --> B["Шаг 2: start, MD (рынок) на брокера,<br>UD (счет) на счет"] B --> C[Шаг 3: subscribe_order_state<br>и subscribe_executions] C --> D[Шаг 4: reconcile со списком cid из журнала] D --> E[Шаг 5: разобрать confirmed, closed_offline, foreign] E --> F[Шаг 6: работа, события в одной задаче,<br>команды из другой] F --> G[Шаг 7: остановка, свои заявки снять,<br>если так решил бот, потом shutdown]
На схеме: шаги, которые делает любая программа на brokapi, в обязательном порядке. Пункты 3 и 4 нельзя менять местами,
пункт 4 нельзя пропустить: без него любая торговая команда вернет NotReconciled.
Коротко
| № | Правило |
|---|---|
| 0 | Боевой токен выдает человек. Первую боевую заявку ставят только с его слова. Токен с правом торговли и флаги записи агент сам не создает и не включает |
| 1 | cid придумывает бот и пишет в журнал на диск до place |
| 2 | Торговля только после reconcile. Подписка на статусы - до него |
| 3 | Unknown не повторять. Выход: order_state, reconcile. Новая попытка - с новым cid |
| 4 | Один BrokerLimiter на область, где брокер считает лимит. Ждать квоту должен бот |
| 5 | Свои заявки и сделки идут только Lossless. Канал читать всегда, отдельной задачей |
| 6 | Overflow, Reconnected и Degraded: дочитать канал, прочитать состояние, reconcile, восстановить пропущенное |
| 7 | Resync: стакан выбросить, не торговать по нему |
| 8 | Цены, количества и деньги - Decimal (Price, Qty, Money). Цену округлять до шага. Количество в единицах инструмента, не в лотах, кратно лоту |
| 9 | Один UD на счет, один MD на брокера. Идентификатор инструмента - вид своего брокера |
| 10 | shutdown не снимает заявки. Снимает или не снимает бот |
| 11 | Чужие заявки (foreign) не трогать |
| 12 | Длинную историю свечей читать окнами (CandlePages), не одним запросом |
| 13 | Возможность, которой нет у брокера, это Unsupported, а не сбой. Проверять до вызова |
1. cid придумывает бот и пишет в журнал до place
Правило. Номер заявки (ClientOrderId, UUID) создает бот: ClientOrderId::new_v4(). Он записывает его в свой
журнал на диск (с принудительной записью, fsync) до вызова place. Один cid - одна заявка. Новая попытка - новый
cid.
Почему. Библиотека ничего не хранит между запусками. Если ответ на place потерялся, cid - единственная нить, по
которой после падения можно спросить брокера «что с заявкой». Журнал, записанный после вызова, теряет заявку, когда
процесс падает между вызовом и записью.
Что сломается. Без записи до вызова после падения бот не знает о заявке, которая могла встать, и не сверит ее
(closed_offline и confirmed строятся из вашего списка). Повтор того же cid библиотека не отправит:
Rejected с кодом duplicate_place, единица квоты потрачена.
Как правильно.
let cid = request.cid; // made by the bot: ClientOrderId::new_v4()
journal.sending(cid)?; // on disk BEFORE the call
let result = ud.place(request).await;
cid = brokapi.new_cid() # made by the bot
journal.sending(cid) # on disk BEFORE the call
result = await client.place(cid=cid, instrument=instrument, side=brokapi.Side.BUY, qty=qty, price=price)
Подробнее: Круг команд, cid, Запуск, рестарт и остановка.
2. Торговля только после reconcile
Правило. После start шлюз торговых команд закрыт. Порядок: подписаться на статусы и исполнения, вызвать
reconcile(&журнал), только потом ставить заявки. Если журнала нет, список пустой: reconcile(&[]).
Почему. Пока бот не знает, что уже стоит у брокера, новая команда может задвоить заявку или спорить с той, которую
бот не видит. Подписка идет раньше сверки, потому что событие между снимком сверки и подпиской иначе теряется. subscribe_* возвращается, когда поток открыт, поэтому заявку можно ставить сразу
после него: исполнение, случившееся после возврата, до бота дойдет. Не открылся за subscribe_ack_timeout (3 секунды) -
Transport, подписка снята, вызов можно повторить. Исключение Финам: по таймауту Ok и поток дооткрывается в фоне (sub_status),
подробно в Круге событий.
Что сломается. place, cancel, replace, place_stop и cancel_stop возвращают NotReconciled, в сеть
ничего не уходит. cid при этом не потрачен. Если подписаться после заявки, события о ней потеряны.
Как правильно.
ud.subscribe_order_state().await?; // before the reconcile, or an event in between is lost
let report = ud.reconcile(&journal.live()).await?; // an empty slice for a bot without a journal
// report.confirmed, report.closed_offline, report.foreign: handle them (see the lifecycle page)
ud.place(request).await?; // before the reconcile this is Error::NotReconciled
await client.subscribe_order_state() # before the reconcile, or an event in between is lost
report = await client.reconcile(journal.live()) # an empty list for a bot without a journal
# report.confirmed, report.closed_offline, report.foreign: handle them (see the lifecycle page)
await client.place(cid=brokapi.new_cid(), instrument=instrument, side=brokapi.Side.BUY, qty=qty, price=price)
# Before the reconcile the line above raises brokapi.NotReconciled.
reconcile только читает, его можно вызывать когда угодно и сколько угодно. Ошибка reconcile значит, что сверки
не было и шлюз закрыт: подождать и повторить.
Подробнее: Круг жизни, Запуск, рестарт и остановка.
3. Unknown не повторять
Правило. Если команда вернула Error::Unknown (или Error::Transport на команде), заявка могла встать. Команду
не повторять. Спросить брокера по тому же cid: order_state(cid), при сомнении active_orders, executions,
reconcile. Новую заявку ставить с новым cid, только когда стало ясно, что первой нет.
Почему. Из «нет ответа» нельзя понять, дошла ли заявка. Повтор при первой дошедшей дает две заявки на счете, для денег это худший исход. Библиотека сама никогда не повторяет команды, и бот тоже не должен.
Что сломается. Двойная позиция. Отрицательный ответ сразу после обрыва тоже ничего не доказывает: незнакомый номер
приходит как Rejected с кодом not_found (один код у всех), и запрос,
который был в пути, может дойти позже. Подождать и спросить еще раз.
Как правильно.
let cid = request.cid;
match ud.place(request).await {
Ok(_) | Err(Error::Rejected { .. }) => {}
Err(Error::Unknown | Error::Transport(_)) => {
// The order may exist. NEVER place again. Ask about THE SAME cid (see the recovery page for the loop).
let _state = ud.order_state(cid).await;
}
Err(other) => return Err(other.into()),
}
cid = brokapi.new_cid()
state = None
try:
await client.place(cid=cid, instrument=instrument, side=brokapi.Side.BUY, qty=qty, price=price)
except brokapi.Rejected:
pass # the broker said no: there is no order
except (brokapi.Unknown, brokapi.Transport):
# The order may exist. NEVER place again. Ask about THE SAME cid (see the recovery page for the loop).
state = await client.order_state(cid)
Как брокеры отвечают на номер, которого не знают (order_state):
| Брокер | Ответ |
|---|---|
| БКС | Error::Rejected с кодом not_found |
| Т-Банк | Error::Rejected с кодом not_found |
| Финам | Error::Rejected с кодом not_found |
| АЛОР | Error::Rejected с кодом not_found (список заявок дня просматривается, номера, которого нет, брокеру не отправляется: запрос несуществующей заявки он штрафует) |
| Bybit | Error::Rejected с кодом not_found (код биржи 110001) |
Error::Transport на команде rustdoc описывает как «сбой до отправки», но коннектор не всегда может это доказать, поэтому
считайте, что заявка могла дойти, и спросите order_state.
Сколько ждать. Сразу после Unknown в том же запуске запрос еще мог быть в пути: спрашивайте несколько секунд, сдавайтесь по
истечении терпения. После рестарта (closed_offline из сверки) запроса от прежнего процесса уже нет в пути: «не найдена» после короткого ожидания
окончательно, запись в журнале закрывается как «брокер не знает». В примерах
полсекунды для имитаций, для боевого счета берите секунды.
Цикл «спросить, подождать, спросить еще раз» с готовым кодом: Если что-то пошло не так.
Для cancel с Unknown повторить можно после проверки order_state. Для replace спрашивать по обоим номерам.
Подробнее: Почему нет автоповтора, Ошибки: Unknown.
4. Один BrokerLimiter на область квоты
Правило. Счетчик лимитов (BrokerLimiter) создается один на ту область, где брокер считает квоту, и передается всем
коннекторам этой области. Область у брокеров своя.
| Брокер | Область квоты | Счетчиков |
|---|---|---|
| БКС | токен (то есть счет) | по одному на токен |
| Т-Банк | пользователь: все счета и все токены вместе | один на программу |
| Финам | пользователь | один |
| АЛОР | логин (все портфели вместе) | один на логин |
| Bybit | ключ (пользователь и метод) и адрес (600 запросов за 5 секунд) | один на ключ, а если с одного адреса работают разные ключи, адресный лимит общий: держите ключи одной программы на одном BrokerLimiter |
Разные брокеры счетчик не делят.
Почему. Два счетчика на одну квоту вместе ее превысят, и брокер начнет отказывать, в самый неудобный момент, в том числе на отмене. Один счетчик там, где лимиты независимы, лишний: коннекторы будут мешать друг другу.
Что сломается. RateLimited от брокера на боевом счете. Библиотека сама не ждет квоту: при нехватке сразу
RateLimited { retry_after }, в сеть ничего не ушло. Ждать должен бот. Квоту тратит любой вызов: find, last_trades,
trading_params, подписка, а не только place и свечи. Исключение одно: CandlePages ждет сам.
Как правильно.
// T-Bank counts per user: the market data and BOTH accounts share one limiter.
let limiter = BrokerLimiter::new(&brokapi::tbank::limits::default_limits());
let (tx, _events) = mpsc::channel::<Envelope>(8192);
let md =
brokapi::tbank::MarketDataConnector::start(md_cfg, limiter.clone(), tx.clone()).await?;
let a = brokapi::tbank::UserDataConnector::start(ud_a_cfg, limiter.clone(), tx.clone()).await?;
let b = brokapi::tbank::UserDataConnector::start(ud_b_cfg, limiter, tx).await?;
if let Err(Error::RateLimited { retry_after }) = ud.place(request).await {
// Nothing was sent. Wait; the next try gets a NEW cid (always safe).
tokio::time::sleep(retry_after).await;
}
Подробнее: Лимиты запросов, Несколько счетов и брокеров.
5. Свои заявки и сделки идут только Lossless, канал читать всегда
Правило. Статусы своих заявок и свои сделки (subscribe_order_state, subscribe_executions) приходят только в режиме
Lossless: библиотека ставит его сама, размер буфера - order_state_buffer в настройке UD (1024). Режим для стакана и
котировок - Conflate, для ленты сделок рынка - DropNewest или Lossless. Канал событий читается всегда, отдельной
задачей, которая ничего не ждет: она только переносит конверты в очередь бота.
Почему. Поток брокера нельзя замедлить, и потерянное событие о частичном исполнении значит, что бот торгует по неверной картине. Поэтому для своих событий дыра не молчит: буфер переполняется громко (см. правило 6).
Что сломается. Если задача, которая читает канал, ждет команду или диск (а place ждет ответ брокера, до 30 секунд), буфер заполняется. Приходит Overflow,
торговля закрывается (Degraded). Канал событий делайте с запасом: в примерах книги от 1024 до 8192.
Как правильно. Читатель канала переносит конверты в собственную очередь, бот разбирает очередь и может ждать команды сколько нужно. Очередь без границы: следите за ее длиной, растущая очередь значит, что бот отстает.
// The reader does nothing but move envelopes to the bot's own queue, so the library's channel is always empty.
// The bot takes envelopes from `queue` and may await commands as long as it needs.
let (queue_tx, queue) = mpsc::unbounded_channel();
tokio::spawn(async move {
while let Some(env) = events.recv().await {
if queue_tx.send(env).is_err() {
break;
}
}
});
Подписка на стакан:
let sub = md
.subscribe_order_book(
id,
SubscribeOpts {
depth: Some(10),
..SubscribeOpts::conflate()
},
)
.await?;
// events of this subscription: env.source (which connector) + env.sub == Some(sub)
Подробнее: Круг событий.
6. Overflow, Reconnected и Degraded: дочитать, сверить, восстановить
Правило. Пришел Event::Overflow или Event::Reconnected (поток своих заявок или исполнений оборвался и открыт снова) или команда вернула Error::Degraded. По порядку: дочитать канал до пустого
(это история до дыры), вызвать reconcile(&журнал), восстановить пропущенное из снимков (состояния из confirmed и
order_state, сделки из executions(range) начиная с lost_from (у Reconnected с запасом раньше gap_from), портфель из portfolio()), вернуться к работе.
Для рыночной подписки сверки нет: подписку снять (unsubscribe) и оформить заново.
Почему. Торговать при дыре в данных о своих заявках нельзя: бот мог пропустить исполнение или снятие. Шлюз закрыт, пока бот не сверит картину со счетом.
Что сломается. Без сверки каждая торговая команда возвращает Degraded. Если сверку сделать до того, как дочитан
канал, события до дыры будут обработаны уже после снимка и могут откатить состояние назад.
Как правильно.
while let Ok(env) = events.try_recv() {
let _ = env; // the events that DID arrive are the history up to the hole: handle them as always
}
let report = ud.reconcile(&journal.live()).await?; // opens the gate again
// then restore: report.confirmed (states), executions(range) since lost_from, portfolio()
Подробнее: Если что-то пошло не так,
Круг событий,
Reconnected.
7. Resync: стакан выбросить
Правило. Event::Resync { instrument, .. } значит, что хранимому стакану этого инструмента верить нельзя. Выбросить его,
не торговать по нему, дождаться следующего Event::OrderBook.
Почему. После обрыва поток переоткрыт, часть данных могла пропасть. Стакан у всех брокеров приходит полным снимком, поэтому следующий снимок сам все исправит.
Что сломается. Заявка по цене из устаревшего стакана.
Как правильно.
match event {
Event::Resync { instrument, .. } => {
books.remove(&instrument.key); // do not trade from it; the next snapshot replaces it
}
Event::OrderBook(book @ OrderBook::Snapshot { .. }) => {
if let OrderBook::Snapshot { instrument, .. } = &book {
books.insert(instrument.key.clone(), book.clone());
}
}
_ => {}
}
Обрыв потока стакана бот видит как Resync. Обрыв потока своих заявок или исполнений приходит как Event::Reconnected:
шлюз при этом закрыт (Degraded), см. правило 6.
Подробнее: Круг событий.
8. Цены и количества - Decimal, цена на шаге
Правило. Price, Qty и Money построены на Decimal. f64 для денег, цен и количеств не использовать нигде.
Цену округлять до шага цены инструмента (TradingParams::price_step): покупка вниз (RoundMode::Down), продажа вверх.
Шаг и лот передать коннектору один раз: price_grid().learn(¶ms). Количество заявки - в единицах инструмента
(штуки, контракты, единицы валюты), одинаково у всех брокеров, и кратно лоту (TradingParams::lot_size). Некратное,
дробное там, где брокер берет целые, и нулевое количество это Rejected с кодом invalid_qty до отправки. Ничего не
округляется. Т-Банк считает лотами, и его коннектор переводит туда и обратно сам.
Почему. 244.3 в двоичной дроби не лежит ровно, цена «не на шаге» получается из-за шума и биржа отклоняет заявку.
Что сломается. Цена не на шаге дает Rejected с кодом price_off_step до отправки (квота и cid не тратятся), если
шаг коннектору известен. Если не известен, решает брокер, и отказ придет от него. Количество, которое не целое число
лотов, дает invalid_qty без отправки (у Т-Банка, АЛОРа и Bybit всегда, у БКС и Финама когда лот передан).
Как правильно.
// 1% below the bid, rounded DOWN to the price step (for a sell round UP). No f64 anywhere.
let wanted = best_bid * Decimal::new(99, 2);
let price = wanted
.round_to_step(params.price_step, RoundMode::Down)
.map_err(|e| Error::Config(ConfigError::Message(e.to_string())))?;
price_grid.learn(params); // once per instrument; then place() refuses a price off the step before sending
Подробнее: Деньги, цены и количества.
9. Один UD на счет, один MD на брокера, идентификатор своего брокера
Правило. Заявки и портфель идут через UD, по одному на счет. Рынок идет через MD, по одному на брокера. InstrumentId
несет NativeId, и вид у каждого брокера свой (БКС: тикер и класс, Т-Банк: uid, он основной, но принимаются figi и тикер с классом, Финам: символ SBER@MISX). Получать
идентификатор через find или собирать по странице брокера.
Почему. MD и UD разделены, чтобы всплеск стакана не задерживал отмену заявки. Один UD не умеет работать с двумя счетами.
Что сломается. Идентификатор чужого вида дает Error::Config, вызов не делается. Общий портфель по счетам и выбор
счета для заявки в библиотеке нет, это слой бота.
Подробнее: Несколько счетов и брокеров, Брокеры.
10. shutdown не снимает заявки
Правило. shutdown(режим) останавливает коннектор и забирает его по значению. Заявки на бирже он не трогает. Хочет бот
снять свои заявки перед выходом, снимает их сам и до shutdown. Порядок: сначала UD (команды закрыты), потом MD.
Почему. «Снять все» может быть неверным решением: например, защитный стоп должен пережить бота.
Что сломается. Заявка, которую бот считал снятой, остается на бирже и после рестарта приходит в confirmed сверки.
Как правильно.
for cid in working {
// Ok(()) means cancelled. Rejected: already final. Unknown: ask order_state, cancel again only if it works.
let _ = ud.cancel(*cid).await;
}
// Then the connector: `shutdown` takes it by value. Account connectors first, market data last.
// ud.shutdown(ShutdownMode::Graceful { deadline: Duration::from_secs(5) }).await;
Подробнее: Запуск, рестарт и остановка.
11. Чужие заявки не трогать
Правило. Заявки из списка foreign (поставлены руками, из приложения или другим роботом) не снимать и не менять. Показать
человеку.
Почему. Бот не знает, зачем человек их поставил. Снятие может сломать чужую стратегию или защитный стоп.
Что сломается. Чужая позиция остается без защиты. Библиотека такие заявки сама не трогает и не прячет.
Как правильно.
for order in &report.foreign {
// Placed by hand or by another program. No cancel, no replace. A person decides.
println!("foreign order {}: {:?}", order.cid, order.state);
}
Подробнее: Круг жизни.
12. Длинную историю свечей читать окнами
Правило. Брокер ограничивает один запрос свечей по числу баров или длине периода (БКС: 1440 баров, Т-Банк: от дня для
минутных до десяти лет для месячных). Историю длиннее читать через CandlePages или load_candles: они режут период на окна,
ждут RateLimited, повторяют чтение после сбоя сети, склеивают окна без дублей и помнят, откуда продолжить.
Почему. Запрос длиннее предела брокер отклоняет. Ручная нарезка забывает про лимит запросов и про бары на краю окна.
Что сломается. Rejected на слишком длинном периоде, дубли баров на стыках окон, RateLimited посреди выгрузки без
места, откуда продолжить.
Как правильно.
use brokapi::runtime::history::{CandlePages, HistoryOpts};
let mut pages = CandlePages::new(
md,
id,
brokapi::core::Timeframe::H1,
range,
HistoryOpts::default(),
);
while let Some(bars) = pages.next_page().await? {
// write `bars` to disk; after a stop continue from pages.resume_from()
let _ = bars;
}
Повторяются только чтения. Команды не повторяются нигде в библиотеке.
Как вести себя после HistoryError: смотрите error.class(). Retryable (лимит, сеть): подождать (retry_after у RateLimited) и
начать снова с resume_from или с места, где кончается файл, не больше нескольких попыток. Fatal (Rejected, Config,
Unsupported): то же чтение упадет снова, бумагу записать в отчет и идти к следующей.
Подробнее: Как собрать исследовательский движок.
13. Unsupported - не сбой
Правило. Возможность, которой нет у брокера, возвращает Error::Unsupported { capability } сразу, без сети. Это не сбой:
убрать вызов или проверить caps() заранее. Библиотека отсутствующий метод опросом не подменяет. Примеры: replace у Финама,
свечи 10 минут у БКС, поток стоп-заявок у всех.
Почему. Подмена (отмена и новая заявка вместо replace) не атомарна: старая заявка может исполниться между шагами. Решение
за ботом.
Что сломается. Бот, который считает Unsupported сбоем и повторяет, зациклится.
Подробнее: События и возможности.
Чего библиотека не делает
Это не недоработки. Это слой над коннекторами, его пишет бот:
| Что | Чей слой |
|---|---|
| Стратегии, сигналы, управление риском | бот |
| Учет позиций, общий портфель по счетам, выбор счета для заявки | бот |
| Журнал заявок на диске, восстановление состояния после рестарта | бот (библиотека дает cid в каждом событии и сверку) |
| Бэктест и подмена времени | нет. Для проверки кода есть имитации brokapi-mocks, для данных - запись и воспроизведение событий (исследовательский движок) |
| Автоповтор команд | нет и не будет |
| Снятие заявок при остановке | бот |
| Работа с чужими заявками | человек |
| Хранение токенов | бот (.env, в логи не попадают) |
Частые ошибки агентов
Что агент обычно делает не так, когда пишет бота без чтения этой страницы, и как правильно.
| Ошибка | Что будет | Как правильно |
|---|---|---|
Включает запись (BCS_IS_WRITE=true) или берет токен с правом торговли, «чтобы проверить» | Настоящая заявка на настоящий счет без ведома владельца | Правило 0: проверять на имитации и песочнице, на боевой счет только со слова человека |
| Ставит заявку в цикле повторов «пока не получится» | Две заявки после первого Unknown. Для того же cid - duplicate_place | Исходы разные: Rejected - исправить причину и новый cid, RateLimited - подождать, Unknown - order_state. Правило 3 |
Считает Ok(OrderAck) исполнением | Бот думает, что позиция открыта, а заявка только стоит | OrderAck значит «брокер принял». Исполнение приходит событиями Event::Order и Event::Execution |
Ставит заявку сразу после start | NotReconciled | Подписка, reconcile, потом торговля. Правило 2 |
Подписывается на статусы после place или после reconcile | События между снимком и подпиской потеряны | Подписка раньше сверки и раньше первой заявки |
Новый BrokerLimiter для каждого коннектора | Вместе превышают квоту брокера | Один на область квоты. Правило 4 |
Придумывает cid внутри повтора или использует cid из журнала повторно | duplicate_place | Новая попытка - новый cid, старый cid остается в журнале как финальный |
Использует f64 для цены | Цена не на шаге, округление уезжает на копейку | Price, Decimal, round_to_step. Правило 8 |
| Ждет внутри цикла чтения канала ответ на команду | Канал переполняется, торговля закрывается (Degraded) | Команды из другой задачи, чтение канала короткое. Правило 5 |
Хранит стакан и не смотрит на Resync | Торговля по устаревшему стакану | Выбросить стакан. Правило 7 |
Не обрабатывает Overflow | После него каждая команда - Degraded | Правило 6 |
Считает сообщение Rejected кодом | Ломается при смене формулировки брокера | Сравнивать code, не message |
Считает not_found после Unknown окончательным | Ставит вторую заявку, пока первая в пути | Подождать и спросить еще раз. Правило 3 |
Ждет, что shutdown снимет заявки | Заявки остаются на бирже | Снять самому до shutdown. Правило 10 |
| Снимает все заявки счета «для чистоты» | Снимает чужие защитные стопы | Только свои, из журнала. Правило 11 |
Отправляет период в год одним candles | Rejected по длине периода | CandlePages. Правило 12 |
| Не запоминает шаг цены | Отказ придет от брокера, а не до отправки | price_grid().learn(¶ms) |
| Количество не кратно лоту, дробное или нулевое | invalid_qty до отправки | Количество в единицах инструмента, кратно лоту. Деньги, цены и количества |
| Берет идентификатор инструмента от другого брокера | Config | find у MD своего брокера |
| Подключает крейт без флага брокера | Нет brokapi::bcs | features = ["bcs"] и другие. Быстрый старт |
Запускает #[tokio::main(flavor = "current_thread")] с блокирующим кодом | Зависания | Обычный многопоточный tokio, блокирующее - в spawn_blocking |
Пишет в журнал после place | Падение между вызовом и записью теряет заявку | До вызова, с fsync. Правило 1 |
Ставит заявки через raw() | Заявка не попадает ни в события, ни в reconcile, коннектор за ней не следит | Заявки только через OrderExecutor. raw |
Перед запуском на настоящем счете
Прочитайте Перед боевым счетом и список допущений по документации брокеров: agents/unverified.md. Кроме них:
- Пройти тесты против имитаций (Как тестировать): путь
Unknown,Overflow, рестарт со сверкой. - Токены только в
.env, в журнал и логи не попадают. - Первая боевая заявка - только со слова человека, токен с правом торговли выдал он (правило 0).
- Известно, как остановить бота и что при этом останется на счете: Аварийная остановка бота.
Ошибки и что с ними делать
Справочник: ошибка или состояние, что оно значит, почему бывает, что делать и куда смотреть дальше. Текст в первой
колонке - то, что печатает ошибка в коде (Display), а не пересказ: по нему можно искать страницу поиском. Тест в
репозитории проверяет, что тексты ошибок из brokapi::core::Error лежат на этой странице. Объяснение, откуда эти
исходы берутся: Круг команд, Круг жизни, Круг событий. У каждой строки таблиц есть якорь
(errors.html#unknown, errors.html#rate-limited, errors.html#duplicate_place): на нее можно сослаться из своего кода и документов.
Python. У каждой ошибки есть одноименное исключение в brokapi.errors (и на верхнем уровне пакета: brokapi.Unknown, brokapi.Rejected и так далее), текст str(error) равен тексту в первой колонке. Error::class() - это error.error_class (ErrorClass.RETRYABLE, FATAL, UNKNOWN). Поля вариантов - атрибуты: Rejected.code, Rejected.message, RateLimited.retry_after (timedelta), Unsupported.capability. Подробнее: Как Python связан с ядром.
Две вещи помогают разобраться быстрее. Метод Error::class() (тип brokapi::core::ErrorClass) отвечает на вопрос «можно ли повторить»: Retryable
(сеть, лимит), Fatal (тот же вызов упадет снова), Unknown (исход команды неизвестен). И правило для всех
команд: команда не повторяется автоматически, а повторять вручную можно только после того, как вы узнали, что
заявки нет.
Ошибки вызова (brokapi::core::Error)
| Ошибка или состояние | Что значит | Почему бывает | Что делать | Подробнее |
|---|---|---|---|---|
configuration error: <причина> (Config) | Неверная настройка или вход, который не подходит этому брокеру. Ничего не отправлено | Токен или секрет не приняты брокером (у всех трех: БКС, Т-Банк UNAUTHENTICATED, Финам), счет не найден, формат cid не вмещается, идентификатор инструмента другого брокера, маршрут лимитов указывает на несуществующее ведро | Исправить настройку или вход. Повтор не поможет, вызов упадет так же | Брокеры |
transport error: <причина> (Transport) | Сбой связи, ответа брокера нет. Для чтения - повторить можно | Обрыв, TLS, таймаут чтения, оборванный поток, нечитаемый ответ. На команде означает, что у коннектора нет признаков, что она дошла, но доказать это он не всегда может: считайте, что заявка могла дойти | Чтение: повторить позже. Команда: сначала посмотреть заявку (order_state), потом решать. Новая заявка - с новым cid | Если что-то пошло не так |
rate limited, retry after <время> (RateLimited) | Не хватило квоты: у счетчика библиотеки или брокер ответил «слишком много запросов» (у Т-Банка RESOURCE_EXHAUSTED с номером 80002, 80003, 80006 и без известного номера. Остальные номера этого статуса - Rejected, см. таблицу ниже). В сеть ничего не ушло (если отказал счетчик) | Бот шлет быстрее квоты, один счетчик делят разные коннекторы, или общее ведро исчерпано чтением | Подождать retry_after, отправить снова. Если отказал счетчик библиотеки, cid не потрачен и годится снова, но новый cid безопаснее всегда. Если ответил брокер, только новый cid. Библиотека сама не ждет и не повторяет | Лимиты запросов |
rejected: <код>: <текст> (Rejected) | Брокер (или библиотека до отправки) точно отказал, заявки нет | Цена вне коридора, нет денег, неизвестная заявка, заявка уже исполнена, не хватает цены у лимитной. Конкретная причина - в code и message | Не повторять тот же запрос. Исправить причину и отправить с новым cid. Сравнивайте code, а не message | коды отказов - таблица ниже |
command outcome unknown: do not send it again, ask the broker with order_state(cid) (Unknown) | Команда ушла, ответа нет. Заявка могла встать, могла не дойти | Таймаут, обрыв после отправки, ошибка сервера 5xx, нечитаемый ответ | Не повторять. Спросить брокера: order_state(cid), при сомнении active_orders, executions, reconcile. Новую заявку - только когда брокер подтвердил, что первой нет, и с новым cid | Если что-то пошло не так, Почему нет автоповтора |
unsupported capability: <возможность> (Unsupported) | Брокер или коннектор этого не умеет. Проверено без сети | Например, replace у Финама, свечи 10 минут у БКС, поток стоп-заявок у всех | Это не сбой. Убрать вызов или проверить caps() заранее. Библиотека не подменяет отсутствующий метод опросом | События и возможности |
not reconciled: call reconcile() before trading commands (NotReconciled) | Шлюз команд закрыт: после запуска не было успешной сверки. Ничего не отправлено | Бот сразу ставит заявку, не вызвав reconcile, или сверка упала с ошибкой | Вызвать reconcile(&журнал) (пустой список, если журнала нет), потом повторить команду. cid годен | Круг жизни, Запуск, рестарт и остановка |
connector degraded: events may be lost, read the event channel to the end and call reconcile() (Degraded) | Шлюз закрыт: переполнился буфер подписки без потерь или оборвался и открылся заново поток своих заявок или исполнений (Event::Reconnected), события о своих заявках могли пропасть. Ничего не отправлено. Верно и для команды, которая стояла в очереди по бумаге, когда шлюз закрылся | Бот читает канал медленнее, чем идут события, или оборвалась связь с потоком заявок | Дочитать канал до пустого, прочитать состояние у брокера, вызвать reconcile, после успеха шлюз открыт. Потом сделать канал быстрее | Если что-то пошло не так, Круг событий |
shutting down: the connector takes no new trading commands (ShuttingDown) | Идет остановка коннектора, шлюз закрыт навсегда. Ничего не отправлено | Вызван shutdown, команда пришла из другой задачи | Ничего не отправлять. Нужна торговля - запустить новый коннектор. Заявки на бирже остановка не снимает | Запуск, рестарт и остановка |
Коды Rejected
Коды ставит коннектор, они не общие для всех брокеров. Часть отказов библиотека возвращает до отправки (в сеть ничего не уходит), часть выводит из ответа брокера.
| Код | Что значит | Когда | Что делать |
|---|---|---|---|
duplicate_place | Этот cid уже отправлялся (place, place_stop) | Повтор после таймаута, повтор после отказа брокера. Единица квоты на этот вызов уже потрачена, в сеть ничего не ушло | Не повторять. Выяснить судьбу первой заявки, новая - с новым cid |
invalid_qty | Количество не подходит: нуль, отрицательное, дробное там, где брокер берет целые (БКС, Т-Банк), не целое число лотов (Т-Банк всегда, БКС и Финам если лот передан в price_grid()) | Проверка входа до отправки. Ничего не округляется, ничего не отправлено, cid годен | Исправить количество. Оно в единицах инструмента, не в лотах (Деньги, цены и количества) |
invalid_order | Финам: форма заявки неверна (например, не удалось собрать символ) | Проверка входа | Исправить заявку |
missing_price | Лимитная заявка без цены (у Т-Банка еще и с ценой не больше нуля) | Проверка входа | Указать цену. cid годен |
qty_required | БКС: заявка частично исполнена, в replace нужно указать количество | replace без qty на частично исполненной | Указать количество явно |
already_filled | БКС: заявка исполнилась раньше, чем дошла отмена | Гонка отмены и исполнения | Читать итог: order_state, executions |
not_found | Брокер не знает такую заявку (cancel, replace, order_state) или инструмент. Один и тот же код у всех брокеров (константа brokapi::core::NOT_FOUND, проверка Error::is_not_found()) | Заявка не ставилась, уже закрыта, неизвестный инструмент, или запрос после обрыва еще в пути | После Unknown не окончательно: подождать и спросить еще раз. См. recovery |
REJECTED | Т-Банк: брокер принял запрос, но ответил статусом «отклонено» | Решение биржи или брокера, причина в message | Исправить причину, новая заявка с новым cid |
rejected, cancel_rejected | БКС: заявка отклонена в ответе на постановку, отмена отклонена | Причина в message | Исправить причину |
validation | БКС: брокер не принял форму запроса (HTTP 400, ошибки полей в message) | Неверное поле в запросе | Исправить запрос |
subscription_quota | Финам: уже открыто максимум потоков | Слишком много подписок на соединение | Снять ненужные подписки |
unknown_subscription | Все брокеры: unsubscribe по номеру, которого нет | Подписка уже снята или номер чужой | Не снимать повторно |
stop_orders_limit | Т-Банк: по инструменту набрано максимум стоп-заявок (ответ 80007) | place_stop по инструменту, где уже есть предельное число стопов | Снять одну из стоп-заявок. Повтор не поможет. Заявки нет, cid годен |
streams_limit, no_subscriptions, stream_lifetime | Т-Банк: открыто максимум потоков (80001), в потоке нет подписок (80004), поток прожил свой срок (80005) | Ответ RESOURCE_EXHAUSTED с этими номерами. Это не лимит запросов | Закрыть ненужный поток, подписаться, подключиться заново |
stop_orders_response_limit | Т-Банк: стоп-заявок слишком много для одного ответа (30261), и чтение по периодам не помогло | active_stop_orders при очень длинном списке | Снять ненужные стоп-заявки или читать период за периодом через raw() (Т-Банк) |
range_too_long | История сделок (executions) не помещается в то, что умеет читать коннектор: Т-Банк длиннее 20 страниц по 1000 операций, БКС список упирается в предел 1000 последних сделок, Финам больше 100 страниц или больше сделок с одной временной меткой, чем помещается в ответ | Слишком большой период | Читать по частям: разбить период. Ничего из прочитанного не возвращается, чтобы обрезок не сошел за всю историю |
unsupported_expiry, missing_limit_price, invalid_price, unsupported_limit_price, unknown_stop_order | Стоп-заявки | Форма запроса или неизвестный стоп | Стоп-заявки |
События и состояния, которые не являются ошибкой вызова
| Состояние | Что значит | Почему бывает | Что делать | Подробнее |
|---|---|---|---|---|
Event::Overflow { sub, lost_from } | Буфер Lossless-подписки заполнился, события начиная с lost_from могли быть потеряны. Приходит один раз на подписку. Следом шлюз закрывается (Degraded) | Бот отстает от потока своих заявок и сделок | Дочитать канал, reconcile, восстановить пропущенное из снимков. Для рыночной подписки: снять и оформить заново | Если что-то пошло не так |
Event::Reconnected { sub, gap_from } | Поток своих заявок или своих исполнений оборвался и открыт снова, за время обрыва могли пройти события, которых бот не видел. Следом шлюз закрывается (Degraded) | Обрыв связи с потоком заявок или исполнений | Дочитать канал, прочитать состояние у брокера (active_orders, order_state, executions с запасом раньше gap_from), вызвать reconcile | Круг событий, Если что-то пошло не так |
Event::Resync { sub, instrument, reason } | Стакан мог потерять данные, прежнему верить нельзя | SubscriptionReset: поток переоткрыт после обрыва. SequenceGap: брокер пропустил данные (Т-Банк, Финам) | Выбросить хранимый стакан, не торговать по нему, дождаться следующего Event::OrderBook | Круг событий |
OrderState::Unknown | Судьба заявки неизвестна. Из него библиотека сама не выходит | Таймаут или обрыв на команде, неизвестный статус Т-Банка, у БКС статус 6 «отменяется» (заявка еще жива) | Запросить состояние (order_state) или сделать сверку | Круг команд, recovery |
Список foreign в отчете сверки | На счете есть заявки, которых нет в журнале бота | Поставлены руками, из приложения или другим роботом | Не трогать. Показать человеку | Круг жизни |
Список closed_offline в отчете сверки | Заявка из журнала не найдена среди живых у брокера | Исполнилась, снята или не была принята, пока бот был выключен | Узнать итог: order_state, executions, занести в журнал | Круг жизни |
reconcile вернул ошибку | Сверка не состоялась, шлюз не открыт | Нет связи, лимит, у БКС ошибка или пустой ответ по одному из cid (ответ «брокер не знает номер» ошибкой не считается, это closed_offline) | Подождать, вызвать еще раз. Ошибка ничего не говорит о заявках | Круг жизни |
| События пропали, ошибок нет | Поток оборван, идет переподключение | Обрыв связи | Подождать. Когда поток откроется, придет Reconnected (заявки и исполнения) или Resync (стакан). У Финама смотреть sub_status. При сомнении reconcile | Круг событий |
| Подписка не подтверждается | subscribe_* вернул transport error: the subscription was not confirmed by the broker within <время> (Transport), либо Rejected { subscription_refused } | Transport: поток не открылся за subscribe_ack_timeout (сеть, медленный брокер, обрыв). Rejected: брокер отказал (нет такого инструмента, нет прав). Текст ошибки содержит причину. У Финама по таймауту ошибки нет, вызов возвращает Ok и поток дооткрывается в фоне (sub_status) | Transport: подписка уже снята, повторить позже. Rejected: исправить запрос, повтор ничего не изменит | Круг событий |
События и возможности
Справочник: что приходит в общий канал, в каком виде, и как спросить у коннектора, что он умеет. Откуда берутся
события и что происходит, когда бот отстает: Круг событий. Каждое поле, вариант и метод до последней
строки - в Справочнике API (крейт brokapi_core).
Python. Конверт - brokapi.Envelope, события - классы OrderBookSnapshot, OrderBookIncrement, MarketTrade, Quote, Candle, OrderSnapshot (событие Order), Execution, Portfolio, PositionsUpdate, BalancesUpdate, Resync, Overflow, Reconnected (поля sub и gap_from: время, когда коннектор заметил обрыв потока заявок или исполнений, шлюз закрыт до reconcile). Вид события, который пакет еще не знает, приходит как UnknownEvent, а цикл не падает. Возможности - await client.caps(). Справочник: Справочник API: Python.
Конверт события
Каждое событие приходит в конверте Envelope:
| Поле | Что это |
|---|---|
schema | версия формата конверта (сейчас 1). Растет только вперед, правки только добавляют поля |
source | метка коннектора, которую бот задал в конфиге ("tbank-ud-iis"). Библиотека ее не разбирает. Нужна, чтобы различать коннекторы в общем канале |
sub | номер подписки (SubscriptionId). Его вернул subscribe_*. Уникален только внутри одного коннектора, поэтому событие адресуется парой source + sub. Поле пустое у событий вне подписки. Например, переходы заявки после place, если бот не вызывал subscribe_order_state: Т-Банк в этом случае шлет их без sub |
ts | время (Timestamps) |
payload | само событие (Event) |
Время в ts:
| Поле | Что это | Для чего |
|---|---|---|
rx_ts | время получения по настенным часам | логи и сопоставление |
rx_mono | монотонные часы при получении. При записи конверта в файл или сеть не сохраняется | единственный источник для замеров задержки и таймаутов |
broker_ts | время, которое назвал брокер. Может отсутствовать, быть неточным или идти назад | для расчета задержек не используйте |
События (Event)
| Событие | Что значит |
|---|---|
OrderBook | стакан. У всех брокеров приходит полным снимком (Snapshot), не приращением |
MarketTrade, Quote, Candle | сделка рынка, котировка (лучшая покупка и продажа), свеча |
Order | новое состояние заявки (OrderSnapshot). Всегда несет cid |
Execution | своя сделка. Если связь с заявкой известна, несет cid |
Portfolio, Positions, Balances | состояние счета |
Resync | стакан мог потерять данные, прежний выбросить и ждать новый снимок |
Overflow | буфер подписки переполнился, торговля остановлена до сверки |
Reconnected | поток своих заявок или исполнений оборвался и открыт снова, за обрыв могли пройти события, которых бот не видел. Торговля остановлена до сверки |
Что делать с Resync, Overflow и Reconnected: Ошибки и что с ними делать и
Если что-то пошло не так, при обрыве потока заявок.
Возможности коннектора (caps())
У каждого коннектора есть метод caps(): что он умеет. Так бот может не предполагать, а спросить.
| Поле | БКС | Т-Банк | Финам | АЛОР | Bybit |
|---|---|---|---|---|---|
| Макс. глубина стакана (MD) | 20 | 50 | 50 | 50 | 1000 |
| Стакан приращениями (MD) | нет | нет | нет | нет | нет |
Изменение заявки (supports_replace) | да | да | нет | да | нет |
Стоп-заявки (supports_stop_orders) | да | да | да | да | да |
Поток своих сделок (streams_executions) | да | да | да | да | да |
Песочница (sandbox) | нет | по настройке | нет | нет | на демо-счете |
Повтор cid (place_idempotency) | LabelOnly | LabelOnly | LabelOnly | LabelOnly | LabelOnly |
LabelOnly значит: «cid для брокера лишь метка, повтор той же заявки создаст вторую». Это осторожное
значение по умолчанию. Что делает брокер с повтором, зависит от брокера, и в любом
случае библиотека заявку не повторяет.
Многопоточность
Все публичные типы Send + Sync, асинхронные вызовы Send. Нужен обычный многопоточный tokio. Коннекторы можно
вызывать из нескольких задач одновременно.
Покрытие API брокеров
Какие методы API брокера закрыты общим интерфейсом. Колонка Где:
- в интерфейсе - метод доступен через общие трейты (
brokapi-core). - через raw() - общего трейта нет, метод доступен через
connector.raw()с теми же токеном, адресом и общим счетчиком лимитов. - нет - метода нет в библиотеке.
Все методы брокера доступны через raw(), даже если общего трейта для них нет.
Т-Банк
Источник: официальные .proto в crates/tbank-protocol/proto.
Всего 101: в интерфейсе 30, через raw() 71.
| Сервис | Метод | Поток | Где |
|---|---|---|---|
| InstrumentsService | TradingSchedules | в интерфейсе | |
| InstrumentsService | BondBy | через raw() | |
| InstrumentsService | Bonds | в интерфейсе | |
| InstrumentsService | GetBondCoupons | через raw() | |
| InstrumentsService | GetBondEvents | через raw() | |
| InstrumentsService | CurrencyBy | через raw() | |
| InstrumentsService | Currencies | в интерфейсе | |
| InstrumentsService | EtfBy | через raw() | |
| InstrumentsService | Etfs | в интерфейсе | |
| InstrumentsService | FutureBy | через raw() | |
| InstrumentsService | Futures | в интерфейсе | |
| InstrumentsService | OptionBy | через raw() | |
| InstrumentsService | Options | в интерфейсе | |
| InstrumentsService | OptionsBy | через raw() | |
| InstrumentsService | ShareBy | через raw() | |
| InstrumentsService | Shares | в интерфейсе | |
| InstrumentsService | Indicatives | через raw() | |
| InstrumentsService | GetAccruedInterests | через raw() | |
| InstrumentsService | GetFuturesMargin | через raw() | |
| InstrumentsService | GetInstrumentBy | в интерфейсе | |
| InstrumentsService | GetDividends | через raw() | |
| InstrumentsService | GetAssetBy | через raw() | |
| InstrumentsService | GetAssets | через raw() | |
| InstrumentsService | GetFavorites | через raw() | |
| InstrumentsService | EditFavorites | через raw() | |
| InstrumentsService | CreateFavoriteGroup | через raw() | |
| InstrumentsService | DeleteFavoriteGroup | через raw() | |
| InstrumentsService | GetFavoriteGroups | через raw() | |
| InstrumentsService | GetCountries | через raw() | |
| InstrumentsService | FindInstrument | в интерфейсе | |
| InstrumentsService | GetBrands | через raw() | |
| InstrumentsService | GetBrandBy | через raw() | |
| InstrumentsService | GetAssetFundamentals | через raw() | |
| InstrumentsService | GetAssetReports | через raw() | |
| InstrumentsService | GetConsensusForecasts | через raw() | |
| InstrumentsService | GetForecastBy | через raw() | |
| InstrumentsService | GetRiskRates | через raw() | |
| InstrumentsService | GetInsiderDeals | через raw() | |
| InstrumentsService | StructuredNoteBy | через raw() | |
| InstrumentsService | StructuredNotes | через raw() | |
| MarketDataService | GetCandles | в интерфейсе | |
| MarketDataService | GetLastPrices | через raw() | |
| MarketDataService | GetOrderBook | в интерфейсе | |
| MarketDataService | GetTradingStatus | в интерфейсе | |
| MarketDataService | GetTradingStatuses | через raw() | |
| MarketDataService | GetLastTrades | в интерфейсе | |
| MarketDataService | GetClosePrices | через raw() | |
| MarketDataService | GetTechAnalysis | через raw() | |
| MarketDataService | GetMarketValues | через raw() | |
| MarketDataStreamService | MarketDataStream | да | в интерфейсе |
| MarketDataStreamService | MarketDataServerSideStream | да | через raw() |
| OperationsService | GetOperations | через raw() | |
| OperationsService | GetPortfolio | в интерфейсе | |
| OperationsService | GetPositions | в интерфейсе | |
| OperationsService | GetWithdrawLimits | через raw() | |
| OperationsService | GetBrokerReport | через raw() | |
| OperationsService | GetDividendsForeignIssuer | через raw() | |
| OperationsService | GetOperationsByCursor | в интерфейсе | |
| OperationsStreamService | PortfolioStream | да | в интерфейсе |
| OperationsStreamService | PositionsStream | да | в интерфейсе |
| OrdersStreamService | TradesStream | да | в интерфейсе |
| OrdersStreamService | OrderStateStream | да | в интерфейсе |
| OrdersService | PostOrder | в интерфейсе | |
| OrdersService | PostOrderAsync | через raw() | |
| OrdersService | CancelOrder | в интерфейсе | |
| OrdersService | GetOrderState | в интерфейсе | |
| OrdersService | GetOrders | в интерфейсе | |
| OrdersService | ReplaceOrder | в интерфейсе | |
| OrdersService | GetMaxLots | через raw() | |
| OrdersService | GetOrderPrice | через raw() | |
| SandboxService | OpenSandboxAccount | через raw() | |
| SandboxService | GetSandboxAccounts | через raw() | |
| SandboxService | CloseSandboxAccount | через raw() | |
| SandboxService | PostSandboxOrder | через raw() | |
| SandboxService | PostSandboxOrderAsync | через raw() | |
| SandboxService | ReplaceSandboxOrder | через raw() | |
| SandboxService | GetSandboxOrders | через raw() | |
| SandboxService | CancelSandboxOrder | через raw() | |
| SandboxService | GetSandboxOrderState | через raw() | |
| SandboxService | GetSandboxOrderPrice | через raw() | |
| SandboxService | GetSandboxPositions | через raw() | |
| SandboxService | GetSandboxOperations | через raw() | |
| SandboxService | GetSandboxOperationsByCursor | через raw() | |
| SandboxService | GetSandboxPortfolio | через raw() | |
| SandboxService | SandboxPayIn | через raw() | |
| SandboxService | GetSandboxWithdrawLimits | через raw() | |
| SandboxService | GetSandboxMaxLots | через raw() | |
| SandboxService | PostSandboxStopOrder | через raw() | |
| SandboxService | GetSandboxStopOrders | через raw() | |
| SandboxService | CancelSandboxStopOrder | через raw() | |
| SignalService | GetStrategies | через raw() | |
| SignalService | GetSignals | через raw() | |
| StopOrdersService | PostStopOrder | в интерфейсе | |
| StopOrdersService | GetStopOrders | в интерфейсе | |
| StopOrdersService | CancelStopOrder | в интерфейсе | |
| UsersService | GetAccounts | в интерфейсе | |
| UsersService | GetMarginAttributes | через raw() | |
| UsersService | GetUserTariff | через raw() | |
| UsersService | GetInfo | через raw() | |
| UsersService | GetBankAccounts | через raw() | |
| UsersService | CurrencyTransfer | через raw() |
Финам
Источник: официальные .proto в crates/finam-protocol/proto.
Всего 42: в интерфейсе 22, через raw() 20.
| Сервис | Метод | Поток | Где |
|---|---|---|---|
| AccountsService | GetAccount | в интерфейсе | |
| AccountsService | Trades | в интерфейсе | |
| AccountsService | Transactions | через raw() | |
| AccountsService | SubscribeAccount | да | в интерфейсе |
| AssetsService | Exchanges | через raw() | |
| AssetsService | Assets | в интерфейсе | |
| AssetsService | AllAssets | в интерфейсе | |
| AssetsService | GetAsset | в интерфейсе | |
| AssetsService | GetAssetParams | в интерфейсе | |
| AssetsService | OptionsChain | через raw() | |
| AssetsService | Schedule | в интерфейсе | |
| AssetsService | Clock | через raw() | |
| AssetsService | GetConstituents | через raw() | |
| AuthService | Auth | через raw() | |
| AuthService | TokenDetails | через raw() | |
| AuthService | SubscribeJwtRenewal | да | через raw() |
| CorporateActionsService | GetFutureSplits | через raw() | |
| CorporateActionsService | GetPastSplits | через raw() | |
| CorporateActionsService | GetFutureBondsEvents | через raw() | |
| CorporateActionsService | GetPastBondsEvents | через raw() | |
| CorporateActionsService | GetFutureDividends | через raw() | |
| CorporateActionsService | GetPastDividends | через raw() | |
| MarketDataService | Bars | в интерфейсе | |
| MarketDataService | LastQuote | через raw() | |
| MarketDataService | OrderBook | в интерфейсе | |
| MarketDataService | LatestTrades | в интерфейсе | |
| MarketDataService | SubscribeQuote | да | в интерфейсе |
| MarketDataService | SubscribeOrderBook | да | в интерфейсе |
| MarketDataService | SubscribeLatestTrades | да | в интерфейсе |
| MarketDataService | SubscribeBars | да | в интерфейсе |
| UsageMetricsService | GetUsageMetrics | через raw() | |
| OrdersService | PlaceOrder | в интерфейсе | |
| OrdersService | CancelOrder | в интерфейсе | |
| OrdersService | GetOrders | в интерфейсе | |
| OrdersService | GetOrder | в интерфейсе | |
| OrdersService | SubscribeOrderTrade | да | через raw() |
| OrdersService | SubscribeOrders | да | в интерфейсе |
| OrdersService | SubscribeTrades | да | в интерфейсе |
| OrdersService | PlaceSLTPOrder | в интерфейсе | |
| ReportsService | CreateAccountReport | через raw() | |
| ReportsService | GetAccountReportInfo | через raw() | |
| ReportsService | SubscribeAccountReportInfo | да | через raw() |
БКС
Источник: документация БКС (trade-api.bcs.ru). Устаревшие методы (deprecated) не считаем.
Всего 33: в интерфейсе 22, через raw() 11.
| Сервис | Метод | Поток | Где |
|---|---|---|---|
| REST | POST /trade-api-keycloak/realms/tradeapi/protocol/openid-connect/token (authenticate) | в интерфейсе | |
| REST | GET /api/v1/instruments-discounts (get_instruments_discounts) | через raw() | |
| REST | POST /api/v1/operations/search (search_operations) | через raw() | |
| REST | GET /api/v1/trading-schedule/daily-schedule (get_daily_schedule) | в интерфейсе | |
| REST | POST /api/v1/instruments/by-isins (get_instruments_by_isins) | в интерфейсе | |
| REST | POST /api/v1/instruments/by-tickers (get_instruments_by_tickers) | в интерфейсе | |
| REST | GET /api/v1/instruments/by-type (get_instruments_by_type) | в интерфейсе | |
| REST | GET /api/v1/trading-schedule/status (get_trading_status) | в интерфейсе | |
| REST | GET /api/v1/limits (get_limits) | через raw() | |
| REST | GET /api/v1/candles-chart (get_candles) | в интерфейсе | |
| REST | POST /api/v1/last-trades (get_last_trades) | в интерфейсе | |
| REST | GET /api/v1/order-book (get_order_book) | в интерфейсе | |
| REST | POST /api/v1/quotes (get_quotes) | через raw() | |
| REST | POST /api/v1/orders/search (search_orders, search_orders_simple) | в интерфейсе | |
| REST | POST /api/v1/orders/cancel (cancel_order_by_id) | через raw() | |
| REST | POST /api/v1/orders (create_order) | через raw() | |
| REST | GET /api/v1/orders (get_order) | в интерфейсе | |
| REST | POST /api/v1/orders/cancel/sync (cancel_order_by_id_sync) | в интерфейсе | |
| REST | POST /api/v1/orders/sync (create_order_sync) | в интерфейсе | |
| REST | POST /api/v1/orders/edit/sync (edit_order_sync) | в интерфейсе | |
| REST | POST /api/v1/orders/edit (edit_order) | через raw() | |
| REST | GET /api/v1/portfolio (get_portfolio) | в интерфейсе | |
| REST | POST /api/v1/trades/search (search_trades) | в интерфейсе | |
| WebSocket | market-data/order-book (connect_market_data) | да | в интерфейсе |
| WebSocket | market-data/quotes (connect_market_data) | да | в интерфейсе |
| WebSocket | market-data/trades (connect_market_data) | да | в интерфейсе |
| WebSocket | market-data/last-candle (connect_market_data) | да | в интерфейсе |
| WebSocket | operations/execution-status (connect_order_execution) | да | через raw() |
| WebSocket | operations/status (connect_order_events) | да | в интерфейсе |
| WebSocket | operations/transaction-status (connect_order_transaction) | да | через raw() |
| WebSocket | portfolio (connect_portfolio) | да | в интерфейсе |
| WebSocket | limits (connect_limits) | да | через raw() |
| WebSocket | marginal-indicators (connect_marginal_indicators) | да | через raw() |
АЛОР
Источник: описания API брокера (OpenAPI для HTTP и AsyncAPI для WebSocket, alor.dev). Команды канала /cws в клиенте есть (CwsSession), коннектор ходит за теми же командами по HTTP.
Всего 84: в интерфейсе 25, через raw() 59.
| Сервис | Метод | Поток | Где |
|---|---|---|---|
| REST | POST /refresh (get_access_token, refresh_access_token) | в интерфейсе | |
| REST | GET /md/v2/Clients/{exchange}/{portfolio}/orders (get_orders) | в интерфейсе | |
| REST | GET /md/v2/Clients/{exchange}/{portfolio}/orders/{orderId} (get_order) | в интерфейсе | |
| REST | GET /md/v2/Clients/{exchange}/{portfolio}/stoporders (get_stop_orders) | в интерфейсе | |
| REST | GET /md/v2/Clients/{exchange}/{portfolio}/stoporders/{orderId} (get_stop_order) | через raw() | |
| REST | GET /md/v2/Clients/legacy/{exchange}/{portfolio}/money (get_money_legacy) | через raw() | |
| REST | GET /md/v2/Clients/{exchange}/{portfolio}/summary (get_summary) | через raw() | |
| REST | GET /md/v2/Clients/{exchange}/{portfolio}/positions (get_positions) | в интерфейсе | |
| REST | GET /md/v2/Clients/{exchange}/{portfolio}/positions/{symbol} (get_position) | через raw() | |
| REST | GET /md/v2/Clients/{exchange}/{portfolio}/trades (get_trades) | в интерфейсе | |
| REST | GET /md/v2/Clients/{exchange}/{portfolio}/{symbol}/trades (get_symbol_trades) | через raw() | |
| REST | GET /md/v2/Clients/{exchange}/{portfolio}/fortsrisk (get_forts_risk) | через raw() | |
| REST | GET /md/v2/Clients/{exchange}/{portfolio}/risk (get_risk) | через raw() | |
| REST | GET /md/v2/Clients/{login}/positions (get_login_positions) | через raw() | |
| REST | GET /md/stats/{exchange}/{portfolio}/history/trades (get_trade_stats) | через raw() | |
| REST | GET /md/stats/{exchange}/{portfolio}/history/trades/{symbol} (get_trade_stats_by_symbol) | через raw() | |
| REST | GET /md/v2/Stats/{exchange}/{portfolio}/history/trades (get_history_trades) | в интерфейсе | |
| REST | GET /md/v2/Stats/{exchange}/{portfolio}/history/trades/{symbol} (get_history_trades_by_symbol) | через raw() | |
| REST | GET /md/v2/Securities (get_securities) | в интерфейсе | |
| REST | GET /md/v2/Securities/{exchange} (get_securities_by_exchange) | через raw() | |
| REST | GET /md/v2/Securities/{exchange}/{symbol} (get_security) | в интерфейсе | |
| REST | GET /md/v2/Securities/{exchange}/{symbol}/availableBoards (get_available_boards) | через raw() | |
| REST | GET /md/v2/Securities/{exchange}/{symbol}/alltrades (get_all_trades) | в интерфейсе | |
| REST | GET /md/v2/Securities/{exchange}/{symbol}/alltrades/history (get_all_trades_history) | через raw() | |
| REST | GET /md/v2/Securities/{exchange}/{symbol}/actualFuturesQuote (get_actual_futures_quote) | через raw() | |
| REST | GET /md/v2/Securities/{symbols}/quotes (get_quotes) | через raw() | |
| REST | GET /md/v2/Securities/currencyPairs (get_currency_pairs) | через raw() | |
| REST | GET /md/v2/orderbooks/{exchange}/{symbol} (get_order_book) | в интерфейсе | |
| REST | GET /md/v2/time (get_time) | через raw() | |
| REST | GET /md/v2/risk/rates (get_risk_rates) | через raw() | |
| REST | GET /md/v2/history (get_history) | в интерфейсе | |
| REST | GET /md/status/{exchange}/{market} (get_market_status) | через raw() | |
| REST | POST /commandapi/warptrans/TRADE/v2/client/orders/actions/market (create_market_order) | в интерфейсе | |
| REST | POST /commandapi/warptrans/TRADE/v2/client/orders/actions/limit (create_limit_order) | в интерфейсе | |
| REST | POST /warptrans/{tradeServerCode}/v2/client/orders/actions/stopLoss (create_stop_loss) | через raw() | |
| REST | POST /warptrans/{tradeServerCode}/v2/client/orders/actions/takeProfit (create_take_profit) | через raw() | |
| REST | POST /warptrans/{tradeServerCode}/v2/client/orders/actions/takeProfitLimit (create_take_profit_limit) | через raw() | |
| REST | POST /warptrans/{tradeServerCode}/v2/client/orders/actions/stopLossLimit (create_stop_loss_limit) | через raw() | |
| REST | PUT /warptrans/{tradeServerCode}/v2/client/orders/actions/stopLoss/{orderId} (update_stop_loss) | через raw() | |
| REST | PUT /warptrans/{tradeServerCode}/v2/client/orders/actions/takeProfit/{orderId} (update_take_profit) | через raw() | |
| REST | PUT /warptrans/{tradeServerCode}/v2/client/orders/actions/takeProfitLimit/{orderId} (update_take_profit_limit) | через raw() | |
| REST | PUT /warptrans/{tradeServerCode}/v2/client/orders/actions/stopLossLimit/{orderId} (update_stop_loss_limit) | через raw() | |
| REST | PUT /commandapi/warptrans/TRADE/v2/client/orders/actions/market/{orderId} (update_market_order) | через raw() | |
| REST | PUT /commandapi/warptrans/TRADE/v2/client/orders/actions/limit/{orderId} (update_limit_order) | в интерфейсе | |
| REST | DELETE /warptrans/{tradeServerCode}/v2/client/orders/{orderId} (cancel_order_legacy) | через raw() | |
| REST | POST /commandapi/warptrans/TRADE/v2/client/orders/estimate (estimate_order) | через raw() | |
| REST | POST /commandapi/warptrans/TRADE/v2/client/orders/estimate/all (estimate_orders) | через raw() | |
| REST | POST /commandapi/warptrans/TRADE/v2/client/orders/actions/stopLimit (create_stop_limit_order) | в интерфейсе | |
| REST | POST /commandapi/warptrans/TRADE/v2/client/orders/actions/stop (create_stop_order) | в интерфейсе | |
| REST | PUT /commandapi/warptrans/TRADE/v2/client/orders/actions/stop/{stopOrderId} (update_stop_order) | через raw() | |
| REST | PUT /commandapi/warptrans/TRADE/v2/client/orders/actions/stopLimit/{stopOrderId} (update_stop_limit_order) | через raw() | |
| REST | DELETE /commandapi/warptrans/TRADE/v2/client/orders/{orderId} (cancel_order) | в интерфейсе | |
| REST | DELETE /commandapi/warptrans/TRADE/v2/client/orders/all (cancel_all_orders) | через raw() | |
| REST | GET /commandapi/api/orderGroups (get_order_groups) | через raw() | |
| REST | POST /commandapi/api/orderGroups (create_order_group) | через raw() | |
| REST | GET /commandapi/api/orderGroups/{orderGroupId} (get_order_group) | через raw() | |
| REST | PUT /commandapi/api/orderGroups/{orderGroupId} (update_order_group) | через raw() | |
| REST | DELETE /commandapi/api/orderGroups/{orderGroupId} (delete_order_group) | через raw() | |
| WebSocket | subscribeOrderBook /ws (WsSubscribe::order_book) | да | в интерфейсе |
| WebSocket | subscribeBars /ws (WsSubscribe::bars) | да | в интерфейсе |
| WebSocket | subscribeQuotes /ws (WsSubscribe::quotes) | да | в интерфейсе |
| WebSocket | subscribeTrades /ws (WsSubscribe::trades) | да | в интерфейсе |
| WebSocket | subscribeAllTrades /ws (WsSubscribe::all_trades) | да | в интерфейсе |
| WebSocket | subscribePositions /ws (WsSubscribe::positions) | да | в интерфейсе |
| WebSocket | subscribeSummaries /ws (WsSubscribe::summaries) | да | через raw() |
| WebSocket | subscribeRisks /ws (WsSubscribe::risks) | да | через raw() |
| WebSocket | subscribeSpectraRisks /ws (WsSubscribe::spectra_risks) | да | через raw() |
| WebSocket | subscribeInstruments /ws (WsSubscribe::instruments) | да | через raw() |
| WebSocket | subscribeOrders /ws (WsSubscribe::orders) | да | в интерфейсе |
| WebSocket | subscribeStopOrders /ws (WsSubscribe::stop_orders) | да | через raw() |
| WebSocket | unsubscribe /ws (WsSubscribe::unsubscribe) | да | через raw() |
| WebSocket | authorize /cws (CwsSession::authorize) | да | через raw() |
| WebSocket | createMarketOrder /cws (CREATE_MARKET) | да | через raw() |
| WebSocket | createLimitOrder /cws (CREATE_LIMIT) | да | через raw() |
| WebSocket | createStopMarketOrder /cws (CREATE_STOP) | да | через raw() |
| WebSocket | createStopLimitOrder /cws (CREATE_STOP_LIMIT) | да | через raw() |
| WebSocket | updateMarketOrder /cws (UPDATE_MARKET) | да | через raw() |
| WebSocket | updateLimitOrder /cws (UPDATE_LIMIT) | да | через raw() |
| WebSocket | updateStopMarketOrder /cws (UPDATE_STOP) | да | через raw() |
| WebSocket | updateStopLimitOrder /cws (UPDATE_STOP_LIMIT) | да | через raw() |
| WebSocket | deleteMarketOrder /cws (DELETE_MARKET) | да | через raw() |
| WebSocket | deleteLimitOrder /cws (DELETE_LIMIT) | да | через raw() |
| WebSocket | deleteStopMarketOrder /cws (DELETE_STOP) | да | через raw() |
| WebSocket | deleteStopLimitOrder /cws (DELETE_STOP_LIMIT) | да | через raw() |
Справочник API: Rust
Справочник по каждому публичному типу, трейту, методу, полю и ошибке собирается из кода (rustdoc), поэтому он не расходится с кодом. Он на английском, как принято у библиотек Rust. Книга объясняет, как это устроено и что делать, справочник отвечает на вопрос «что делает этот метод и какие ошибки вернет».
Справочник лежит рядом с книгой, в папке api/. Ссылки ниже ведут в него, а вверху его страниц есть путь назад в книгу.
Тот же справочник текстом, одним файлом, для чтения агентом: api-reference.md (объявления типов, поля, варианты, методы трейтов с разделами Errors и Contract, без примеров кода).
Каждый пример в справочнике - тест, он запускается против имитации брокера (cargo test --doc). Сеть и деньги в них не
задействованы.
| Крейт | Что в нем | Справочник |
|---|---|---|
brokapi | главный крейт: все остальное под одним именем, брокеры включаются флагами bcs, tbank, finam, alor, bybit | brokapi |
brokapi-core | типы, трейты возможностей, ошибки, Envelope. С него начинать | brokapi_core |
brokapi-runtime | лимиты запросов, буферы подписок, сверка, остановка | brokapi_runtime |
brokapi-sansio | ядро коннектора без сети, стенд для записанных кадров | brokapi_sansio |
brokapi-bcs | коннекторы БКС | brokapi_bcs |
brokapi-tbank | коннекторы Т-Банка | brokapi_tbank |
brokapi-finam | коннекторы Финама | brokapi_finam |
brokapi-alor | коннекторы АЛОРа | brokapi_alor |
brokapi-bybit | коннекторы Bybit | brokapi_bybit |
brokapi-bcs-protocol | нижний слой БКС: REST и WebSocket | brokapi_bcs_protocol |
brokapi-tbank-protocol | нижний слой Т-Банка: gRPC из официальных .proto | brokapi_tbank_protocol |
brokapi-finam-protocol | нижний слой Финама: gRPC из официальных .proto, JWT | brokapi_finam_protocol |
brokapi-alor-protocol | нижний слой АЛОРа: HTTP и WebSocket, все методы описания API | brokapi_alor_protocol |
brokapi-bybit-protocol | нижний слой Bybit: HTTP и WebSocket API V5, подпись запросов | brokapi_bybit_protocol |
brokapi-conformance | общие сценарии, которые проходит каждый коннектор | brokapi_conformance |
Имитации брокеров (brokapi-mocks) нужны только тестам и примерам. Они не часть библиотеки, в справочнике их нет.
Справочник API: Python
Справочник по каждому классу, методу, полю и ошибке пакета brokapi для Python собирается из docstring в коде (pdoc), поэтому он не
расходится с кодом. Он на английском. Книга объясняет, как это устроено и что делать, справочник отвечает на вопрос «что делает
этот метод и какие ошибки поднимет».
Справочник лежит рядом с книгой: Справочник API Python. Вверху его страниц есть путь назад в книгу. Страницы не делают запросов к внешним адресам: шрифты и стили свои.
Тот же справочник текстом, одним файлом, для чтения агентом: python/api-reference.md (сигнатуры и описания классов и методов без оформления).
| Модуль | Что в нем | Справочник |
|---|---|---|
brokapi | connect, Client, EventStream, merge, new_cid: все, что нужно для работы, и общий обзор | brokapi |
brokapi.models | данные: инструменты, стакан, заявки, события, возможности | brokapi.models |
brokapi.record | запись событий в файл и проигрыш из файла (тот же формат, что у Rust) | brokapi.record |
brokapi.errors | исключения с теми же именами, что у Error в Rust, и ErrorClass | brokapi.errors |
brokapi.numbers | точное округление до шага цены (round_to_step) | brokapi.numbers |
brokapi.testing | имитации брокеров для тестов | brokapi.testing |
Примеры в docstring, помеченные >>>, запускаются тестом (pytest --doctest-modules), так что справочник не может тихо устареть.
Справочник Rust: Справочник API: Rust. Имена ошибок, событий и возможностей у Python те же, что у ядра, поэтому Ошибки и что с ними делать и События и возможности верны для обоих.
Справочник API: Gateway (gRPC)
Контракт между ботом и гейтвеем: сервисы, вызовы, сообщения и перечисления. Страница собрана из файлов .proto в каталоге
proto/ репозитория, руками не правится: сборка книги сравнивает ее с файлами и падает, если они разошлись.
Как подключиться, какие заголовки слать и в каком порядке вызывать: Подключить бота. Как устроены события и
что делать с Gap и Lagged: События и сверка после перезапуска. Что значит каждая ошибка и что с ней делать:
Ошибки и что с ними делать.
В файлах три пакета описаний:
| Файл | Для кого | Что в нем |
|---|---|---|
gateway.proto | боты | сервис Gateway: заявки, счет, рынок, подписки, Health и поток Events |
types.proto | боты | значения, которыми обмениваются вызовы: инструменты, заявки, портфель, рыночные данные |
service.proto | команды brokapi | сервис Service у службы brokapi up. Боту он не нужен |
Описания и комментарии в файлах английские, так они и показаны ниже: это тот самый текст, который увидит ваш генератор кода.
Gateway: служба для ботов и события
Файл proto/brokapi/gateway/v1/gateway.proto, пакет brokapi.gateway.v1.
The brokapi gateway: one process that holds the connection to one broker account and serves any number of bots.
How a bot uses it:
1. Connect to the Unix socket of the gateway (or 127.0.0.1 on Windows) and send two metadata headers with every
call: `authorization: Bearer <the key from the access.key file>` and `x-brokapi-bot: <name of the bot>`.
2. Open `Events` first, then subscribe. The first frame of `Events` says the stream is registered. Events that
happen after it are yours.
3. Call `Reconcile` once before any trading command.
An error of the library comes back as a gRPC status with the `brokapi-error-bin` trailer, an `ErrorDetail`. The
status code is a hint for generic tools. The detail is the whole error: what a bot needs to decide whether to retry
is in `kind`.
Сервис Gateway
Orders: a command is never repeated by the gateway, and it runs to its end if you stop waiting
| Вызов | Запрос | Ответ | Описание |
|---|---|---|---|
Place | PlaceOrderRequest | OrderAck | |
Cancel | CancelRequest | CancelResponse | |
Replace | ReplaceOrderRequest | OrderAck | |
OrderState | OrderStateRequest | OrderSnapshot | |
ActiveOrders | ActiveOrdersRequest | ActiveOrdersResponse | |
Reconcile | ReconcileRequest | ReconcileReport | |
SubscribeOrderState | SubscribeRequest | SubscribeResponse |
Stop orders
| Вызов | Запрос | Ответ | Описание |
|---|---|---|---|
PlaceStop | PlaceStopOrderRequest | OrderAck | |
CancelStop | CancelStopRequest | CancelResponse | |
ActiveStopOrders | ActiveOrdersRequest | ActiveStopOrdersResponse | |
SubscribeStopOrders | SubscribeRequest | SubscribeResponse |
Account
| Вызов | Запрос | Ответ | Описание |
|---|---|---|---|
Portfolio | AccountRequest | Portfolio | |
Positions | AccountRequest | PositionsResponse | |
Balances | AccountRequest | BalancesResponse | |
Executions | ExecutionsRequest | ExecutionsResponse | |
SubscribeExecutions | SubscribeRequest | SubscribeResponse | |
SubscribePortfolio | SubscribeRequest | SubscribeResponse |
Market data
| Вызов | Запрос | Ответ | Описание |
|---|---|---|---|
OrderBook | OrderBookRequest | OrderBook | |
LastTrades | LastTradesRequest | LastTradesResponse | |
Candles | CandlesRequest | CandlesResponse | |
SubscribeOrderBook | SubscribeInstrumentRequest | SubscribeResponse | |
SubscribeMarketTrades | SubscribeInstrumentRequest | SubscribeResponse | |
SubscribeQuotes | SubscribeInstrumentRequest | SubscribeResponse | |
SubscribeCandles | SubscribeCandlesRequest | SubscribeResponse | |
Unsubscribe | UnsubscribeRequest | UnsubscribeResponse | Lets go of a subscription this bot holds. The same request of two bots is one subscription at the broker with two owners: it is closed when the last owner lets go, and a bot that does not hold it gets the refusal not_your_subscription. The subscriptions of a bot are also let go when the bot is gone (its event stream ended and did not come back within the grace period of the gateway). The events of a subscription go to every bot that reads the stream, whoever asked for it. |
Instruments
| Вызов | Запрос | Ответ | Описание |
|---|---|---|---|
ResolveInstrument | ResolveInstrumentRequest | InstrumentId | |
Find | FindRequest | FindResponse | |
GetInstrument | InstrumentRequest | Instrument | |
TradingParams | InstrumentRequest | TradingParams | |
TradingStatus | InstrumentRequest | TradingStatus | |
Schedule | ScheduleRequest | Schedule |
The rest
| Вызов | Запрос | Ответ | Описание |
|---|---|---|---|
Raw | RawRequest | RawResponse | Any method of the broker’s own API by name, arguments and reply as JSON text. See the page of the broker. |
Caps | CapsRequest | CapsResponse | |
Health | HealthRequest | HealthResponse | Works while the gateway starts: this is how a bot waits for it. |
Events | EventsRequest | поток EventFrame | The events of the connection. See EventsRequest. |
CancelRequest
| Поле | Тип | № | Описание |
|---|---|---|---|
cid | string | 1 |
CancelResponse
Без полей.
OrderStateRequest
| Поле | Тип | № | Описание |
|---|---|---|---|
cid | string | 1 |
ActiveOrdersRequest
Без полей.
ActiveOrdersResponse
| Поле | Тип | № | Описание |
|---|---|---|---|
orders | список OrderSnapshot | 1 |
ActiveStopOrdersResponse
| Поле | Тип | № | Описание |
|---|---|---|---|
stop_orders | список StopOrderSnapshot | 1 |
ReconcileRequest
| Поле | Тип | № | Описание |
|---|---|---|---|
ledger | список string | 1 | The ids of the orders you placed and have not seen end. |
include_journal | bool | 2 | Add the orders of the gateway’s own journal (all bots) to the ledger. Without it the orders of the other bots come back as foreign. |
CancelStopRequest
| Поле | Тип | № | Описание |
|---|---|---|---|
stop | StopOrderRef | 1 |
AccountRequest
Без полей.
PositionsResponse
| Поле | Тип | № | Описание |
|---|---|---|---|
positions | список Position | 1 |
BalancesResponse
| Поле | Тип | № | Описание |
|---|---|---|---|
balances | список Balance | 1 |
ExecutionsRequest
| Поле | Тип | № | Описание |
|---|---|---|---|
range | TimeRange | 1 |
ExecutionsResponse
| Поле | Тип | № | Описание |
|---|---|---|---|
executions | список Execution | 1 |
OrderBookRequest
| Поле | Тип | № | Описание |
|---|---|---|---|
instrument | InstrumentId | 1 | |
depth | uint32 | 2 |
LastTradesRequest
| Поле | Тип | № | Описание |
|---|---|---|---|
instrument | InstrumentId | 1 | |
limit | uint32 | 2 |
LastTradesResponse
| Поле | Тип | № | Описание |
|---|---|---|---|
trades | список MarketTrade | 1 |
CandlesRequest
| Поле | Тип | № | Описание |
|---|---|---|---|
instrument | InstrumentId | 1 | |
timeframe | Timeframe | 2 | |
range | TimeRange | 3 |
CandlesResponse
| Поле | Тип | № | Описание |
|---|---|---|---|
candles | список Candle | 1 |
SubscribeRequest
| Поле | Тип | № | Описание |
|---|---|---|---|
opts | SubscribeOpts | 1 |
SubscribeInstrumentRequest
| Поле | Тип | № | Описание |
|---|---|---|---|
instrument | InstrumentId | 1 | |
opts | SubscribeOpts | 2 |
SubscribeCandlesRequest
| Поле | Тип | № | Описание |
|---|---|---|---|
instrument | InstrumentId | 1 | |
timeframe | Timeframe | 2 | |
opts | SubscribeOpts | 3 |
SubscribeResponse
| Поле | Тип | № | Описание |
|---|---|---|---|
sub | uint64 | 1 | The id that marks the events of this subscription (Event.sub). |
UnsubscribeRequest
| Поле | Тип | № | Описание |
|---|---|---|---|
sub | uint64 | 1 |
UnsubscribeResponse
Без полей.
ResolveInstrumentRequest
| Поле | Тип | № | Описание |
|---|---|---|---|
ticker | string | 1 |
FindRequest
| Поле | Тип | № | Описание |
|---|---|---|---|
query | InstrumentQuery | 1 |
FindResponse
| Поле | Тип | № | Описание |
|---|---|---|---|
instruments | список Instrument | 1 |
InstrumentRequest
| Поле | Тип | № | Описание |
|---|---|---|---|
instrument | InstrumentId | 1 |
ScheduleRequest
| Поле | Тип | № | Описание |
|---|---|---|---|
exchange | string | 1 | |
range | TimeRange | 2 |
RawRequest
| Поле | Тип | № | Описание |
|---|---|---|---|
method | string | 1 | The name of the method of the broker’s API, as the broker names it. |
args_json | string | 2 | The arguments as a JSON object, empty for none. |
command | bool | 3 | True when the call changes something at the broker: a lost reply is then Unknown and the call is not repeated. |
RawResponse
| Поле | Тип | № | Описание |
|---|---|---|---|
reply_json | string | 1 |
CapsRequest
Без полей.
CapsResponse
| Поле | Тип | № | Описание |
|---|---|---|---|
md | MarketDataCaps | 1 | |
ud | UserDataCaps | 2 | |
candle_spans | список CandleSpan | 3 | The longest span one Candles request may cover, per timeframe, as the broker documents it. A timeframe that is not listed has no known limit. A client that reads a long history cuts it into windows of this span. |
CandleSpan
| Поле | Тип | № | Описание |
|---|---|---|---|
timeframe | Timeframe | 1 | |
span_ns | uint64 | 2 |
HealthRequest
Без полей.
BotInfo
| Поле | Тип | № | Описание |
|---|---|---|---|
name | string | 1 | |
first_seen_ns | int64 | 2 | When the gateway first heard from the bot (since the gateway started). |
last_seen_ns | int64 | 3 | |
calls | uint64 | 4 | |
streaming | bool | 5 | The bot has an Events stream open. |
queued | uint64 | 6 | Events waiting in the buffer of its stream. |
lagged | uint64 | 7 | How many times its stream was closed because the bot read too slowly. |
HealthResponse
| Поле | Тип | № | Описание |
|---|---|---|---|
state | GatewayState | 1 | |
name | string | 2 | The name of the gateway and the broker it serves. |
broker | string | 3 | |
version | string | 4 | |
epoch | uint64 | 5 | Chosen at random each time the gateway starts. A bot keeps it with its last event number. |
last_seq | uint64 | 6 | The number of the last event. |
detail | string | 7 | While STARTING: what the gateway is doing or what went wrong the last time. |
open_orders | uint64 | 8 | Orders of the journal that are not known to have ended. |
bots | список BotInfo | 9 | |
uptime_ns | uint64 | 10 | How long this run of the gateway has been up. |
pid | uint32 | 11 | The process id of the gateway. |
events_total | uint64 | 12 | The number of events since the start, and how many per second over the last ten seconds. |
events_per_sec | double | 13 | |
limits | список LimitInfo | 14 | How much of each bucket of the rate limit of the broker is left. |
subscriptions | список SubscriptionInfo | 15 | The subscriptions at the broker and who holds them. |
LimitInfo
One bucket of the rate limit of the broker.
| Поле | Тип | № | Описание |
|---|---|---|---|
bucket | string | 1 | |
available | uint32 | 2 | |
capacity | uint32 | 3 |
SubscriptionInfo
A subscription at the broker. The same request of several bots is one subscription with several owners. Only an owner can close it. The gateway’s own subscriptions (the states of orders) have the owner “(gateway)”.
| Поле | Тип | № | Описание |
|---|---|---|---|
sub | uint64 | 1 | |
what | string | 2 | What it is: the kind and the instrument. |
owners | список string | 3 |
EventsRequest
| Поле | Тип | № | Описание |
|---|---|---|---|
epoch | uint64 | 1 | Where to continue. Without epoch the stream is live only. |
last_seq | uint64 | 2 | The number of the last event you handled. |
own_orders_only | bool | 3 | Orders and executions of the orders that this bot placed (by the journal), not those of the other bots. Everything else comes to all. |
EventFrame
A frame of the Events stream. The first is always ready.
| Поле | Тип | № | Описание |
|---|---|---|---|
ready | Ready | 1 | Одно из группы frame. |
event | Event | 2 | Одно из группы frame. |
gap | Gap | 3 | Одно из группы frame. |
lagged | Lagged | 4 | Одно из группы frame. |
Ready
| Поле | Тип | № | Описание |
|---|---|---|---|
epoch | uint64 | 1 | The epoch of this gateway run and the number the next live event will have. |
next_seq | uint64 | 2 | |
replaying | uint64 | 3 | How many events follow from the ring before the live ones begin. |
Gap
The events after last_seq are no longer there. Call Reconcile and read the account again: the stream goes on from now.
| Поле | Тип | № | Описание |
|---|---|---|---|
reason | Reason | 1 | |
requested_after | uint64 | 2 | |
first_available | uint64 | 3 | The oldest event still in the ring (the next one to come if the ring is empty). |
Lagged
The stream is closed because this bot read too slowly and its buffer filled. The events in the buffer were delivered first. Reconnect with the last number you got.
| Поле | Тип | № | Описание |
|---|---|---|---|
last_seq | uint64 | 1 |
ErrorDetail
The error of the library, whole. Sent in the brokapi-error-bin trailer of a failed call.
| Поле | Тип | № | Описание |
|---|---|---|---|
kind | Kind | 1 | |
code | string | 2 | REJECTED: the code of the refusal (“not_found”, “price_off_step”, …). |
message | string | 3 | CONFIG, TRANSPORT, REJECTED, GATEWAY_STARTING, BAD_REQUEST: the text. |
retry_after_ns | uint64 | 4 | RATE_LIMITED. |
capability | string | 5 | UNSUPPORTED. |
GatewayState
| Значение | Число | Описание |
|---|---|---|
GATEWAY_STATE_UNSPECIFIED | 0 | |
GATEWAY_STATE_STARTING | 1 | The gateway runs the reconcile of its journal. Every call but Health and Caps is answered UNAVAILABLE. |
GATEWAY_STATE_READY | 2 | |
GATEWAY_STATE_STOPPING | 3 | The gateway is stopping: no new commands. |
Gap.Reason
| Значение | Число | Описание |
|---|---|---|
REASON_UNSPECIFIED | 0 | |
REASON_TOO_OLD | 1 | The ring has already dropped them (by count or by age). |
REASON_NEW_EPOCH | 2 | The gateway was restarted: its numbers begin again. |
REASON_AHEAD | 3 | The number is ahead of the gateway. |
ErrorDetail.Kind
| Значение | Число | Описание |
|---|---|---|
KIND_UNSPECIFIED | 0 | |
KIND_CONFIG | 1 | |
KIND_TRANSPORT | 2 | |
KIND_RATE_LIMITED | 3 | |
KIND_REJECTED | 4 | |
KIND_UNKNOWN | 5 | The command may or may not have reached the broker: look at the order before anything else. |
KIND_UNSUPPORTED | 6 | |
KIND_NOT_RECONCILED | 7 | |
KIND_DEGRADED | 8 | |
KIND_SHUTTING_DOWN | 9 | |
KIND_GATEWAY_STARTING | 10 | The gateway is not ready yet. The command was not run. |
KIND_BAD_REQUEST | 11 | The request could not be read: a bad id, a bad number. The command was not run. |
Типы: значения, заявки, портфель, рынок
Файл proto/brokapi/gateway/v1/types.proto, пакет brokapi.gateway.v1.
The types of the brokapi gateway: the same values as the types of `brokapi-core`, written for the wire.
Rules that hold for every message in this file:
* A price, a quantity, an amount of money is a string with a decimal number ("101.25", "-0.5"). Never a double:
a double cannot hold 0.1 and a bot that adds prices loses kopecks. Parse it into a decimal type of your language.
* A moment of time is an integer of nanoseconds since 1970-01-01 UTC, in a field that ends with `_ns`.
* An order id (`cid`) is a UUID in the text form with hyphens, made by the bot.
* `optional` marks a value that may be absent: absent is not the same as an empty string or zero.
Timestamps
When the library saw something. broker_ts_ns is the broker’s own time when the broker gives one.
| Поле | Тип | № | Описание |
|---|---|---|---|
rx_ts_ns | int64 | 1 |
TimeRange
| Поле | Тип | № | Описание |
|---|---|---|---|
from_ns | int64 | 1 | |
to_ns | int64 | 2 |
InstrumentId
An instrument: the same in every broker (symbol and mic) and the way this broker names it (native). Take it from ResolveInstrument, Find or GetInstrument and pass it back as it is.
| Поле | Тип | № | Описание |
|---|---|---|---|
symbol | string | 1 | |
mic | string | 2 | |
native | NativeId | 3 |
NativeId
| Поле | Тип | № | Описание |
|---|---|---|---|
symbol_mic | SymbolMic | 1 | Одно из группы id. |
ticker_class | TickerClass | 2 | Одно из группы id. |
figi | string | 3 | Одно из группы id. |
uid | string | 4 | Одно из группы id. |
SymbolMic
| Поле | Тип | № | Описание |
|---|---|---|---|
symbol | string | 1 | |
mic | string | 2 |
TickerClass
| Поле | Тип | № | Описание |
|---|---|---|---|
ticker | string | 1 | |
class_code | string | 2 |
SubscribeOpts
| Поле | Тип | № | Описание |
|---|---|---|---|
backpressure | Backpressure | 1 | |
cap | uint64 | 2 | The buffer size for LOSSLESS and DROP_NEWEST. |
PlaceOrderRequest
| Поле | Тип | № | Описание |
|---|---|---|---|
cid | string | 1 | |
instrument | InstrumentId | 2 | |
side | Side | 3 | |
order_type | OrderType | 4 | |
qty | string | 5 |
ReplaceOrderRequest
| Поле | Тип | № | Описание |
|---|---|---|---|
cid | string | 1 | The id of the new order. |
original_cid | string | 2 |
OrderAck
| Поле | Тип | № | Описание |
|---|---|---|---|
cid | string | 1 | |
broker_id | string | 2 | |
ts | Timestamps | 3 |
OrderState
| Поле | Тип | № | Описание |
|---|---|---|---|
kind | OrderStateKind | 1 | |
cid | string | 2 | |
broker_id | string | 3 | ACKED. |
filled | string | 4 | PARTIALLY_FILLED. |
left | string | 5 | |
reason | string | 6 | REJECTED. |
OrderSnapshot
| Поле | Тип | № | Описание |
|---|---|---|---|
cid | string | 1 | |
instrument | InstrumentId | 3 | |
state | OrderState | 4 | |
side | Side | 5 | |
order_type | OrderType | 6 | |
qty | string | 7 | |
filled | string | 8 | |
ts | Timestamps | 10 |
ReconcileReport
| Поле | Тип | № | Описание |
|---|---|---|---|
confirmed | список OrderSnapshot | 1 | In your ledger and active at the broker. |
closed_offline | список string | 2 | In your ledger, not active at the broker: filled, cancelled or refused while you were away. Ask OrderState. |
foreign | список OrderSnapshot | 3 | Active at the broker, not in your ledger: somebody else’s orders. Do not touch them. |
StopExpiry
| Поле | Тип | № | Описание |
|---|---|---|---|
kind | Kind | 1 | |
date_ns | int64 | 2 | KIND_GOOD_TILL_DATE. |
PlaceStopOrderRequest
| Поле | Тип | № | Описание |
|---|---|---|---|
cid | string | 1 | |
instrument | InstrumentId | 2 | |
side | Side | 3 | |
kind | StopOrderKind | 4 | |
qty | string | 5 | |
stop_price | string | 6 | |
expiry | StopExpiry | 8 |
StopOrderState
| Поле | Тип | № | Описание |
|---|---|---|---|
kind | Kind | 1 | |
other | string | 2 |
StopOrderRef
A stop order by your id or by the broker’s id.
| Поле | Тип | № | Описание |
|---|---|---|---|
cid | string | 1 | Одно из группы ref. |
broker_id | string | 2 | Одно из группы ref. |
StopOrderSnapshot
| Поле | Тип | № | Описание |
|---|---|---|---|
broker_id | string | 2 | |
instrument | InstrumentId | 3 | |
side | Side | 4 | |
kind | StopOrderKind | 5 | |
qty | string | 6 | |
stop_price | string | 7 | |
expiry | StopExpiry | 9 | |
state | StopOrderState | 10 | |
ts | Timestamps | 11 |
BookLevel
| Поле | Тип | № | Описание |
|---|---|---|---|
price | string | 1 | |
qty | string | 2 |
OrderBook
A full book (incremental false) or a change of it (incremental true, then seq is the number of the change).
| Поле | Тип | № | Описание |
|---|---|---|---|
instrument | InstrumentId | 1 | |
incremental | bool | 2 | |
seq | uint64 | 3 | |
bids | список BookLevel | 4 | |
asks | список BookLevel | 5 | |
ts | Timestamps | 6 |
MarketTrade
| Поле | Тип | № | Описание |
|---|---|---|---|
instrument | InstrumentId | 1 | |
price | string | 2 | |
qty | string | 3 | |
side | Side | 4 | |
ts | Timestamps | 6 |
Quote
| Поле | Тип | № | Описание |
|---|---|---|---|
instrument | InstrumentId | 1 | |
ts | Timestamps | 4 |
Candle
| Поле | Тип | № | Описание |
|---|---|---|---|
instrument | InstrumentId | 1 | |
timeframe | Timeframe | 2 | |
open | string | 3 | |
high | string | 4 | |
low | string | 5 | |
close | string | 6 | |
volume | string | 7 | |
open_ts_ns | int64 | 8 | |
close_ts_ns | int64 | 9 | |
ts | Timestamps | 10 |
Balance
| Поле | Тип | № | Описание |
|---|---|---|---|
currency | string | 1 | |
available | string | 2 | |
blocked | string | 3 | |
ts | Timestamps | 4 |
Position
| Поле | Тип | № | Описание |
|---|---|---|---|
instrument | InstrumentId | 1 | |
qty | string | 2 | |
ts | Timestamps | 5 |
Portfolio
| Поле | Тип | № | Описание |
|---|---|---|---|
balances | список Balance | 1 | |
positions | список Position | 2 | |
ts | Timestamps | 3 |
Execution
| Поле | Тип | № | Описание |
|---|---|---|---|
broker_order_id | string | 2 | |
instrument | InstrumentId | 3 | |
side | Side | 4 | |
price | string | 5 | |
qty | string | 6 | |
commission | string | 7 | |
trade_id | string | 8 | |
ts | Timestamps | 9 |
Instrument
| Поле | Тип | № | Описание |
|---|---|---|---|
id | InstrumentId | 1 | |
name | string | 2 | |
kind | InstrumentKind | 4 | |
currency | string | 5 | |
exchange | string | 6 |
InstrumentQuery
| Поле | Тип | № | Описание |
|---|---|---|---|
by_ticker | ByTicker | 1 | Одно из группы query. |
by_isin | string | 2 | Одно из группы query. |
by_kind | InstrumentKind | 3 | Одно из группы query. |
InstrumentQuery.ByTicker
| Поле | Тип | № | Описание |
|---|---|---|---|
ticker | string | 1 |
TradingParams
| Поле | Тип | № | Описание |
|---|---|---|---|
instrument | InstrumentId | 1 | |
lot_size | string | 2 | |
min_qty | string | 3 | |
price_step | string | 4 | |
price_step_value | string | 5 | |
currency | string | 6 |
TradingStatus
| Поле | Тип | № | Описание |
|---|---|---|---|
instrument | InstrumentId | 1 | |
availability | TradingAvailability | 2 |
SessionInterval
| Поле | Тип | № | Описание |
|---|---|---|---|
start_ns | int64 | 1 | |
end_ns | int64 | 2 | |
kind | SessionKind | 3 |
Schedule
| Поле | Тип | № | Описание |
|---|---|---|---|
exchange | string | 1 | |
sessions | список SessionInterval | 2 | |
ts | Timestamps | 3 |
StreamingCaps
| Поле | Тип | № | Описание |
|---|---|---|---|
order_book | bool | 1 | |
market_trades | bool | 2 | |
quotes | bool | 3 | |
candles | bool | 4 |
MarketDataCaps
| Поле | Тип | № | Описание |
|---|---|---|---|
max_order_book_depth | uint32 | 1 | |
incremental_order_book | bool | 2 | |
streaming | StreamingCaps | 3 |
PlaceIdempotency
| Поле | Тип | № | Описание |
|---|---|---|---|
kind | Kind | 1 | |
window_secs | uint64 | 2 | How long the broker remembers an id (the two last kinds). |
UserDataCaps
| Поле | Тип | № | Описание |
|---|---|---|---|
place_idempotency | PlaceIdempotency | 1 | |
ws_rpc | bool | 2 | |
ordered_command_channel | bool | 3 | |
supports_replace | bool | 4 | |
supports_stop_orders | bool | 5 | |
streams_executions | bool | 6 | |
sandbox | bool | 7 |
PositionList
| Поле | Тип | № | Описание |
|---|---|---|---|
positions | список Position | 1 |
BalanceList
| Поле | Тип | № | Описание |
|---|---|---|---|
balances | список Balance | 1 |
Resync
| Поле | Тип | № | Описание |
|---|---|---|---|
sub | uint64 | 1 | |
instrument | InstrumentId | 2 | |
reason | ResyncReason | 3 |
Overflow
| Поле | Тип | № | Описание |
|---|---|---|---|
sub | uint64 | 1 | |
lost_from_ns | int64 | 2 |
Reconnected
| Поле | Тип | № | Описание |
|---|---|---|---|
sub | uint64 | 1 | |
gap_from_ns | int64 | 2 |
Event
One event of the broker connection. seq counts the events of this gateway from 1, without holes, in the order the gateway got them: keep the last one you handled and give it to Events when you reconnect.
| Поле | Тип | № | Описание |
|---|---|---|---|
seq | uint64 | 1 | |
source | string | 2 | The connector that produced it: “NAME-md” or “NAME-ud”, NAME being the name of the gateway. |
ts | Timestamps | 4 | |
order_book | OrderBook | 10 | Одно из группы payload. |
market_trade | MarketTrade | 11 | Одно из группы payload. |
quote | Quote | 12 | Одно из группы payload. |
candle | Candle | 13 | Одно из группы payload. |
order | OrderSnapshot | 14 | Одно из группы payload. Your order changed state. |
execution | Execution | 15 | Одно из группы payload. |
portfolio | Portfolio | 16 | Одно из группы payload. |
positions | PositionList | 17 | Одно из группы payload. |
balances | BalanceList | 18 | Одно из группы payload. |
resync | Resync | 19 | Одно из группы payload. A stream of order book changes lost its place: ask for a snapshot. |
overflow | Overflow | 20 | Одно из группы payload. A lossless subscription of the connector overflowed. Trading stops until you call Reconcile. |
reconnected | Reconnected | 21 | Одно из группы payload. A stream was cut and came back: whatever happened from gap_from_ns may be missing. |
Side
| Значение | Число | Описание |
|---|---|---|
SIDE_UNSPECIFIED | 0 | |
SIDE_BUY | 1 | |
SIDE_SELL | 2 |
OrderType
| Значение | Число | Описание |
|---|---|---|
ORDER_TYPE_UNSPECIFIED | 0 | |
ORDER_TYPE_MARKET | 1 | |
ORDER_TYPE_LIMIT | 2 |
Timeframe
| Значение | Число | Описание |
|---|---|---|
TIMEFRAME_UNSPECIFIED | 0 | |
TIMEFRAME_M1 | 1 | |
TIMEFRAME_M5 | 2 | |
TIMEFRAME_M10 | 3 | |
TIMEFRAME_M15 | 4 | |
TIMEFRAME_M30 | 5 | |
TIMEFRAME_H1 | 6 | |
TIMEFRAME_H4 | 7 | |
TIMEFRAME_D1 | 8 | |
TIMEFRAME_W1 | 9 | |
TIMEFRAME_MN | 10 |
InstrumentKind
| Значение | Число | Описание |
|---|---|---|
INSTRUMENT_KIND_UNSPECIFIED | 0 | |
INSTRUMENT_KIND_STOCK | 1 | |
INSTRUMENT_KIND_BOND | 2 | |
INSTRUMENT_KIND_OPTION | 3 | |
INSTRUMENT_KIND_FUTURE | 4 | |
INSTRUMENT_KIND_ETF | 5 | |
INSTRUMENT_KIND_MUTUAL_FUND | 6 | |
INSTRUMENT_KIND_CURRENCY | 7 | |
INSTRUMENT_KIND_OTHER | 8 | |
INSTRUMENT_KIND_PERPETUAL | 9 |
TradingAvailability
| Значение | Число | Описание |
|---|---|---|
TRADING_AVAILABILITY_UNSPECIFIED | 0 | |
TRADING_AVAILABILITY_OPEN | 1 | |
TRADING_AVAILABILITY_CLOSED | 2 | |
TRADING_AVAILABILITY_BREAK | 3 | |
TRADING_AVAILABILITY_UNKNOWN | 4 |
SessionKind
| Значение | Число | Описание |
|---|---|---|
SESSION_KIND_UNSPECIFIED | 0 | |
SESSION_KIND_PRE_MARKET | 1 | |
SESSION_KIND_MAIN | 2 | |
SESSION_KIND_POST_MARKET | 3 | |
SESSION_KIND_AUCTION | 4 | |
SESSION_KIND_OTHER | 5 |
ResyncReason
| Значение | Число | Описание |
|---|---|---|
RESYNC_REASON_UNSPECIFIED | 0 | |
RESYNC_REASON_SEQUENCE_GAP | 1 | |
RESYNC_REASON_SUBSCRIPTION_RESET | 2 | |
RESYNC_REASON_MANUAL | 3 |
StopOrderKind
| Значение | Число | Описание |
|---|---|---|
STOP_ORDER_KIND_UNSPECIFIED | 0 | |
STOP_ORDER_KIND_STOP_LOSS | 1 | |
STOP_ORDER_KIND_TAKE_PROFIT | 2 | |
STOP_ORDER_KIND_STOP_LIMIT | 3 |
Backpressure
| Значение | Число | Описание |
|---|---|---|
BACKPRESSURE_UNSPECIFIED | 0 | |
BACKPRESSURE_CONFLATE | 1 | The latest value replaces older ones that nobody has read yet. For prices. |
BACKPRESSURE_LOSSLESS | 2 | Every value is kept up to cap. When the buffer is full the stream is marked lost and trading stops until Reconcile. |
BACKPRESSURE_DROP_NEWEST | 3 | At cap the newest value is dropped. |
OrderStateKind
| Значение | Число | Описание |
|---|---|---|
ORDER_STATE_KIND_UNSPECIFIED | 0 | |
ORDER_STATE_KIND_IN_FLIGHT | 1 | Sent, no answer yet. |
ORDER_STATE_KIND_UNKNOWN | 2 | No answer and the outcome is not known: ask with OrderState, do not send again. |
ORDER_STATE_KIND_ACKED | 3 | Accepted by the broker (broker_id). |
ORDER_STATE_KIND_PARTIALLY_FILLED | 4 | Partly executed (filled, left). |
ORDER_STATE_KIND_FILLED | 5 | |
ORDER_STATE_KIND_CANCELLED | 6 | |
ORDER_STATE_KIND_REJECTED | 7 | Refused (reason). |
StopExpiry.Kind
| Значение | Число | Описание |
|---|---|---|
KIND_UNSPECIFIED | 0 | |
KIND_GOOD_TILL_CANCEL | 1 | |
KIND_END_OF_DAY | 2 | |
KIND_GOOD_TILL_DATE | 3 |
StopOrderState.Kind
| Значение | Число | Описание |
|---|---|---|
KIND_UNSPECIFIED | 0 | |
KIND_ACTIVE | 1 | |
KIND_TRIGGERED | 2 | |
KIND_CANCELLED | 3 | |
KIND_EXPIRED | 4 | |
KIND_OTHER | 5 | A state this version does not know: the broker’s word is in other. |
PlaceIdempotency.Kind
| Значение | Число | Описание |
|---|---|---|
KIND_UNSPECIFIED | 0 | |
KIND_LABEL_ONLY | 1 | The broker only keeps the label: a second place with the same id may open a second order. |
KIND_REJECT_DUPLICATE | 2 | |
KIND_IDEMPOTENT | 3 |
Service: служба brokapi up и ее команды
Файл proto/brokapi/service/v1/service.proto, пакет brokapi.service.v1.
The API of the brokapi service: the commands of the program `brokapi` talk to it. The service runs the gateways of one
config file, one process each, and watches them.
It is for the person who runs the machine, not for bots: bots talk to a gateway (brokapi.gateway.v1). The service
listens on a Unix socket (or on 127.0.0.1 on Windows) in its state directory, and a call carries the key from
`service.key` of that directory in `authorization: Bearer <key>`, as a call to a gateway does.
Сервис Service
| Вызов | Запрос | Ответ | Описание |
|---|---|---|---|
Info | InfoRequest | ServiceInfo | What the service is. |
List | ListRequest | ListResponse | The gateways, with what the service knows of each (the state of the process) and what the gateway says of itself. Without names: all of them. |
Start | GatewayRef | GatewayInfo | Starts a gateway that is stopped or failed. A gateway that runs is left alone. |
Stop | StopRequest | StopResponse | Stops a gateway and keeps it stopped until Start. With cancel_orders the active orders and stop orders of the account are cancelled first. Orders at the exchange are otherwise not touched. |
Restart | GatewayRef | GatewayInfo | Stops a gateway and starts it again. |
Add | GatewayRef | GatewayInfo | Reads the config file again and starts the gateway of this name from it, if it is not running. |
Remove | GatewayRef | GatewayInfo | Stops a gateway and forgets it until Add or Reload brings it back from the config. |
Reload | ReloadRequest | ReloadResponse | Reads the config file again and brings the running gateways in line with it: starts the new ones, stops the ones that are gone from the file, restarts the ones whose section changed. |
Shutdown | ShutdownRequest | ShutdownResponse | Stops the service. The gateways are stopped too, unless leave_gateways is set. |
InfoRequest
Без полей.
ServiceInfo
| Поле | Тип | № | Описание |
|---|---|---|---|
pid | uint32 | 1 | |
version | string | 2 | |
config | string | 3 | The config file the service was started with, and its state directory. |
state_dir | string | 4 | |
address | string | 5 | Where the service listens. |
started_ns | uint64 | 6 | |
uptime_ns | uint64 | 7 | |
gateways | uint32 | 8 | |
running | uint32 | 9 |
ListRequest
| Поле | Тип | № | Описание |
|---|---|---|---|
names | список string | 1 |
ListResponse
| Поле | Тип | № | Описание |
|---|---|---|---|
gateways | список GatewayInfo | 1 |
GatewayRef
| Поле | Тип | № | Описание |
|---|---|---|---|
name | string | 1 |
GatewayInfo
A gateway as the service sees it.
| Поле | Тип | № | Описание |
|---|---|---|---|
name | string | 1 | |
broker | string | 2 | |
state | string | 3 | starting, running, restarting, stopping, stopped or failed. |
pid | uint32 | 4 | The process id, 0 if there is no process. |
uptime_ns | uint64 | 5 | How long the process has run. |
restarts | uint32 | 6 | How many times the service started it again after it stopped on its own. |
last_error | string | 7 | The last time it stopped on its own or could not start, and why. Empty if it never did. |
restart_in_ns | uint64 | 8 | While restarting: how long until the next start. |
cpu_percent | float | 9 | The CPU use of the process in percent of one core, and its memory in bytes. |
memory_bytes | uint64 | 10 | |
account | string | 11 | The account and whether it is a sandbox, from the config. |
sandbox | bool | 12 | |
adopted | bool | 13 | The service found this process running when it started, instead of starting it. |
address | string | 14 | Where the gateway listens, and its directory. |
state_dir | string | 15 |
StopRequest
| Поле | Тип | № | Описание |
|---|---|---|---|
name | string | 1 | |
cancel_orders | bool | 2 |
StopResponse
| Поле | Тип | № | Описание |
|---|---|---|---|
gateway | GatewayInfo | 1 | |
cancelled_orders | uint32 | 2 | How many orders and stop orders were cancelled before the stop. |
cancelled_stops | uint32 | 3 | |
errors | список string | 4 | Orders that could not be cancelled, with the reason. |
ReloadRequest
Без полей.
ReloadResponse
| Поле | Тип | № | Описание |
|---|---|---|---|
started | список string | 1 | |
stopped | список string | 2 | |
restarted | список string | 3 | |
unchanged | список string | 4 |
ShutdownRequest
| Поле | Тип | № | Описание |
|---|---|---|---|
leave_gateways | bool | 1 |
ShutdownResponse
Без полей.
Изменения
Что появилось в библиотеке и что поменять в своем коде. Новое сверху.
0.1.0-alpha.5: Bybit
- Bybit: пятый коннектор. Крейты
brokapi-bybitиbrokapi-bybit-protocol, флагbybitв главном крейте,brokapi.connect("bybit")иmock_bybit()в Python,broker = "bybit"в конфиге гейтвея. Поддержана категорияlinearAPI V5 (контракты с расчетом в USDT), спота пока нет. Общегоreplaceнет (изменение заявки у биржи оставляет заявку и ееorderLinkId), стоп-заявки только «до отмены», свечи без 10 минут. Демо-счет с виртуальными деньгами (env="demo", у него своя пара ключейBYBIT_DEMO_API_KEYиBYBIT_DEMO_API_SECRET). Страница: Bybit, в ней же что проверено вживую и что нет. - В ядре новый вид инструмента
InstrumentKind::Perpetual(бессрочный контракт, без срока и с ставкой финансирования). Если вы разбираетеInstrumentKindисчерпывающимmatch, добавьте ветку. Справочник gRPC получил значениеINSTRUMENT_KIND_PERPETUAL = 9, старые клиенты видят его как неизвестное. БКС, Т-Банк и АЛОР на поиск по этому виду отвечаютUnsupported. - Книга: быстрый старт на Python начинается с запуска на имитации без токена, добавлены правило 0 (боевой токен выдает человек, чей это счет), разделы «Где взять токен и счет» на страницах брокеров, первый день на Финаме, страницы Аварийная остановка бота и Если вы пришли с tinkoff-investments.
Client.scheduleиClient.candlesв Python описывают, что передавать вexchangeу каждого брокера и что даты должны быть с часовым поясом.
0.1.0-alpha.4: Gateway, программа brokapi
- Gateway: программа
brokapi. Одна программа из двух частей. Служба (brokapi up) держит связь со счетами БКС, Т-Банка, Финама и АЛОР за все ваши боты и отдает им gRPC, поэтому боты на любом языке делят один счетчик лимитов и не переподключаются к брокеру при каждом своем перезапуске. Команды управляют службой (ps,top,start,stop,reload), читают счет (portfolio,book,quoteс ключом--json) и снимают заявки (cancel-all). Несколько служб с разными конфигами работают на одной машине. Для ботов на Rust есть клиентRemoteClient, для остальных языков - файлы.protoи справочник. Страницы: Gateway, быстрый старт, Подключить бота, команды. - Программа ставится готовым файлом для Linux, macOS и Windows со страницы выпусков или командой
cargo install brokapi-gateway --version 0.1.0-alpha.4 --locked(Установка). Клиента для ботов на Go нет, подключение по.protoописано в Подключить бота. - Python: подключение к гейтвею тем же
Client.await brokapi.connect_gateway("bcs-main", bot="my-bot")возвращает тот жеbrokapi.Client, что иbrokapi.connect: бот переезжает с брокера на гейтвей заменой одной строки, а все его вызовы иasync forпоclient.eventsостаются. ПодClientтот же код на Rust, что уRemoteClient, поэтому события, пропуски и сверка работают так же. Потеря событий приходит событиемbrokapi.Gap, торговля после нее закрыта (Degraded) доreconcile, как послеOverflow. Различия с прямым подключением: нетclient.limiter(счетчик общий на гейтвей),close()закрывает только соединение бота. Вbrokapi.testingпоявилисьmock.start_gateway()иgateway.restart(). Страница: Подключить бота. - В гейтвее
Capsтеперь сообщает предел одного запроса истории свечей по таймфреймам (полеcandle_spansв.proto), клиент режет по нему длинную историю на окна. Старые клиенты поле не читают, ничего не ломается. - Rust: у
RemoteClientпоявилисьfeed()(поток событий, который сам переоткрывается и сообщает о потере какFeedItem::Gap),close(),wait_ready()и необязательная защита команд (ConnectOptions::trading_gate:NotReconciledиDegradedдо сверки, как у коннектора, по умолчанию выключена). Крейтbrokapi-clientполучил трейтAccountиcall_by_nameдля любого клиента счета. - В книге больше нет отдельного раздела «CLI»: командная строка - часть Gateway. Старый адрес
cli/index.htmlведет на Команды. - Gateway на macOS: длинный путь до каталога состояния. Гейтвей не стартовал, если путь до его сокета был длиннее 103 байт (
path must be shorter than SUN_LEN), а в~/Library/Application Support/...или при длинном имени гейтвея это обычное дело. Теперь такой сокет лежит в личном коротком каталоге (права 700), а адрес берется изgateway.addrиservice.addr. Тот же отказ на Linux был при пути длиннее 107 байт. Подробности: Какие файлы где лежат. - Gateway на Windows: справка называет программу
brokapi, а неbrokapi.exe, как на остальных системах. - Gateway на Windows: бот переподключался не к тому порту. Гейтвей на Windows слушает свободный порт, и после перезапуска порт другой. Бот, подключенный по каталогу состояния (
connect_gatewayв Python,RemoteClient::connect_dirиfeed()в Rust), шел по старому адресу и не получал ни событий, ни пропуска (Gap). Теперь адрес читается изgateway.addrпри каждом переподключении, а ключ изaccess.keyперечитывается, если гейтвей его отверг. На Linux и macOS адрес сокета не менялся, там это не проявлялось. brokapi statusиtop: память-вместо0 B. Пока служба не прочитала память нового процесса, команды печатали0 B. Теперь там-.- Т-Банк: стоп-заявка с потерянным ответом возвращается в списке с вашим
cid. Раньше послеUnknownуplace_stopстоп у брокера стоял, аactive_stop_ordersпоказывал его сcid: None, и код вида «нет стопа с моимcid, значит ставлю заново» ставил второй стоп. Теперь коннектор связывает стоп сcid, когда подходит ровно один (подробности: Т-Банк). Если подходящих стопов два,cidпо-прежнему пуст. - Что поменять у себя: после
Unknownсмотрите не только стоп с вашимcid, но и стопы безcidна той же бумаге и в ту же сторону: ваш мог оказаться среди них, второй не ставьте. Рецепт для всех брокеров: Стоп-заявки, Python.protect_onceв примере теперь возвращаетPlacement, а не стоп илиNone. - Python:
brokapi.testingполучилmock.lose_stop_answers(): брокер принимает стоп-заявки, а ответ теряется. Для проверки своего кода на этом сбое.
0.1.0-alpha.3: subscribe_* возвращается, когда поток открыт
- У БКС, Т-Банка и АЛОРа
subscribe_*возвращается, когда поток открыт: заявку можно ставить сразу после подписки на статусы и исполнения. Если заsubscribe_ack_timeout(3 секунды, у Т-Банкаrequest_timeout) поток не открылся, придетTransport, подписку можно повторить. Что считается открытым потоком, по брокерам: Круг событий. - У Финама по таймауту
subscribe_*по-прежнему вернетOk, поток дооткроется в фоне (sub_status). - БКС: рыночная подписка, которую брокер отклонил, теперь возвращает
Rejected { subscription_refused }, как у остальных. - Что поменять у себя: ничего, если вы подписывались до заявки. В
UserDataConfigиMarketDataConfigБКС появилось полеsubscribe_ack_timeout. Если вы полагались на фоновое открытие при медленном АЛОРе, теперь придетTransport: повторите вызов.
0.1.0-alpha.2: АЛОР Брокер
- Новый брокер АЛОР: флаг
alorу крейтаbrokapi,brokapi.connect("alor")в Python. Заявки и замена лимитной, стоп-заявки и стоп-лимиты, стакан до 50 уровней, котировки, лента, свечи, свои сделки, портфель, справочник. Страница: АЛОР. - Нет расписания торгов, справочника по ISIN и потока стоп-заявок. Рыночную заявку заменить нельзя.
- Все остальные методы брокера доступны через
raw(). Таблица: Покрытие API. - Исправлено:
brokapi.testingиз PyPI падал на машине пользователя.
0.1.0-alpha.1: открытая альфа
- Код открыт: https://github.com/runinfra/brokapi, лицензия
MIT OR Apache-2.0. - Пакеты опубликованы:
cargo add brokapi --features bcsиpip install brokapi. Для Python готовые пакеты под Linux x86_64, macOS и Windows, на остальных системах расширение собирается при установке. - Коннекторы БКС, Т-Банка и Финама: рыночные данные, заявки, стоп-заявки, сверка со счетом, лимиты запросов, любой метод брокера через
raw(). В Python те же возможности, плюс запись событий в файл и проигрыш (brokapi.record). - Как сообщить об уязвимости: SECURITY.md.