# brokapi: вся книга одним файлом
Как пользоваться этим набором. Книга написана по задачам и состоит из общей части (как устроено, брокеры, ошибки, события, контракт для агентов) и разделов продуктов: Rust, Python, Gateway (программа brokapi: служба и команды). Писать код можно на Rust или на Python, оба работают сейчас (альфа); Gateway - программа brokapi, раздел gateway/. Если пользователь пишет на Python, берите отдельный индекс для Python: https://brokapi.ru/docs/python/llms.txt, в нем только то, что нужно для Python, и справочник пакета одним файлом. Если боты на нескольких языках или несколько ботов на одном счете, берите Gateway: он запускается командами brokapi (gateway/quickstart.md), бот подключается к нему по gRPC (gateway/bots.md): для Rust есть клиент RemoteClient, для Python бот берет тот же brokapi.Client через brokapi.connect_gateway (gateway/bots.md), для Go готового клиента пока нет, контракт - файлы .proto (api-gateway.md). Если код пойдет на боевой счет, сначала before-live.md и agents/unverified.md (допущения по документации брокеров, как их обойти). Если нужно написать код: прочитайте "Контракт для агентов" (все обязательные правила на одной странице, они одни для любого языка), затем инструкцию из раздела нужного языка, ближайшую к задаче (торговый бот, несколько брокеров и счетов, история или исследовательский движок), и копируйте код оттуда: каждый пример - рабочий тест против имитаций. Общие страницы (круги механики, заявки, сверка) показывают код вкладками Rust и Python. Точные объявления типов и сигнатуры методов - в api-reference.md (Rust, из rustdoc) и python/api-reference.md (Python, из docstring), а также в самих справочниках. Весь текст книги одним файлом: llms-full.txt. У каждой страницы есть markdown-исходник рядом с HTML (замените .html на .md). Все адреса ниже - на https://brokapi.ru/docs/, справочник API Rust - на https://brokapi.ru/docs/api/, справочник Python - на https://brokapi.ru/docs/api/python/.
Каждая страница начинается со строки ``.
---
# Что это
**brokapi** - единый интерфейс к торговым API. Один и тот же код бота работает с любым брокером. Начинаем с API
брокеров российского рынка: **БКС**, **Т-Банк**, **Финам** и **АЛОР**, есть также технический коннектор к API Bybit ([страница](brokers/bybit.md)). Дальше другие брокеры и биржевые протоколы под тот же
интерфейс.
Для ИИ-агентов: [llms.txt](llms.txt) / [llms-full.txt](llms-full.txt). Для тех, что пишут на Python: [python/llms.txt](python/llms.txt)
**Стадия: открытая альфа.** Интерфейс до беты может меняться. Перед первой заявкой на настоящем счете: [Перед боевым счетом](before-live.md).
## Выберите путь
В книге одна общая часть (как устроено, брокеры, ошибки, события) и у каждого продукта свой раздел. Общее написано
один раз и верно для любого пути. Работают Rust, Python и Gateway (все в альфе).
- [**Rust**](rust/index.md) `альфа`
Ядро: рыночные данные, заявки, стоп-заявки, сверка, портфель, любой метод брокера через `raw()`. **Работает сейчас.**
Быстрый старт и инструкции.
- [**Python**](python/index.md) `альфа`
Тонкая обертка над ядром: события через `async for`, деньги как `Decimal`, те же правила и ошибки, что у ядра. **Работает сейчас.**
Быстрый старт и инструкции. `pip install brokapi`.
- [**Gateway**](gateway/index.md) `альфа`
Программа `brokapi`: служба держит связь со счетами за все ваши боты, боты на любом языке подключаются к ней по gRPC, один счетчик лимитов на всех.
Команды той же программы запускают службу, показывают гейтвеи (`brokapi ps`, `brokapi top`) и читают счет (`brokapi quote`, вывод `--json` для скриптов и ИИ-агентов).
Бесплатно, ставится у себя: готовый файл для Linux, macOS и Windows или `cargo install`. **Работает сейчас.**
## Что общее у всех путей
- **Переводчик.** Бот говорит на одном языке (заявка, стакан, событие) со всеми брокерами. Разница протоколов
(REST, WebSocket, gRPC) спрятана внутри. Сменить брокера - поменять коннектор, код бота почти не меняется.
- **Предохранитель.** Главная задача библиотеки - чтобы бот никогда не поставил заявку дважды и не торговал вслепую.
Поэтому команда без ответа не повторяется, а торговля закрывается, когда данные о своих заявках могли потеряться.
- **Без памяти.** Между запусками библиотека ничего не хранит. Журнал заявок ведет бот, библиотека дает номер `cid` в
каждом событии заявки и сверку со счетом.
Книга устроена так: сначала общая часть (как устроено, брокеры), затем разделы продуктов со своим быстрым стартом и
инструкциями, в конце справочник. Код на общих страницах дан вкладками Rust и Python там, где он есть.
## Что умеет
| Возможность | Что внутри |
|---|---|
| Рыночные данные | стакан (снимок и поток), сделки рынка, котировки, свечи, справочник инструментов, лот и шаг цены, статус торгов, расписание |
| Заявки | постановка, отмена, изменение (где оно есть у брокера), статус по номеру бота, список активных, поток статусов |
| Свои сделки | история и поток исполнений |
| Портфель | снимок и поток: деньги и позиции |
| Стоп-заявки | стоп-лосс, тейк-профит, стоп-лимит одним набором методов |
| Сверка после рестарта | бот приносит список своих заявок, библиотека сравнивает его с брокером |
| Лимиты запросов | общий счетчик на область, где брокер считает лимит |
| Любой другой метод брокера | через `raw()`, с теми же токеном, адресом и счетчиком лимитов |
## Из чего состоит
У каждого брокера два коннектора: **MD** (рыночные данные, один на брокера) и **UD** (заявки, свои сделки, портфель,
один на счет). Как они связаны с ботом, с лимитами и между собой, показывает [Карта](architecture.md).
Общие трейты (`OrderBookSource`, `OrderExecutor`, `StopOrderExecutor` и другие) закрывают то, что умеют все
брокеры. Бот, написанный на них, работает с любым брокером. Все остальное, что есть у брокера (купоны, отчеты,
избранное, новые методы), доступно через метод `raw()` каждого коннектора: [raw](rust/raw.md). Сколько методов брокера
закрыто общим интерфейсом, а сколько только `raw()`: [Покрытие API](coverage.md).
## Три правила, на которых все держится
1. **`cid` придумывает бот** и записывает у себя до отправки. Это единственная нить, по которой можно спросить
брокера о заявке без ответа. Если номер выдает библиотека, он теряется вместе с ответом, и после падения бот не
знает, что за заявка могла встать. Подробно: [Круг команд](orders.md).
2. **Команда без ответа не повторяется.** `Unknown` выясняется у брокера, новая попытка идет с новым `cid`. Заявка без
ответа могла встать, и повтор дал бы две заявки на счете. Что делать вместо повтора: [Если что-то пошло не так](rust/recovery.md).
3. **Торговля только после сверки.** После старта, после перегрузки, при сомнении. Пока бот не знает, какие заявки уже
стоят у брокера, новая команда может задвоить заявку или спорить с той, которую бот не видит. Подробно:
[Круг жизни](reconcile.md).
## Чего библиотека не делает
- **Не придумывает номер заявки.** `cid` создает бот, по нему он узнает свою заявку после рестарта.
- **Не повторяет команды.** Заявка, на которую не пришел ответ, не отправляется второй раз. Ни при каких условиях.
- **Не хранит состояние на диске.** Журнал своих заявок ведет бот.
- **Не собирает общий портфель по счетам и не выбирает счет для заявки.** Это слой над коннекторами.
- **Не трогает чужие заявки.** Заявки, поставленные руками или другим роботом, она только показывает.
- **Не снимает заявки при остановке.** Заявка на бирже переживает процесс бота.
## С чего начать
- Хочу запустить: сначала [Установка](install.md), потом [Быстрый старт на Rust](rust/quickstart.md) или [на Python](python/quickstart.md).
- Пишу бота (сам или с ИИ-агентом): сначала [Контракт для агентов](agent-contract.md), затем инструкция по задаче: торговый бот ([Rust](rust/howto-trading-bot.md), [Python](python/howto-trading-bot.md)), несколько брокеров и счетов ([Rust](rust/howto-infrastructure.md), [Python](python/howto-infrastructure.md)), [исследовательский движок](rust/howto-research.md), [история свечей на Python](python/howto-history.md).
- Несколько ботов на одном счете или бот не на Rust и Python: [Gateway](gateway/index.md), затем [быстрый старт](gateway/quickstart.md).
- Хочу понять устройство: [Карта](architecture.md), затем три круга: [события](streams.md), [команды](orders.md),
[жизнь коннектора](reconcile.md).
- Что делать при старте, рестарте и остановке бота: [Запуск, рестарт и остановка](rust/lifecycle.md).
- Пришла ошибка или странное состояние: [Ошибки и что с ними делать](errors.md).
- Особенности конкретного брокера: [Брокеры](brokers/README.md).
- Хочу поставить заявку на настоящем счете: сначала [Перед боевым счетом](before-live.md).
- Нужен метод, тип или ошибка до последнего поля: [Справочник API Rust](api.md) (rustdoc, лежит рядом с книгой в `api/`) или [Python](api-python.md).
- Что появилось нового: [Изменения](changelog.md).
---
# Установка
Как получить библиотеку и убедиться, что она встала. Это первый шаг обоих быстрых стартов: [Rust](rust/quickstart.md),
[Python](python/quickstart.md).
> Открытая альфа: интерфейс до беты может меняться. Перед первой заявкой на счете прочитайте [Перед боевым счетом](before-live.md).
> Код открыт: `https://github.com/runinfra/brokapi`. Ниже в каждом разделе команда для пакета и для самой свежей версии из репозитория.
## Что понадобится
| | Rust | Python |
|---|---|---|
| Язык | Rust 1.88 или новее (редакция 2024) | Python 3.10 или новее. Пакет один на все версии (`abi3`) |
| Инструменты | `cargo` (идет вместе с Rust, ставится через [rustup](https://rustup.rs)) | `pip`, лучше виртуальное окружение |
| Для установки из репозитория | `git` | `git` и Rust 1.88 или новее: расширение собирается на месте, сборщик `maturin` подтянется сам. Из PyPI Rust не нужен на Linux (x86_64, aarch64), macOS и Windows: там готовые пакеты |
Брокеры включаются флагами, как у `tokio`: `bcs`, `tbank`, `finam`, `alor`, `bybit`, можно несколько. В Python все брокеры уже в пакете.
## Rust
Флаг выбирает брокера, здесь БКС:
```bash
cargo add brokapi --features bcs
```
Самая свежая версия, из репозитория:
```bash
cargo add --git https://github.com/runinfra/brokapi brokapi --features bcs
```
Обе команды добавляют зависимость в `Cargo.toml`. Для программ из быстрого старта нужны еще `tokio` и `dotenvy`:
```bash
cargo add tokio --features full
cargo add dotenvy
```
Из клона репозитория (без доступа в сеть к GitHub во время сборки, например на закрытой машине):
```toml
[dependencies]
brokapi = { path = "../brokapi", features = ["bcs"] }
```
Проверка: `cargo build` собирает проект без ошибок (первая сборка занимает несколько минут: компилируются зависимости).
В коде все лежит под `brokapi::`: общее в `brokapi::core` и `brokapi::runtime`, брокеры в `brokapi::bcs`,
`brokapi::tbank`, `brokapi::finam`, `brokapi::alor`, `brokapi::bybit`. Дальше: [быстрый старт на Rust](rust/quickstart.md).
## Python
Готовые пакеты есть для Linux (x86_64 и aarch64), macOS (Intel и Apple silicon) и Windows, на остальных системах `pip` соберет расширение сам (нужен Rust):
```bash
python -m venv .venv && . .venv/bin/activate
pip install brokapi
```
Самая свежая версия, из репозитория (нужен Rust: расширение собирается при установке, это несколько минут):
```bash
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-gateway) ниже. Имя `brokapi` одно у библиотеки на Rust, пакета для Python и программы.
Проверка:
```bash
python -c "import brokapi; print(brokapi.__version__)"
```
Должен напечататься номер версии. Не напечатала: смотрите таблицу ниже. Дальше: [быстрый старт на Python](python/quickstart.md).
## Если что-то пошло не так
| Что видно | Что значит | Что делать |
|---|---|---|
| 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](https://rustup.rs) и повторить |
| `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`: служба и команды в одном бинарнике ([что это](gateway/index.md)). Ставится готовым файлом (Rust не нужен)
или через `cargo install`.
### Готовый файл
Файлы лежат на [странице выпусков](https://github.com/runinfra/brokapi/releases), в выпуске `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 (для другой системы замените имя архива):
```bash
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:
```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` ищет только стабильные выпуски
и ответит, что крейта нет.
```bash
cargo install brokapi-gateway --version 0.1.0-alpha.5 --locked
```
Сборка занимает несколько минут. Бинарник `brokapi` попадает в `~/.cargo/bin`.
Самая свежая версия, из клона репозитория:
```bash
git clone https://github.com/runinfra/brokapi
cd brokapi
cargo install --path crates/gateway --locked
```
### Проверка
```bash
brokapi --version
```
Должно напечататься `brokapi 0.1.0-alpha.5` (номер как у библиотеки). Не напечатало: убедитесь, что каталог с файлом есть в `PATH` (для `cargo install`
это `~/.cargo/bin`), а при ошибке сборки смотрите таблицу выше (`rustc`, `cargo`).
Дальше: [быстрый старт Gateway](gateway/quickstart.md).
Если вы пишете бота на Rust и вам нужен только клиент гейтвея, программа не нужна: [Подключить бота](gateway/bots.md#бот-на-rust).
---
# Перед боевым счетом
brokapi в открытой альфе. Заявки, сверка и лимиты проверены на имитациях брокеров. Коннектор Bybit проверен вживую ([что именно и что нет](brokers/bybit.md#что-проверено-вживую)). БКС, Т-Банк, Финам и АЛОР проверены только на имитациях, на настоящих счетах их пока не запускали, поэтому первый запуск на них делайте осторожно.
0. **Правило 0. Боевой токен выдает человек, чей это счет, и первую боевую заявку ставят только с его слова.** Если код пишет ИИ-агент, он сам не создает токен с правом торговли
и не включает флаги записи (`BCS_IS_WRITE` и подобные). Что попросить у человека: [Контракт для агентов, правило 0](agent-contract.md#правило-0-токен-и-первая-боевая-заявка-только-от-человека).
1. Начните без токена: имитации (`brokapi.testing`, [быстрый старт на Python](python/quickstart.md)). Потом Т-Банк: песочница. У БКС и Финама песочницы нет, заявка уйдет на настоящий счет. У АЛОРа есть тестовый контур со своим счетом, у Bybit демо-счет с виртуальными деньгами.
2. Первая заявка - лимитная, минимальное количество, цена заведомо далеко от рынка, сразу отмена.
3. Перезапустите бота при стоящей заявке и убедитесь, что сверка ее узнала.
4. Не оставляйте бота без присмотра, пока не проверили оба пункта выше на своем счете.
5. Заранее решите, как остановить бота, и проверьте это: [Аварийная остановка бота](emergency-stop.md). Остановка бота заявки на бирже не снимает.
Что сделает брокер в спорных случаях, мы берем из его документации. Если на вашем счете оно работает иначе, нажмите «Сообщить об ошибке». На страницах брокеров в разделе «Что учесть» собрано то, что меняет код бота.
---
# Аварийная остановка бота
Инструкция. Цель: остановить бота, который ведет себя не так, и твердо знать, что при этом останется на счете. Заранее, до первой боевой заявки, решите,
какой из путей ниже ваш, и проверьте его на имитации или песочнице.
Главное, что надо знать: **остановка бота заявок на бирже не снимает.** Заявка живет у брокера дольше, чем процесс, который ее поставил. Стоп-заявки
тоже остаются. Позицию не закрывает ничто из того, что описано здесь.
## Что остается на счете после каждого действия
| Действие | Заявки | Стоп-заявки | Позиция |
|---|---|---|---|
| Остановка процесса бота (`Ctrl+C`, `kill`, падение) | остаются | остаются | остается |
| `client.close()` в Python, `shutdown` в Rust | остаются | остаются | остается |
| `brokapi stop <гейтвей>`, `brokapi down` | остаются | остаются | остается |
| `brokapi stop <гейтвей> --cancel-orders` | снимаются все | снимаются все, **и защитные тоже** | остается |
| `brokapi cancel-all <гейтвей>` | снимаются все | снимаются все, **и защитные тоже** | остается |
| Скрипт «снять заявки бота» (ниже) | снимаются заявки из журнала бота | остаются | остается |
```mermaid
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](agent-contract.md#10-shutdown-не-снимает-заявки)). Если бот должен уйти, не оставив заявок, он снимает их сам до `close()`.
У клиента, подключенного через гейтвей, `close()` только закрывает его соединение. Гейтвей продолжает работать, заявки и стопы остаются, подписки этого бота
гейтвей отпускает сам, когда бот долго не появляется.
## `brokapi cancel-all`
```bash
brokapi cancel-all <гейтвей>
```
> **Команда снимает все активные заявки и все стоп-заявки счета. В том числе защитные стопы вашего бота и чужие заявки, поставленные руками или другой программой.**
> После нее открытая позиция остается без стопа, а заявки, о которых вы не знали, исчезают.
Это аварийная кнопка на случай, когда важнее всего очистить счет, а позицию вы защитите или закроете сами. Гейтвей при этом продолжает работать. Команда заявок не ставит.
На отказ по лимиту она ждет и повторяет, пока не снимет. Нужен запущенный гейтвей ([Команды brokapi](gateway/commands.md), [Запуск и обслуживание](gateway/operations.md#остановить-гейтвей-и-снять-заявки)).
Ту же работу с остановкой гейтвея делает `brokapi stop <гейтвей> --cancel-orders`.
Прежде чем запускать, посмотрите, что будет снято:
```bash
brokapi orders <гейтвей> # активные заявки счета
brokapi stops <гейтвей> # активные стоп-заявки счета
```
Агент запускает `cancel-all` только со слова человека: команда меняет настоящий счет, и защитные стопы после нее придется ставить заново.
## Снять заявки бота и оставить позицию защищенной
Нужен список `cid` бота, то есть его журнал ([Контракт, правило 1](agent-contract.md#1-cid-придумывает-бот-и-пишет-в-журнал-до-place)). Скрипт открывает клиента, сверяется с
журналом и снимает только то, что в журнале, живое и найдено у брокера. Стопы он не трогает, чужие заявки (`foreign`) тоже.
```python
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` списком.
- За время работы скрипта заявка могла частично исполниться. Позиция тогда выросла, а стоп остался прежним по размеру.
После снятия сверьте позиции и стопы и, если стопа не хватает, поставьте недостающий: [Бот со стопом: старт и рестарт](python/stop-orders.md#бот-со-стопом-старт-и-рестарт).
Тот же рецепт делает бот при каждом запуске, поэтому после аварии достаточно запустить его заново.
Скрипт идет в паре с остановкой самого бота. В Rust порядок тот же ([Запуск, рестарт и остановка](rust/lifecycle.md)): остановить поток решений, снять свои заявки по
журналу, `shutdown`, и только потом процесс.
## Что делает человек
Бот и скрипт позицию не закрывают. Если позицию надо закрыть, это делается в приложении или кабинете брокера: библиотека для этого не предназначена, а команды `brokapi`
заявок не ставят ([Команды brokapi](gateway/commands.md)).
## Как проверить
Скрипт «снять заявки бота» проверен на имитациях всех четырех брокеров: две заявки бота и одна чужая стоят вместе с защитным стопом, бот закрывается, скрипт
в новом процессе снимает две, чужая и стоп остаются. Потерянный ответ на `cancel` попадает в список неясных. Для этих тестов нужен клон репозитория (в пакете `pip`
тестов и примеров нет):
```bash
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(конфиг, счетчик_лимитов, канал_событий)`.
На схеме: бот, библиотека с коннекторами, брокер, общий канал событий и счетчик лимитов.
```mermaid
flowchart TB
BR[Брокер REST, WebSocket, gRPC]
subgraph lib[Библиотека brokapi]
MD[MD, рыночные данные, один на брокера]
UD1[UD, данные счета 1: заявки, портфель]
UD2[UD, данные счета 2]
end
L[Счетчик лимитов один на область квоты]
Q[(Канал событий один на всех)]
Bot[Бот]
BR <--> lib
L -. квота .- lib
lib -- события в конверте --> Q
Q --> Bot
Bot -- команды --> lib
```
Из схемы следует три вещи, и у каждой есть причина:
- **MD и UD разделены.** Всплеск в потоке стакана не должен задерживать отмену заявки. Если бы команды и стакан шли
через одно соединение, отмена стояла бы в очереди за стаканом. У MD и UD разные соединения и разные лимиты, они
ничего не знают друг о друге и не делят сокет.
- **Счетчик лимитов один на ту область, где брокер считает квоту.** У Т-Банка это пользователь (все счета вместе),
у БКС - токен, то есть счет (по документации). Дать двум коннекторам разные счетчики
на одну квоту нельзя: вместе они превысят ее, и брокер начнет отказывать. Дать один счетчик там, где лимиты
независимы, тоже плохо: коннекторы будут мешать друг другу без нужды. Как устроен счетчик: [Лимиты запросов](limits.md),
какая область у какого брокера: [Несколько счетов и брокеров](rust/accounts.md).
- **Канал событий один, коннектор не знает, кто его читает.** Бот отдал канал при старте и может дать один всем
коннекторам или по каналу каждому. Если читатель отстает, коннектор не блокируется: что тогда происходит,
решает буфер подписки ([Круг событий](streams.md)).
Словарь:
| Сущность | Что это |
|---|---|
| Коннектор MD | стаканы, сделки рынка, котировки, свечи. К счету не привязан |
| Коннектор UD | заявки, свои сделки, портфель. Ровно один счет |
| Счетчик лимитов | набор «ведер» с квотой. Считает запросы до отправки, при нехватке сразу `RateLimited`, в сеть ничего не уходит ([Лимиты запросов](limits.md)) |
| Канал событий | очередь `mpsc`, которую бот отдал при старте |
| Конверт `Envelope` | событие плюс метка коннектора `source`, номер подписки `sub` и время ([События и возможности](events.md)) |
| `cid` | номер заявки. Придумывает бот, UUID, до отправки ([Круг команд](orders.md#cid-номер-заявки)) |
| Подписка | поток событий одного вида. У каждой свой буфер ([Круг событий](streams.md)) |
| Шлюз команд | замок на торговые команды (`CommandGate`): закрыт после старта, при перегрузке и при остановке ([Круг жизни](reconcile.md)) |
| Сверка | `reconcile`: сравнение списка заявок бота со счетом, после нее шлюз открывается ([Круг жизни](reconcile.md)) |
## Слои
Крейты разложены так, чтобы логика брокера не знала про сеть, а сеть - про логику. Стрелка на схеме значит
«пользуется». Что лежит в каждом крейте, до отдельного типа: [Справочник API](api.md).
```mermaid
flowchart TB
CONN[brokapi-bcs, brokapi-tbank, brokapi-finam, brokapi-alor, brokapi-bybit коннекторы: склеивают слои]
PROTO[brokapi-bcs-protocol, brokapi-tbank-protocol, brokapi-finam-protocol, brokapi-alor-protocol, brokapi-bybit-protocol клиент брокера: сеть]
RT[brokapi-runtime лимиты, буферы, шлюз, очередь по бумаге, сверка]
SANS[brokapi-sansio ядро без сети: разбор, машина состояний заявки]
CORE[brokapi-core общие типы и трейты: то, что видит бот]
CONN --> PROTO & RT & SANS
RT --> SANS --> CORE
MOCK[brokapi-mocks имитации брокеров] -. для тестов .-> PROTO
CONF[brokapi-conformance общие сценарии] -. проверяет .-> CONN
```
Что из этого следует:
- Бот видит только `brokapi-core`. Сменить брокера - поменять коннектор, код бота почти не меняется.
- Ядро без сети проверяется записанными сообщениями брокера и виртуальным временем, быстро и без интернета.
- Внизу - тестовый контур: имитация брокера с настоящим протоколом и общие сценарии, которые гоняются против
каждого коннектора.
### Зачем ядро без сети
Ядро коннектора (sans-io) - чистая логика: «пришло такое сообщение брокера -> вот события для бота» и «чтобы
подписаться, нужно отправить вот такое сообщение». Оно **не открывает сокеты, не держит таймеры, не знает про
асинхронность**. Сеть и время ему подает внешний слой. Интерфейс ядра (`BrokerCore`) - пять действий:
`on_frame` (разобрать пришедшее сообщение), `poll_transmit` (что отправить брокеру), `poll_timeout` (когда
разбудить ядро), `on_timeout` (обработать наступивший срок), `apply` (привести подписки к нужному набору).
Зачем так:
- Ядро проверяется быстро и без сети. Тест кормит его записанными сообщениями брокера и проверяет события. Время
в таких тестах виртуальное.
- Ошибки разбора и перевода ищутся отдельно от ошибок сети. Если смешать, сбой в сети похож на ошибку разбора, и
причину ищут не там.
- Записанные настоящие сообщения (когда будут живые прогоны) становятся тестами без правок.
У Т-Банка и Финама запросы типизированы (gRPC), поэтому вместо `poll_transmit` у ядра Т-Банка есть свой метод
выдачи запросов. На работу бота это не влияет.
## Три круга
Вся работа - три круга, которые крутятся одновременно и почти не мешают друг другу: **события** (брокер -> бот),
**команды** (бот -> брокер) и **жизнь коннектора** (шлюз команд и сверка). Бот общается с коннекторами двумя
путями: команды - вызовы методов, ответ приходит как результат вызова, события - все подписки всех коннекторов
складываются в один канал.
### Круг событий: брокер -> бот
Внутри коннектора сообщение брокера проходит три ступени. Ядро только разбирает и подсказывает, что отправить и
когда его разбудить, а сокеты и таймеры держит внешний слой. На схеме: путь сообщения от брокера до бота и
служебные сигналы ядра.
```mermaid
flowchart TB
BR[Брокер] -- сообщение --> P[Протокол сокет, поток gRPC]
P -- кадр --> C[Ядро без сети on_frame: разбор]
C -- события в общих типах --> B[Буфер подписки Conflate, DropNewest или Lossless]
B --> Q[(Канал событий)]
Q --> Bot[Бот читает отдельной задачей]
C -. poll_transmit: что отправить .-> P
C -. poll_timeout: когда разбудить .-> T[Таймер]
T -. on_timeout .-> C
```
Поток брокера читается всегда и никогда не ждет бота. Если бот отстает, решает режим буфера:
- `Conflate` - старый снимок заменяется свежим. Для стакана и котировок.
- `DropNewest` - лишнее отбрасывается со счетчиком. Для некритичного.
- `Lossless` - ничего не теряется. Для своих заявок и сделок. Переполнился - `Overflow` и закрытый шлюз. Оборвался и открылся заново поток заявок или исполнений - `Reconnected` и тоже закрытый шлюз
(круг жизни ниже).
Почему режимов три: стакану нужен только последний снимок, а своим сделкам нужны все, и один режим на все
подписки либо потерял бы деньги, либо копил бы устаревший стакан. Подробно, с обрывами и восстановлением
соединений: [Круг событий](streams.md).
### Круг команд: бот -> брокер
Команда проходит три проверки без сети, и только потом уходит к брокеру. На схеме: путь `place` и все его
исходы.
```mermaid
flowchart TB
A[place с cid] --> G{Шлюз открыт}
G -- нет --> E1[NotReconciled, Degraded или ShuttingDown]
G -- да --> V{Вход верный}
V -- нет --> E2[Rejected]
V -- да --> L{Квота есть}
L -- нет --> E3[RateLimited]
L -- да --> O[Очередь по бумаге: ждет прежние команды]
O --> M[cid помечен использованным повтор cid: Rejected]
M --> S[Отправка брокеру]
S --> R{Ответ}
R -- принял --> OK[OrderAck]
R -- отказал --> RJ[Rejected]
R -- нет ответа --> U[Unknown без повтора]
```
Что из этого следует:
- Все, что выше «Отправки», до брокера не доходит. Заявки точно нет, а при отказе на первых трех проверках `cid`
не потрачен: после устранения причины его можно использовать снова. `cid` помечается только после очереди,
поэтому пока вызов ждет в очереди, номер еще чистый.
- `Unknown` не повторяется никогда. Заявка без ответа могла встать, повтор дал бы **две заявки**. Бот выясняет
судьбу через `order_state(cid)`: [Если что-то пошло не так](rust/recovery.md#place-вернул-unknown).
- Очередь по бумаге: отмена не обгонит постановку той же бумаги. Разные бумаги идут параллельно. Отмена тратит
отдельное ведро квоты, поэтому поток постановок ее не вытесняет ([Лимиты запросов](limits.md)).
Исходы, коды отказов и порядок проверок подробно: [Круг команд](orders.md).
### Круг жизни: шлюз торговли
У каждого UD есть шлюз торговых команд. Чтение и подписки работают всегда, закрывается только торговля. На
схеме: три состояния шлюза и переходы между ними.
```mermaid
stateDiagram-v2
state "Не сверен NotReconciled" as NR
state "Торговля открыта" as OPEN
state "Перегрузка Degraded" as DEG
state "Остановка ShuttingDown" as SD
[*] --> NR: start
NR --> OPEN: reconcile успешен
OPEN --> DEG: Overflow в Lossless-буфере или Reconnected потока заявок
DEG --> OPEN: бот дочитал канал и reconcile
OPEN --> SD: shutdown
NR --> SD: shutdown
DEG --> SD: shutdown
SD --> [*]
```
Из обоих закрытых состояний выход один - сверка. Это и есть ответ на вопрос «можно ли торговать»: только когда
список заявок бота совпал со счетом и в данных о своих заявках нет дыры. Остановка необратима: коннектор
закрывается, торговать дальше нельзя.
Сверка, ее результат и причины порядка действий: [Круг жизни](reconcile.md). Что делать по шагам: [Запуск,
рестарт и остановка бота](rust/lifecycle.md).
---
# Круг событий: подписки, буферы, обрывы
Страница описывает путь события от брокера к боту и то, что происходит, когда бот отстает или связь рвется. Схема
пути - на [Карте](architecture.md#круг-событий-брокер---бот), здесь ее подробности: подписки, режимы буфера,
переполнение, `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`-буфер заполнился:
1. Коннектор присылает `Event::Overflow { sub, lost_from }`: этой подписке не хватило места, события начиная с
`lost_from` могли быть потеряны. Событие приходит один раз на подписку, раньше обычных данных.
2. Новые события этой подписки отбрасываются, пока не будет сверки. Тихой потери нет: бот знает, что дыра
образовалась.
3. Коннектор переходит в состояние **`Degraded`**: торговые команды возвращают `Error::Degraded`. Продолжать
торговлю при дыре в данных о своих заявках нельзя.
На схеме: как медленное чтение канала доходит до закрытой торговли.
```mermaid
flowchart TB
A[Бот читает канал медленно] --> B[Lossless-буфер заполнен]
B --> C[Event::Overflow и состояние Degraded]
C --> D[Команды: Error::Degraded]
D --> E[Выход: бот дочитывает канал и зовет reconcile]
```
Почему торговля закрывается, а не продолжается с пометкой «возможна дыра»: бот видит свои заявки только по
событиям, и потерянное событие о частичном исполнении или снятии значит, что бот торгует по неверной картине.
Закрытый шлюз заставляет сначала сверить ее со счетом. Порядок действий боту: [Если что-то пошло не так](rust/recovery.md#пришел-overflow-или-ошибка-degraded).
Чтобы переполнения не было: читайте канал отдельной задачей и не делайте в ней долгих операций. Размер буфера
подберите с запасом на всплеск.
У всех брокеров механизм одинаковый. Буферы подписок у Т-Банка и БКС - один и тот
же код (`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`. Успешная сверка открывает шлюз. Подробно: [Если что-то пошло не так](rust/recovery.md#поток-заявок-оборвался).
Для портфеля такого события нет: каждое сообщение потока портфеля и так целое состояние, следующее заменит прежнее. Для
рыночных данных вместо него `Resync` (выше).
| | Т-Банк | БКС | Финам | АЛОР | Bybit |
|---|---|---|---|---|---|
| Когда приходит | поток заявок или поток сделок открылся не первый раз | второе и каждое следующее подключение сокета `orders/events/ws` (он несет и статусы, и исполнения, поэтому событие идет на обе подписки) | поток заявок или поток сделок был открыт, оборвался и открылся снова | второе и каждое следующее подключение сокета подписок (он несет и заявки, и сделки, поэтому событие идет на обе подписки) | второе и каждое следующее подключение приватного сокета (события идут на подписки заявок и исполнений) |
| `gap_from` | момент переоткрытия (обрыв сам коннектор не видит) | момент подключения | момент обрыва | момент подключения | момент подключения |
| Что еще делает коннектор | перечитывает активные заявки и присылает изменившиеся | ничего | ничего | ничего | ничего, подписки заказываются заново |
## Восстановление соединений
Все коннекторы сами переоткрывают оборванные соединения и восстанавливают подписки. Бот ничего для этого не
делает. На схеме: что происходит после обрыва, пока поток снова не заработает.
```mermaid
flowchart TB
W[Поток работает] --> X[Обрыв: молчание, ошибка, закрытие]
X --> P[Пауза: 0,5 с, дальше удваивается до 30 с]
P --> R[Переподключение]
R -- не вышло --> P
R -- вышло --> S[Подписки восстановлены]
S --> RS[Resync по стакану: старый выбросить]
RS --> W
```
Из схемы следует, что бот не видит обрыва как отдельного события: он видит `Resync` по стакану, когда поток вернулся, и
`Reconnected` по потокам заявок и исполнений. Стакан у всех брокеров приходит полным снимком, поэтому после `Resync` достаточно
дождаться следующего. А после `Reconnected` бот обязан прочитать состояние и позвать `reconcile`
([Если что-то пошло не так](rust/recovery.md#поток-заявок-оборвался)). Параметры по брокерам:
| | Т-Банк | БКС | Финам | АЛОР | 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`): [Несколько
счетов и брокеров](rust/accounts.md).
Симптомы, которые видит бот, и что с ними делать: [Ошибки и что с ними делать](errors.md).
---
# Круг команд: заявки
Страница объясняет, что происходит с заявкой от вызова `place` до исполнения и что бот делает в каждом случае.
Три правила, на которых держится вся библиотека, - в [начале книги](README.md#три-правила-на-которых-все-держится).
Схема самого круга - на [Карте](architecture.md#круг-команд-бот---брокер), здесь ее подробности. Исходы и коды
отказов собраны на странице [Ошибки и что с ними делать](errors.md), порядок действий после неясного исхода - на
странице [Если что-то пошло не так](rust/recovery.md).
## cid: номер заявки
`cid` (`ClientOrderId`) - это UUID, который бот создает сам: `ClientOrderId::new_v4()`. Библиотека номера не
придумывает. Причины:
- **Библиотека ничего не помнит между запусками.** Она не пишет на диск. После рестарта бот приносит список своих
`cid`, и по нему заявки на счете делятся на свои и чужие ([Круг жизни](reconcile.md)).
- **Номер нужен до отправки.** Если ответа нет, `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`
Порядок проверок у всех брокеров один, и он показан на схеме круга: [Карта](architecture.md#круг-команд-бот---брокер).
Сначала шлюз команд, потом проверка входа без сети, потом квота у счетчика лимитов, потом очередь по бумаге, потом **шлюз
еще раз**, и только после этого `cid` помечается использованным и команда уходит к брокеру. Почему именно так:
- **Дешевые проверки первыми.** Шлюз, вход и квота не требуют ни сети, ни ожидания. Отказ на любой из них ничего не
тратит: ни `cid`, ни времени в очереди.
- **Квота до очереди.** Отказ по квоте приходит сразу, а не после ожидания за другими командами по бумаге.
- **Шлюз проверяется второй раз после очереди.** Команда могла простоять в очереди за другой командой по той же бумаге, и
за это время шлюз закрылся: оборвался поток заявок или исполнений (`Event::Reconnected`), переполнился буфер
(`Overflow`), началась остановка. Закрытый шлюз значит, что в сеть ничего не уходит, и это верно и для команды, которая
уже стоит в очереди: она получает `Degraded` или `ShuttingDown`, а не уходит к брокеру. Для `cancel` и `cancel_stop` так же.
- **`cid` помечается как можно позже, прямо перед отправкой.** Пока вызов стоит в очереди, номер чистый: если вызов
отменили или он не дошел до отправки, номером можно воспользоваться снова. Пометка сразу после очереди и до
сети: второй `place` с тем же `cid` увидит ее и не уйдет.
- Отказ `duplicate_place` приходит уже после квоты и очереди, поэтому единица квоты на него потрачена, хотя в сеть
ничего не ушло.
Все, что находится выше отправки, **не доходит до брокера**: можно не сомневаться, что заявки нет. На схеме ниже
- тот же путь во времени: бот, UD-коннектор и брокер.
```mermaid
%%{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`, стоп-команды) заканчивается одним из трех видов исхода, и они отвечают
на главный вопрос: **дошла ли команда до брокера**.
1. **Не дошла.** Закрыт шлюз (`NotReconciled`, `Degraded`, `ShuttingDown`), не прошла проверка входа или
`Unsupported`, не хватило квоты (`RateLimited`), неверная настройка (`Config`). Заявки нет, делать с ней нечего,
исправляется причина.
2. **Дошла и получила ответ.** `Ok(OrderAck)` - брокер принял. `Rejected` - брокер **точно** отказал, заявки нет.
3. **Дошла, ответа нет.** `Unknown` (и `Transport` на команде, если брокер мог ее получить). Заявка **могла** встать.
Это единственный исход, где нельзя ни повторять, ни считать, что заявки нет.
Таблица «исход -> что значит -> почему бывает -> что делать» лежит в [справочнике ошибок](errors.md), там же коды
`Rejected`. Особенности перевода ответов в исходы:
- **БКС.** Отказ может прийти не ошибкой HTTP, а внутри обычного ответа 200 (статус заявки 8, `rejectReason`).
Библиотека читает оба случая и возвращает `Rejected`.
- **Т-Банк.** Брокер может принять запрос и ответить статусом «отклонено» внутри ответа. Это тоже `Rejected` (код
`REJECTED`).
- **Проверка входа до отправки** у всех одна. Количество: положительное, целое там, где брокер берет целые (БКС, Т-Банк),
и кратное лоту, если лот известен. Иначе `Rejected` с кодом `invalid_qty`. Ничего не округляется: дробное количество
там, где брокер берет целые, это отказ. Количество в **единицах инструмента**, не в лотах, у любого
брокера ([Деньги, цены и количества](numbers.md#количество-единицы-инструмента-не-лоты)). Цену коннектор сверяет с
шагом, если шаг ему передан (`price_grid()`): цена не на шаге - `Rejected { price_off_step }` до отправки, квота и
`cid` не тратятся. Идентификатор инструмента другого брокера (например `SymbolMic` Финама у Т-Банка) это `Config`, без
сети. Как округлить цену точно и как передать шаг - [Деньги, цены и количества](numbers.md).
### Как это выглядит в коде
Номер придумывает бот и записывает в журнал до вызова. Вкладка выбирает язык, выбор запоминается для всей книги.
```rust,ignore
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;
```
```python
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`:
```rust,ignore
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()),
}
```
```python
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`, это описано в [Если что-то пошло не так](rust/recovery.md#place-вернул-unknown).
## `cancel`
`cancel(cid)` снимает заявку по вашему `cid`. Исходы те же, что и у `place`. Особенности:
- `Ok(())` значит: брокер подтвердил отмену. У БКС команда синхронная, и «отменено» говорится только тогда, когда
брокер сам ответил, что заявка отменена.
- Заявка уже исполнилась: БКС вернет `Rejected` с кодом `already_filled`, остальные - отказ брокера.
- `Unknown`: отмену **можно** повторить вручную после проверки `order_state`. Повторная отмена новых заявок не
создает. Библиотека сама ее не повторяет.
- У БКС, если после синхронной отмены заявка по ответу еще жива, результат тоже `Unknown`: «отменено» не
объявляется без подтверждения. Так бывает со статусом 6 «Отменяется (в процессе отмены)»: заявка еще стоит в стакане и
может исполниться раньше, чем отмена дойдет до конца. Такой статус приходит как состояние `Unknown`, не `Cancelled`.
`Cancelled` будет, когда брокер пришлет статус 4, а если заявка успела исполниться, придет `Filled`.
- Отмена идет через отдельное ведро лимитов, постановка ее не вытесняет ([Лимиты запросов](limits.md)).
## `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`.
```mermaid
stateDiagram-v2
[*] --> InFlight: place ушел к брокеру
InFlight --> Acked: брокер принял
InFlight --> Конец: отказ, Rejected
InFlight --> Unknown: таймаут или обрыв
Unknown --> Acked: order_state или сверка
Unknown --> Конец: выяснилось
Acked --> PartiallyFilled: часть исполнена
PartiallyFilled --> PartiallyFilled: еще исполнение
Acked --> Конец
PartiallyFilled --> Конец
state "Конец: Filled, 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`), если он нужен для отмены стопов у Т-Банка
([Стоп-заявки](rust/stop-orders.md)). Что хранит бот, а что библиотека: [Круг жизни](reconcile.md#что-хранит-бот-а-что-нет).
---
# Круг жизни: шлюз и сверка
После запуска библиотека не знает, какие заявки у бота были раньше: она ничего не хранит на диске. А на счете
могут стоять заявки с прошлого запуска, заявки, которые исполнились, пока бот был выключен, и заявки, поставленные
руками. Сверка (`reconcile`) разбирает это и только после нее **открывается шлюз торговых команд**. Это третий круг
механики: он решает, можно ли сейчас торговать. Схема трех кругов вместе - на [Карте](architecture.md#три-круга).
## Шлюз команд
У каждого UD есть шлюз торговых команд (`CommandGate`). Чтение работает всегда: `order_state`, `active_orders`,
`executions`, `portfolio`, подписки. Закрывается только торговля: `place`, `cancel`, `replace`, `place_stop`,
`cancel_stop` возвращают ошибку и в сеть не уходят. На схеме: состояния шлюза и переходы между ними.
```mermaid
stateDiagram-v2
state "Не сверен NotReconciled" as NR
state "Торговля открыта" as OPEN
state "Перегрузка Degraded" as DEG
state "Остановка 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`: переполнился буфер подписки без потерь.** События о своих заявках могли пропасть, и локальная картина
заявок с дырой. Торговать при дыре нельзя ([Круг событий](streams.md#переполнение-overflow-и-degraded)).
- **`ShuttingDown`: идет остановка.** Выхода нет, коннектор закрывается ([Запуск, рестарт и остановка](rust/lifecycle.md)).
Если закрыто несколько условий одновременно, первым проверяется `ShuttingDown`, затем `Degraded`, затем
`NotReconciled`. Успешная сверка снимает `NotReconciled` и `Degraded`. Из обоих закрытых состояний выход один -
сверка. Что делает каждая ошибка шлюза и как из нее выйти: [Ошибки и что с ними делать](errors.md).
## Что бот передает и что получает
Бот передает **список `cid`** заявок, которые он считает живыми (открытыми или с неизвестным исходом). Получает
`ReconcileReport` с тремя списками:
| Список | Что в нем | Что делать |
|---|---|---|
| `confirmed` | заявки, которые есть и у бота, и у брокера. Пришли целиком (`OrderSnapshot`): состояние, исполнено, цена | Продолжать вести. Обновить в журнале состояние и исполненный объем |
| `closed_offline` | `cid` из вашего списка, которых нет среди живых у брокера: заявка исполнилась, снята, отклонена или так и не была принята, пока бот был выключен | Выяснить итог: `order_state(cid)` и история `executions(...)`. Занести итог в журнал |
| `foreign` | заявки, которые есть у брокера, но нет в вашем списке: поставлены руками, из приложения или другим роботом | **Не трогать.** Не снимать и не менять. Решение, что с ними делать, принимает человек |
У чужих заявок `cid` подставной: брокер вашего номера не знает. Он стабильный (один и тот же для одной заявки) и
выглядит как UUID, но выдан библиотекой из номера брокера, а не вами. Почему `foreign` нельзя трогать: бот не
знает, зачем человек поставил эту заявку, и снятие может сломать чужую стратегию или защитный стоп.
В коде порядок такой. Подписка идет до сверки, сверка принимает список из журнала, три списка разбираются каждый по-своему:
```rust,ignore
// 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);
}
```
```python
# 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`:
```rust,ignore
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
```
```python
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`, получает три списка, шлюз открывается, итоги идут в журнал.
```mermaid
%%{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` вернул ошибку (нет связи, лимит), он **не открыл шлюз**: подождите и вызовите еще раз. Ошибка при
этом ничего не говорит о заявках, сверка просто не состоялась. Пока шлюз закрыт, ставить заявки нельзя, и это
правильно: бот все еще не знает, что стоит на счете.
## Почему порядок именно такой
Пошаговая инструкция - на странице [Запуск, рестарт и остановка бота](rust/lifecycle.md). Здесь причины:
- **Подписка идет до сверки.** Сверка делает снимок счета в одно мгновение. Событие, которое пришло между снимком
и подпиской, иначе было бы потеряно: в снимке его еще нет, а подписка его уже не получит. Подписки при закрытом
шлюзе разрешены именно поэтому.
- **Список `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()` (см. [Стоп-заявки](rust/stop-orders.md)). У Т-Банка после
рестарта `cid` в этом списке пустой, снимать стоп можно только по номеру брокера.
## Что хранит бот, а что нет
| Что | Где хранится |
|---|---|
| Журнал заявок: `cid`, состояние, номер брокера, исполнено | **бот** |
| Какие заявки считались живыми на момент остановки | **бот** (из журнала) |
| Номера брокера стоп-заявок Т-Банка | **бот**, если нужна отмена после рестарта |
| Связь `cid` и номера брокера, состояние заявок текущего запуска | библиотека, **в памяти**, после остановки исчезает |
| Подписки | библиотека, в памяти. После рестарта бот подписывается заново |
Библиотека не ведет журнал на диске и не восстанавливает состояние сама. Она дает основу для этого: **полный
упорядоченный поток событий** (каждый переход состояния заявки - событие), `cid` в каждом событии заявки и сверку,
которая сравнивает список бота со счетом.
Когда звать `reconcile`, кроме старта (после `Overflow`, после долгого обрыва, при сомнении в журнале): [Если что-то
пошло не так](rust/recovery.md).
---
# Лимиты запросов
Брокеры ограничивают число запросов и на превышение отвечают отказом. Чтобы не получать отказы в самый неудобный
момент, библиотека считает запросы сама и **до отправки**: если квоты нет, запрос не уходит в сеть вообще.
Счетчик один на ту область, где брокер считает квоту ([Карта](architecture.md#сущности-и-связи)), а область у каждого
брокера своя: [Несколько счетов и брокеров](rust/accounts.md#таблица-по-брокерам).
## Как устроен счетчик
Счетчик (`BrokerLimiter`) - это набор «ведер» (token bucket). У каждого ведра три параметра: скорость пополнения
(`rate` запросов за `per`) и размер (`burst`). Ведро стартует полным и пополняется равномерно, а не шагами. Один
запрос тратит одну единицу. Тихий клиент может отправить `burst` запросов сразу, занятой удерживается на `rate`.
Каждый метод API привязан к одному или нескольким ведрам (маршрут). Например, у БКС и Финама:
```toml
PlaceOrder = ["global", "orders"]
CancelOrder = ["global", "cancel"]
```
Постановка тратит общее ведро `global` и ведро заявок, отмена - общее и свое. Ведро `global` общее для всех методов
брокера: оно же защищает от суммарного превышения, а отдельные ведра - от перекоса внутри.
## Что происходит при вызове
1. Коннектор берет из ведер маршрута по одной единице. Если одного ведра не хватило, уже взятые единицы
возвращаются: частично потраченная квота не пропадает.
2. Ведер хватило - запрос уходит к брокеру.
3. Не хватило - вызов сразу возвращает `RateLimited { retry_after }`, **в сеть ничего не ушло**.
Счетчик **никогда не ждет сам**, ждать должен бот. Ожидание внутри вызова скрыло бы задержку от бота и держало бы
очередь по бумаге ([Круг команд](orders.md#порядок-команд-по-бумаге)), а решение «подождать, пропустить или отказаться»
принадлежит боту. Что делать с `RateLimited`: [Ошибки и что с ними делать](errors.md#rate-limited).
`retry_after` - подсказка, сколько ждать до следующей попытки. Это не обещание, что следующий вызов пройдет: другое
ведро маршрута может пополняться дольше.
**Метод без маршрута** (например, вызванный через `raw()`, см. [raw](rust/raw.md)) тратит общее ведро брокера (маршрут
`"*"`). Бесплатных вызовов нет: иначе один неучтенный метод мог бы исчерпать настоящую квоту брокера и вызвать отказы
у всех остальных.
## Почему отмена в отдельном ведре
Отмена не должна ждать за потоком постановок: если заявка ушла не туда, снять ее нужно именно тогда, когда бот
ставит много. Поэтому у отмены свое ведро (`cancel` у БКС и Финама, `order_cancel` у Т-Банка), а ведро заявок (`orders`, у Т-Банка
`order_post`) меньше общего. Поток постановок упирается
в него раньше, чем исчерпает `global`, и для отмены остается место в обоих ведрах.
У этой защиты есть граница: отмена тоже тратит `global`. Если исчерпать его чтением (стаканы, справочник, история),
отказ получит и отмена. Поэтому читайте с запасом и не гоните массовые запросы справочника в то время, когда в рынке
стоят ваши заявки.
## Свои числа: формат файла
Файл лимитов - TOML: ведра (`rate` запросов за `per`, запас `burst`), маршруты методов к ведрам и квоты соединений. Раздел
называется по брокеру (`[bcs]`, `[tbank]`, `[finam]`, `[alor]`, `[bybit]`) или как угодно, если читать общей функцией.
```toml
[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`, делится он через `Arc` (`.clone()` у `Arc`), а не копией.
Полный разбираемый файл, который дает «первая заявка прошла, вторая получила `RateLimited`». Лишние ключи разбор пропускает: старый файл с `dedicated_command_conns` читается, ключ ничего не делает (его убрали, потому что ни один коннектор его не читал).
`retry_after` показывает, сколько ведру нужно до одного токена (при `per = "2s"` порядка 1,8 секунды сразу после траты):
```toml
[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` не лежит ровно:
```rust
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` со своим масштабом у каждого числа. Брокеры дают разное число знаков (Т-Банк до девяти, БКС до восьми у цены,
Финам строку любой длины), и один общий масштаб либо режет цену, либо сужает диапазон. Скорость целых нам не нужна: это
слой подключения, а не движок, между числом и биржей сетевая задержка в миллисекунды.
## Как создать число
```rust
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`).
- Числа сравниваются по значению: `Price` `244.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](api.md)). Округление точное, сетка начинается с нуля:
```rust
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()`. Заполните его один раз
на инструмент:
```rust
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`.
---
# Брокеры
Общий интерфейс одинаков для всех брокеров, но сами брокеры отличаются. Эта страница - сводка различий, дальше
по странице на каждого: [БКС](bcs.md), [Т-Банк](tbank.md), [Финам](finam.md), [АЛОР](alor.md), [Bybit](bybit.md). 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` бота и правила заявок одинаковы ([Круг команд](../orders.md)). Меняется только то, куда `cid` кладется.
- Все ответы на неясные команды - `Unknown`, повторов нет.
- Стакан приходит полным снимком (у Bybit коннектор сам собирает его из снимка и изменений и отдает целиком).
- Шлюз команд после запуска закрыт до `reconcile`, и закрывается снова при `Overflow` и при `Reconnected` (оборвался поток своих заявок или исполнений), и команда, которая стояла в очереди, проверяется шлюзом еще раз.
- Ошибки одного вида: отказ токена это `Config`, "не найдено" это `Rejected` с кодом `not_found`, идентификатор инструмента другого брокера это `Config` без сети.
- `raw()` у каждого коннектора.
## Что у брокеров устроено по-разному
- **Счета.** У БКС счет задает токен, у Т-Банка и Финама - `account_id`, у АЛОРа - портфель и биржа в настройке, у Bybit счет определяет ключ.
- **Лимиты.** У каждого брокера своя область, см. [Несколько счетов и брокеров](../rust/accounts.md).
- **Единицы количества.** Одинаковые: `Qty` это число единиц инструмента (штуки, контракты), лотов бот не видит ни у одного
брокера. Т-Банк и АЛОР считают лотами, коннектор переводит. Размер лота дает `TradingParams::lot_size`, заявка кратна ему
([Деньги, цены и количества](../numbers.md#количество-единицы-инструмента-не-лоты)).
- **Что не поддерживает брокер.** Помечено на странице брокера. Такие вызовы возвращают `Unsupported` сразу, без
сети.
---
# БКС
Крейты: `brokapi-bcs-protocol` (REST и WebSocket клиент) и `brokapi-bcs` (коннекторы).
## Вход
| Что | Как |
|---|---|
| Токен | refresh-токен БКС (`BCS_REFRESH_TOKEN`). Из него коннектор получает токен доступа. Счет определяет сам токен: **`account_id` в настройке нет** |
| Чтение и запись | токены бывают на чтение и на запись. Для торговли нужен токен на запись и `BCS_IS_WRITE=true` (в коде `BcsConfig::with_write(true)`). Без этого брокер торговые команды не примет |
| Песочница | нет. Торговля идет на настоящем счете |
| Обновление | токен доступа обновляется заранее и после ответа 401 на чтение (чтение повторяется один раз). Команда после 401 не повторяется. Подробно: [Токены](../streams.md#токены-и-их-обновление) |
| Лимиты | `brokapi::bcs::limits::default_limits()`: встроенный файл `limits.toml` крейта, как у Т-Банка и Финама. Свой файл: `limits_from_toml_str` и `limits_from_toml_path`. Числа из таблицы ограничений брокера (10 запросов в секунду на сервис, 3 для неторговых операций, 200000 заявок в сутки) |
### Где взять токен
Токен выпускает человек, чей это счет ([правило 0](../agent-contract.md#правило-0-токен-и-первая-боевая-заявка-только-от-человека)). Нужен хотя бы один брокерский счет в
БКС Мир инвестиций. Шаги по документации БКС:
1. Войти в веб-версию БКС Мир инвестиций (`lk.bcs.ru`).
2. Открыть «Профиль», под заголовком «Счета и тарифы» нажать на брокерский счет, к которому нужен доступ.
3. В настройках счета выбрать «Токены API», нажать «Выпустить токен» и следовать инструкциям.
4. Скопировать токен и сохранить сразу: брокер показывает его один раз.
Какой токен выбрать. Токены бывают двух видов: «для торговли и чтения данных» и «только для чтения». **Для первого запуска возьмите «только для чтения»**:
им работают портфель, стакан и свечи, а заявку брокер не примет. Токен для торговли нужен, когда человек решил поставить первую боевую заявку, и тогда же
включается `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.md).
Сообщение канала `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`). Принимается только срок «до отмены» (срок жизни условной заявки документация
не описывает), поле срока не отправляется. Направление срабатывания выбирает брокер. Пара «стоп-лосс плюс
тейк-профит» не создается. Потока статусов нет. Подробно: [Стоп-заявки](../rust/stop-orders.md).
## Чего БКС не поддерживает или делает иначе
| Что | Как |
|---|---|
| Свечи 10 минут | `Unsupported("candles_m10")` |
| Справочник по виду: опционы и `Other` | `Unsupported`. Остальные виды работают, список читается **целиком**: страницами по 100, пока страница полная. Каждая страница тратит квоту, а список длиннее 200 страниц - ошибка, не обрезок |
| Поток стоп-заявок | `Unsupported` |
| Песочница | нет |
| Кадры между WebSocket и разбором | между сессией WebSocket и задачей разбора стоит канал на 512 кадров. Если он заполнится (задача разбора встала), кадр отбрасывается со счетчиком и записью в лог, **без** `Overflow`. |
| Перечитывание заявок после обрыва | нет. Но обрыв сокета `orders/events/ws` виден боту: `Event::Reconnected` на подписки заявок и исполнений и закрытый шлюз до `reconcile` ([Круг событий](../streams.md#reconnected-поток-заявок-или-исполнений-оборвался)) |
## Лимиты
`crates/bcs/limits.toml`, по таблице «Ограничения» из документации брокера: у каждого сервиса свое ведро на 10 запросов
в секунду (портфель, лимиты, справочник, заявки, статус заявки, сделки, дисконты, рыночные данные), у неторговых операций
(история операций) 3 в секунду, у заявок еще суточное ведро на 200000. Заявка, отмена, замена, стоп-заявка и список заявок
тратят общее ведро сервиса заявок. До 4 соединений (канал заявок). Всплеск 10, то есть одна секунда. Постановка, замена и стоп-заявка проходят еще через ведро `place` на 8 в секунду
(всплеск 4): это не лимит брокера, а запас, чтобы поток заявок не занял весь бюджет сервиса и отмене осталось место. Как БКС считает секунду (фиксированное окно или скользящее) и сутки (календарные или 24 часа), в документации не сказано:
если упретесь в `RateLimited` раньше времени, уменьшите значения в `limits.toml`. Суточное ведро считает только этот процесс: на одном счете из двух процессов оно не видит общего числа.
## Что учесть
1. Количество в уровнях стакана и ленты в документации БКС не названо. Для расчета объема сверяйтесь с количеством в своей заявке.
2. Если на запрос заявки по неизвестному `cid` БКС ответит не `NOT_FOUND`, сверка отнесет заявку к неопределенным. Проверьте это на минимальной заявке.
3. Суточный лимит на заявки считает только ваш процесс. Два процесса на одном счете его не видят совместно.
---
# Т-Банк
Крейты: `brokapi-tbank-protocol` (gRPC клиент, собранный из официальных `.proto`) и `brokapi-tbank` (коннекторы).
С песочницы Т-Банка рекомендуется начинать: [Перед боевым счетом](../before-live.md).
## Вход
| Что | Как |
|---|---|
| Токен | один статичный токен (`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](../agent-contract.md#правило-0-токен-и-первая-боевая-заявка-только-от-человека)). Шаги по документации Т-Банка:
1. Открыть [настройки профиля Т-Инвестиции](https://www.tbank.ru/invest/settings/) на компьютере (с телефона страница может переадресовать в приложение, где раздела токенов нет)
и войти. Выпустить токен можно только на сайте `tbank.ru`. Если страница не открывается, нужны сертификаты НУЦ Минцифры или Яндекс Браузер.
2. Убедиться, что «Подтверждение сделок кодом» выключено: по документации Т-Банка оно должно быть отключено. Сделки на сумму больше 30 000 000 рублей требуют подтверждения и через API недоступны.
3. Выпустить токен T-Invest API для биржи и/или для песочницы. Система может попросить войти еще раз.
4. Скопировать токен сразу: его показывают один раз, позже получить его нельзя.
Какой токен выбрать. Виды: только чтение, полный доступ, токен для переводов между счетами (его не берите), токен с доступом к одному счету (права чтения или
полного доступа настраиваются) и токен песочницы. **Для первого запуска возьмите токен песочницы** ([быстрый старт на Python](../python/quickstart.md#шаг-3-песочница-т-банка)).
Для чтения боевого счета берите токен «только чтение». Токен с полным доступом нужен, когда человек решил поставить первую боевую заявку, и лучше с доступом
к одному счету. Токен песочницы к боевому адресу не подойдет.
Номер счета. Если токен выпущен для одного счета, `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`
значит, что токен неактуален. Посмотреть и отозвать токены: [настройки безопасности](https://www.tbank.ru/mybank/profile/security/), блок «Приложения, у которых есть доступ к данным для входа».
## Идентификатор инструмента
Коннектор принимает любую из трех форм:
| Форма | Когда использовать |
|---|---|
| `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`**, вычисленный из номера заявки на бирже.
## Ответы брокера
Номер ошибки Т-Банка (пятизначный, из [таблицы ошибок](https://developer.tbank.ru/invest/intro/developer/error-codes/errors/))
коннектор читает из текста статуса, а если там пусто, из начала заголовка `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` библиотека не использует: ключ ли это идемпотентности для стопов, неизвестно.
Подробно: [Стоп-заявки](../rust/stop-orders.md#потерянный-ответ).
## Чего Т-Банк не поддерживает или делает иначе
| Что | Как |
|---|---|
| Поток статусов стоп-заявок | в API нет, `Unsupported` |
| Справочник `MutualFund`, `Other` | `Unsupported` |
| Серверный стрим рыночных данных, торговые статусы в потоке, остальное API | общего трейта нет, доступно через `raw()` |
| Подтверждение отписки в потоках заявок | у серверных потоков нет сообщения отписки, закрывается сам вызов |
## Лимиты
Т-Банк считает лимиты **на пользователя**: на все счета и все токены вместе. Поэтому счетчик один на программу.
В `crates/tbank/limits.toml` значения из документации брокера, отмена вынесена в отдельное ведро. Реальные значения
отдает `GetUserTariff` и заголовки `x-ratelimit-*`: их можно проверить через `raw()`. До 11 одновременных соединений,
до 300 подписок в одном потоке рыночных данных.
## Что учесть
1. Повтор `order_id`: что сделает Т-Банк (вернет прежний отчет, ошибку или примет вторую заявку), в документации не сказано. Коннектор повторов не делает, поэтому на вас это не влияет, но свой код на повтор `order_id` не закладывайте.
2. Единицы количества в потоке своих сделок - штуки (по описанию протокола).
3. Номер биржевой заявки после `ReplaceOrder` может измениться. Ориентируйтесь на `cid`.
---
# Финам
Крейты: `brokapi-finam-protocol` (gRPC клиент по официальным `.proto` и менеджер токенов) и `brokapi-finam` (коннекторы).
Песочницы у Trade API нет: любая проверка идет на настоящем счете, см. [Перед боевым счетом](../before-live.md).
## Вход
| Что | Как |
|---|---|
| Секрет | секрет 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()`, один счетчик на пользователя |
| Обновление токена | заранее и в фоне, подробно в [Токенах](../streams.md#токены-и-их-обновление). Один JWT можно отдать нескольким коннекторам (`shared_tokens`) |
### Где взять секрет и номер счета
Секрет выпускает человек, у которого счет в Финаме ([правило 0](../agent-contract.md#правило-0-токен-и-первая-боевая-заявка-только-от-человека)). Секрет Trade API и
номер счета берутся **в кабинете Финама, в разделе про Trade API** (токены или ключи доступа). Пошагового описания кабинета в документации Trade API, на которую
мы опираемся, нет, поэтому порядок кнопок здесь не приводится: ищите раздел API в кабинете. Что известно из самой документации:
- Секрет обменивается на короткоживущий JWT (метод `Auth`), коннектор делает это сам. Метод `TokenDetails` показывает срок токена, доступ к рыночным данным и
**список счетов секрета**. Номер счета для `FINAM_ACCOUNT_ID` берется из этого списка, в примерах документации он выглядит как `A12345`. Счет не из списка - `Config` при старте.
- В `TokenDetails` есть признак `readonly`: сессия и счета секрета помечаются «только чтение». Если кабинет позволяет выпустить такой секрет, для первого
запуска берите его: портфель, стакан и свечи работают, заявку брокер не примет. Секрет с правом торговли нужен, когда человек решил поставить первую боевую заявку.
- Песочницы нет, поэтому после чтения идет [первый день на боевом счете](#первый-день-на-финаме).
## Первый день на Финаме
У Финама нет песочницы, и любая проверка идет на настоящем счете. План на первый день, от безопасного к боевому. Каждый шаг начинается, когда прошел
предыдущий, а шаги 3 и 4 - только со слова владельца счета.
1. **Имитация.** Весь код сначала проходит на `brokapi.testing.mock_finam()`: [быстрый старт на Python](../python/quickstart.md#шаг-2-первая-программа-без-токена-и-без-денег),
там `first_order` работает с клиентом любого брокера.
2. **Чтение боевого счета.** `FINAM_SECRET` (лучше только для чтения) и `FINAM_ACCOUNT_ID`, затем `connect("finam")`, `portfolio()`, `order_book(...)`. Неверный
секрет или чужой счет видны на `connect` (`Config`). Заявок на этом шаге нет.
3. **Минимальная заявка далеко от рынка и отмена.** Один лот (`trading_params(...).lot_size`), цена в 90% от лучшей покупки, округленная вниз до шага. Если биржа
отвергла цену как выходящую из допустимого коридора, подвиньте ее ближе к рынку на 5% и повторите с **новым** `cid`.
4. **Проверка, что комментарий вернулся.** Библиотека кладет полный `cid` в комментарий заявки, а Финам должен вернуть его без изменений
([Как `cid` уходит к брокеру](#как-cid-уходит-к-брокеру)). От этого зависит `reconcile`: если комментарий искажен, ваши заявки придут как чужие, а бот не узнает
свою заявку после рестарта. Проверка: поставить заявку, закрыть клиента (заявка остается на бирже), открыть нового и сверить его с журналом из одного `cid`.
Заявка должна оказаться в `confirmed`, а `foreign` остаться пустым.
```python
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` нет. Если заявка осталась на счете после остановки бота, ее снимает бот: [Аварийная остановка бота](../emergency-stop.md).
## Идентификатор инструмента
`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, а не процент. Свои тейк-профиты коннектор ставит в штуках. Подробно: [Стоп-заявки](../rust/stop-orders.md).
## Подписки и потоки
Один поток 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` раньше времени, уменьшите значения.
## Что учесть
1. Комментарий заявки: Финам должен возвращать его без изменений (`GetOrders`, `GetOrder`, `SubscribeOrders`, сделки). От этого зависит `reconcile`.
2. Повтор `client_order_id`: что сделает Финам, в документации не сказано. Коннектор повторов не делает.
3. Заголовок 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](../agent-contract.md#правило-0-токен-и-первая-боевая-заявка-только-от-человека)). **Права у refresh-токена АЛОР не выбираются:**
по документации брокера он равносилен логину и паролю торгового аккаунта, то есть дает и чтение, и торговлю. Токена «только для чтения» в документации нет, поэтому для
первого запуска безопаснее тестовый контур, а на боевом контуре токен хранят как пароль. Шаги по документации АЛОР:
Боевой контур (нужен брокерский счет в АЛОР Брокер):
1. Зарегистрировать учетную запись разработчика на портале `alor.dev` (кнопка «Зарегистрироваться» на странице входа, подтвердить почту).
2. Открыть страницу «Токены для доступа к API» (`alor.dev/open-api-tokens`), ввести логин и пароль торгового аккаунта и нажать «Привязать». К одной учетной записи
разработчика привязывается один торговый аккаунт.
3. Нажать «Выписать токен», затем «Показать» у нового токена в списке. Он действует год. Токены общие для всех разработчиков, у которых привязан этот аккаунт:
отзыв одним отзывает для всех.
Тестовый контур (`ALOR_TEST=true`):
1. Учетная запись разработчика та же, что выше.
2. Тестовый торговый аккаунт выдает брокер: заявка на `support@alor.ru` или через личный кабинет `lk.alor.ru`. К учетной записи разработчика он не привязывается.
3. 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` ([Круг команд](../orders.md)).
- Отмена (`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` годен. Общее правило: [Круг команд](../orders.md).
## Изменение заявки
Есть: `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`. Подробно: [Стоп-заявки](../rust/stop-orders.md).
## Чего АЛОР не поддерживает или делает иначе
| Что | Как |
|---|---|
| Расписание торгов | `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` на стаканы ([Круг событий](../streams.md#reconnected-поток-заявок-или-исполнений-оборвался)) |
## Подписки
Все подписки идут по одному 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`.
## Что учесть
1. Единицы объема в стакане, ленте и свечах документация АЛОРа не называет (у котировок - лоты). Количество в этих данных берите как пришло.
2. Повтор `X-REQID`: что сделает брокер, в документации не сказано. Коннектор команды не повторяет.
3. Срок стоп-заявки без времени окончания и регистр значений (`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](../agent-contract.md#правило-0-токен-и-первая-боевая-заявка-только-от-человека)). Шаги по документации Bybit:
Демо-счет (виртуальные деньги, с него начинайте):
1. Войти на сайт Bybit и включить режим «Demo Trading».
2. В этом режиме открыть страницу API и создать ключ с правами на чтение и торговлю по деривативам (Unified Trading) и чтение кошелька.
3. Положить ключ и секрет в `BYBIT_DEMO_API_KEY` и `BYBIT_DEMO_API_SECRET`. Демо-счет пустой: бот пополняет его методом `POST /v5/account/demo-apply-money` (через `raw`, не чаще раза в минуту, до 100 000 USDT за запрос).
Основной контур: ключ выпускают для отдельного субсчета, без права вывода и с привязкой к IP-адресу. Ключ основного счета программе не давайте.
## Первый запуск
План от безопасного к боевому. Каждый шаг начинается, когда прошел предыдущий.
1. **Имитация.** Код сначала проходит на `brokapi.testing.mock_bybit()` (тестовый инструмент `MOCKUSDT`): [быстрый старт на Python](../python/quickstart.md#шаг-2-первая-программа-без-токена-и-без-денег).
2. **Демо-счет.** Тот же код с `env="demo"`: деньги виртуальные, поэтому проверка идет вживую и без риска, но с настоящей подписью, настоящими потоками и настоящими отказами биржи.
3. **Основной контур** - только с минимальной заявкой далеко от рынка и сразу с отменой, на субсчете.
Программа делает на любом контуре одно и то же: читает контракт и его правила, сверяется со счетом, ставит самую малую заявку вдвое ниже лучшей покупки, видит ее в потоке, снимает,
ставит стоп-заявку и тоже снимает. Ничего не может исполниться: обе цены далеко от рынка.
```python
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())}")
```
Запуск на имитации (сеть и деньги не нужны) и на демо-счете:
```python
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()
```
```python
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` ([Круг команд](../orders.md)).
- Отмена успешна, когда биржа приняла запрос. Подтверждение - событие статуса `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` годен.
Общее правило: [Круг команд](../orders.md).
## Изменение заявки
**Общего `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` |
Подробно: [Стоп-заявки](../rust/stop-orders.md), для Python [Стоп-заявки, Python](../python/stop-orders.md).
## Чего 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` на стаканы ([Круг событий](../streams.md#reconnected-поток-заявок-или-исполнений-оборвался)). Публичный сокет пингуется каждые 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` |
Общая таблица ошибок: [Ошибки](../errors.md).
## Лимиты
`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`). Допущения, на которые опирается код, собраны в [Допущениях по документации брокеров](https://brokapi.ru/docs/agents/unverified.md).
## Что учесть
1. Контур выбирается именем, а не адресом: `BYBIT_ENV=demo` берет пару `BYBIT_DEMO_API_KEY` и `BYBIT_DEMO_API_SECRET`. Забытая `BYBIT_ENV` оставит основной контур и основной ключ.
2. Количество в базовой монете и с шагом контракта (`lot_size`). Заявка меньше `min_qty` или дешевле минимальной стоимости (около 5 USDT) уйдет к бирже и вернется `Rejected`.
3. Замены нет: меняйте заявку отменой и новой заявкой под новым `cid`.
4. Стоп-заявки не входят в `active_orders`: проверяйте оба списка при сверке и в скрипте аварийной остановки ([Аварийная остановка бота](../emergency-stop.md)).
5. Рыночная заявка исполняется как IOC: если ликвидности не хватает, остаток отменяется, проверяйте `filled` в `order_state`.
6. Один ключ - один `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`. Подробности: [Установка](../install.md). Первый
шаг быстрого старта - получить библиотеку, дальше переменные `.env`: [Быстрый старт на Rust](quickstart.md).
## Инструкции по задачам
| Что нужно сделать | Страница |
|---|---|
| Пройти путь от нуля до первой заявки | [Быстрый старт на Rust](quickstart.md) |
| Написать торгового бота на событиях | [Торговый бот на событиях, Rust](howto-trading-bot.md) |
| Держать несколько брокеров и счетов в одном процессе | [Несколько брокеров и счетов: инфраструктура](howto-infrastructure.md) |
| Выгрузить историю и проиграть поток событий | [Исследовательский движок](howto-research.md) |
| Правильно запустить, перезапустить и остановить бота | [Запуск, рестарт и остановка](lifecycle.md) |
| Выйти из `Unknown`, переполнения и обрыва связи | [Если что-то пошло не так](recovery.md) |
| Понять, как делить счета и лимиты | [Несколько счетов и брокеров](accounts.md) |
| Поставить стоп-лосс или тейк-профит | [Стоп-заявки](stop-orders.md) |
| Вызвать метод брокера, которого нет в общем интерфейсе | [Любой метод брокера: raw](raw.md) |
| Проверить свой код без сети и денег | [Тесты против имитаций, Rust](mocks.md) |
## Общее, что нужно прочитать
- [Контракт для агентов](../agent-contract.md): обязательные правила на одной странице, нужны и человеку.
- [Карта](../architecture.md) и три круга: [события](../streams.md), [команды](../orders.md), [жизнь коннектора](../reconcile.md).
- [Ошибки и что с ними делать](../errors.md), [события и возможности](../events.md), [Брокеры](../brokers/README.md).
- Справочник по типам и методам, собранный из кода: [Справочник API: Rust](../api.md).
Каждый пример в этом разделе - тест против имитаций брокеров, он собирается и запускается при каждой проверке
репозитория.
---
# Быстрый старт на Rust
Цель страницы: за один заход получить работающую программу, которая подключается к брокеру, смотрит стакан,
ставит одну заявку далеко от рынка, читает события и снимает заявку. Для каждого брокера свой короткий `main`,
а общая часть одна.
> На настоящем счете БКС, Т-Банка, Финама и АЛОРа библиотеку пока не запускали (Bybit проверен вживую). Начинайте с песочницы Т-Банка. Для БКС и Финама песочницы нет, заявка уйдет на
> настоящий счет (у АЛОРа есть тестовый контур со своим счетом, у Bybit демо-счет с виртуальными деньгами): ставьте минимальное количество и цену **заведомо ниже рынка**, кратную шагу цены.
> Подробнее: [Перед боевым счетом](../before-live.md).
## Шаг 1. Получить библиотеку
Нужен Rust 1.88 или новее (редакция 2024). Подключается один крейт `brokapi`, брокеры включаются флагами, как у `tokio`
(`tbank`, `bcs`, `finam`, `alor`, `bybit`, можно несколько):
```bash
cargo add brokapi --features tbank
cargo add tokio --features full
cargo add dotenvy
```
Самая свежая версия из репозитория - вместо первой строки (подробности и проверка - на странице [Установка](../install.md)):
```bash
cargo add --git https://github.com/runinfra/brokapi brokapi --features tbank
```
Должно получиться в `Cargo.toml`:
```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` должен собрать проект без ошибок. Не собрал:
таблица «Если что-то пошло не так» на странице [Установка](../install.md#если-что-то-пошло-не-так).
Что делает каждый крейт: [Карта](../architecture.md#слои).
## Сначала без токена: на имитации
Токен и счет нужны не сразу. В клоне репозитория (`git clone https://github.com/runinfra/brokapi`) пример БКС запускается на имитации, которая
живет в вашем процессе и говорит на протоколе БКС: без сети, без счета, без денег.
```bash
cargo run --example quickstart_bcs # имитация: токен не нужен
BROKAPI_LIVE=1 cargo run --example quickstart_bcs # настоящий БКС: токен на запись и слово владельца счета
```
Пример читает портфель, стакан и свечи, ставит заявку далеко от рынка и снимает ее: ровно то, что делают программы ниже. **Цена в имитации стоит на
месте**, заявка сама не исполнится, ее исполняет тест (`server.fill`). Имитации всех четырех брокеров, рычаги и ограничения описаны на странице
[Тесты против имитаций, Rust](mocks.md). Крейт `brokapi-mocks` в crates.io не публикуется, поэтому в своем проекте его подключают по пути к клону.
Токены и счет для следующих шагов выдает человек, чей это счет: [правило 0](../agent-contract.md#правило-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` нужного брокера.
```rust
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, 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(
md: &M,
ud: &U,
instrument: InstrumentId,
qty: Qty,
price: Price,
events: &mut mpsc::Receiver,
) -> Result<(), Box>
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(())
}
```
Разбор шагов: сверка - [Круг жизни](../reconcile.md), заявка и ее исходы - [Круг команд](../orders.md), режимы подписки -
[Круг событий](../streams.md), события и их поля - [События и возможности](../events.md).
## Шаг 4. Программа для Т-Банка
Количество в **штуках**, как у любого брокера в библиотеке: у Т-Банка API считает лотами, но коннектор переводит сам, лот должен
укладываться в заявку целое число раз (лот `SBER` 10, поэтому ниже заявка на одну бумагу была бы отказана `invalid_qty`).
Цена за одну бумагу. Инструмент ищем через справочник: он отдает `uid`, которым пользуется брокер.
```rust
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> {
// 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::(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(())
}
```
Что важно у Т-Банка: [страница Т-Банка](../brokers/tbank.md).
## Шаг 4. Программа для БКС
Счет определяет токен, поэтому `account_id` нет. Лимиты встроены в крейт: `brokapi::bcs::limits::default_limits()`, как у двух
других брокеров (числа из таблицы ограничений брокера). Инструмент БКС задается тикером и классом (`SBER`, `TQBR`),
справочник для этого не нужен.
```rust
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> {
// 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::(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(())
}
```
У БКС заявка идет в штуках (так в документации брокера): [страница БКС](../brokers/bcs.md). Лот инструмента передайте
коннектору (`ud.price_grid().learn(¶ms)`), тогда некратное количество будет отказано до отправки.
## Шаг 4. Программа для Финама
Количество в штуках. Инструмент задается строкой `SBER@MISX`. Чтобы MD и UD не авторизовались дважды,
создаем один менеджер токенов и отдаем его обоим.
```rust
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> {
// 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::(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(())
}
```
Что важно у Финама: [страница Финама](../brokers/finam.md).
## Что произошло по шагам
1. `start` поднял коннектор. У БКС и Финама он сразу проходит авторизацию и возвращает ошибку, если токен не
подошел. У Т-Банка при заданном счете `start` в сеть не ходит, о неверном токене скажет первый запрос.
2. Подписки дали `SubscriptionId`. По `source` и `sub` в конверте видно, чье это событие. Они сделаны до сверки нарочно.
3. `reconcile(&[])` открыл шлюз команд. До него `place` вернул бы `NotReconciled`.
4. `place` вернул `OrderAck` (или один из исходов, разобранных в [Заявках](../orders.md)).
5. События идут в канал сами. Программа их читает, как только они появились.
6. `cancel` снял заявку, `shutdown` закрыл подписки. **Заявки на бирже при остановке не снимаются**: если
`cancel` не прошел, заявка останется.
---
# Как написать торгового бота на событиях (Rust)
Инструкция. Цель: бот, который читает стакан, по сигналу ставит заявку, следит за ее статусами, выходит из неясного исхода
(`Unknown`) и аккуратно останавливается. Правила, на которых он стоит, собраны в [Контракте для агентов](../agent-contract.md),
причины - в [Круге команд](../orders.md) и [Круге жизни](../reconcile.md). Код ниже - куски программы trading_bot, весь ее текст в конце страницы.
Она запускается тестом против имитаций БКС и Т-Банка, поэтому текст и проверенный код не расходятся.
Стратегия в примере заглушка: когда пришел стакан и своей заявки нет, купить минимальное количество на 1% ниже лучшей цены
покупки. Все остальное (журнал, сверка, исходы каждой команды, `Resync`, `Overflow`, остановка) нужно любому настоящему боту.
## Что получится
Бот написан на трейтах `brokapi::core` (`OrderBookSource`, `InstrumentSource`, `OrderExecutor`, `ExecutionSource`), поэтому
один и тот же `TradingBot` работает с любым брокером. На схеме: откуда идут события, где решает бот и что делает с
каждым исходом `place`.
```mermaid
flowchart TB
BR[Брокер] --> MD[MD: стакан]
BR --> UD[UD: статусы и сделки]
MD --> Q[(Канал событий)]
UD --> Q
Q --> BOT[TradingBot.on_event]
BOT -- стакан, заявки нет --> P[place через UD, новый cid, cid в журнал до вызова]
P -- Ok --> W[Working: ждем событий заявки]
P -- Rejected --> D[Done: отказ]
P -- RateLimited или шлюз закрыт --> N[Не отправлено: позже новая попытка с новым cid]
P -- Unknown --> U[order_state по тому же cid]
U -- заявка есть --> W
U -- брокер не знает --> A[подождать и спросить снова]
A -- так и нет --> D2[Done: не ставилась]
```
Из схемы следует: ответ на `place` и статусы приходят разными путями (результат вызова и события), `Unknown` разбирается
запросом, а не повтором, и каждая развилка заканчивается записью в журнал.
## Подключение
```toml
[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"] }`.
```rust,ignore
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;
```
## Журнал
Журнал - память бота. Библиотека не пишет на диск ничего. В примере он в памяти, чтобы код был короче. Настоящий бот пишет
каждое изменение в файл или базу до того, как идти дальше: готовый журнал в файле - в [инструкции про несколько брокеров и
счетов](howto-infrastructure.md#журнал-заявок-в-файле).
```rust,ignore
/// 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 {
self.entries
.iter()
.filter(|(_, e)| !matches!(e, Entry::Done(_)))
.map(|(c, _)| *c)
.collect()
}
}
```
## Подключение коннекторов
Один счетчик лимитов на токен (БКС считает лимит на токен) и один канал для событий обоих коннекторов. Канал читает отдельная
маленькая задача (`forward`, ниже): она сразу переносит конверты в очередь бота. Это нужно, потому что бот в ходе работы ждет
ответы брокера (`place` до 30 секунд), а коннектор не умеет ждать читателя: если канал не читается, буфер заполняется и приходит
`Overflow` ([Контракт, правило 5](../agent-contract.md#5-свои-заявки-и-сделки-идут-только-lossless-канал-читать-всегда)).
```rust,ignore
/// 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,
}
/// 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) -> mpsc::UnboundedReceiver {
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`), идентификатор
инструмента у БКС и читатель канала.
```rust,ignore
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::(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` не вернулся, бот не торгует.
```rust,ignore
/// 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,
/// The order that works now. One at a time in this bot.
pub working: Option,
/// 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,
/// What each of OUR orders has filled so far, from the order snapshots.
fills: HashMap,
/// 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,
book_sub: Option,
/// 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,
/// The price of the last order that was accepted.
pub placed_price: Option,
}
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, 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](../agent-contract.md#11-чужие-заявки-не-трогать)).
- Сколько исполнено, бот считает по полю `filled` снимков заявок, а не складывает события `Execution`. Тогда после
`Overflow` сверка сама возвращает правильное число (`reconcile` и `order_state` дают снимки).
- `Overflow` бывает двух видов. Если переполнилась **подписка на стакан** (ее `sub` и метка коннектора известны боту), стакан
объявляется недействительным и подписка оформляется заново. Если переполнился поток своих заявок, нужна сверка.
## Сигнал и заявка со всеми исходами
```rust,ignore
/// 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`. «Не найдена» сразу после обрыва не окончательно:
запрос мог быть в пути, поэтому бот ждет и спрашивает еще раз, а сдается только по истечении терпения.
```rust,ignore
/// 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`: дочитать, сверить
```rust,ignore
/// 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,
) -> 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` нужна только затем, чтобы пример не завис при ошибке.
```rust,ignore
// 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`.
```rust,ignore
tokio::select! {
env = queue.recv() => { /* bot.on_event(...) */ }
_ = tokio::signal::ctrl_c() => break, // потом bot.stop(&mut queue), shutdown
}
```
```rust,ignore
/// 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,
) -> 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`).
```rust,ignore
// 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` в отчете - команды, на которые еще не пришел ответ. После нормально завершенных заявок он пуст.
## Как запустить
```bash
cargo test --test examples trading_bot # три теста: исполнение, остановка с отменой, выход из Unknown
cargo run --example trading_bot # то же на имитации БКС с выводом
```
## Вся программа целиком
Для копирования: файл `examples/trading_bot.rs` как есть (в `Cargo.toml` те же зависимости, что выше, плюс
`brokapi-mocks` из [страницы про имитации](mocks.md)). Часть с `run_bcs` и `run_tbank_unknown` запускает бота против имитаций.
Текст программы
```rust,ignore
//! 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;
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 {
self.entries
.iter()
.filter(|(_, e)| !matches!(e, Entry::Done(_)))
.map(|(c, _)| *c)
.collect()
}
}
/// 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,
}
/// 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) -> mpsc::UnboundedReceiver {
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
}
/// 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,
/// The order that works now. One at a time in this bot.
pub working: Option,
/// 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,
/// What each of OUR orders has filled so far, from the order snapshots.
fills: HashMap,
/// 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,
book_sub: Option,
/// 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,
/// The price of the last order that was accepted.
pub placed_price: Option,
}
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, 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(())
}
/// 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(())
}
/// 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;
}
}
/// 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,
) -> 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(())
}
/// 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,
) -> 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(())
}
}
/// 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,
}
/// Plays the bot on the BCS imitation. `ending` says how the order ends.
pub async fn run_bcs(ending: Ending) -> Result> {
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::(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?;
// 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?;
let outcome = Outcome {
journal: bot.journal.entries.clone(),
filled: bot.filled(),
price: bot.placed_price,
};
// 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()
);
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> {
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::(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> {
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` ([Быстрый старт](quickstart.md)).
- **Журнал:** замените на файл или базу, запись до `place` с принудительной записью на диск.
- **Несколько заявок и инструментов:** состояние `working` станет таблицей по `cid`, очередь по бумаге библиотека
держит сама ([Круг команд](../orders.md#порядок-команд-по-бумаге)).
- **Боевой счет:** сначала [Перед запуском на настоящем счете](../agent-contract.md#перед-запуском-на-настоящем-счете).
---
# Как собрать инфраструктуру на несколько брокеров и счетов (Rust)
Инструкция. Цель: один процесс держит несколько брокеров и счетов (БКС один, Т-Банк два на одном токене, Финам один, АЛОР один), все события
идут в один канал, заявки пишутся в журнал в файле, а после рестарта каждый счет сверяется с журналом. Правила: [Контракт для
агентов](../agent-contract.md), устройство: [Несколько счетов и брокеров](accounts.md). Код ниже - куски программы
`examples/infrastructure.rs`. Она запускается тестом против имитаций всех брокеров, и тест проходит две жизни процесса.
## Что получится
На схеме: коннекторы, общий канал, счетчики лимитов (у каждого брокера свой), журнал. Т-Банк делит один счетчик между
рынком и обоими счетами, потому что считает лимит на пользователя.
```mermaid
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 в нужный счет]
R --> J[(Журнал в файле)]
J --> S[start: reconcile каждого счета со своей частью журнала]
```
Из схемы следует: UD на счет, MD на брокера, счетчик на область квоты. Метка `source` у коннектора, которую вы задаете в
настройке, - единственное, по чему в общем канале видно, чье событие. Поэтому метки UD равны именам счетов.
## Подключение
```toml
[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"
```
```rust,ignore
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`), в примере - из имитаций.
```rust,ignore
/// 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> {
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()?,
})
}
}
```
Для пробного запуска без брокеров те же настройки собираются из имитаций (запуск имитаций и их константы: [Тесты против имитаций](mocks.md)):
```rust,ignore
// 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`. Строка, оборванная падением,
пропускается при чтении.
```rust,ignore
/// 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,
}
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 {
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::() 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 {
self.orders
.iter()
.filter(|(_, (a, s))| a == account && !matches!(s, State::Done(_)))
.map(|(cid, _)| *cid)
.collect()
}
}
```
## Что нужно процессу от счета
Три брокера - три разных типа коннекторов. Общий трейт `Account` (его реализуют все автоматически) дает единый вид, и
остальной код не знает, какой это брокер. Исход `place` сведен к четырем вариантам. Чтобы убрать счет, например оставить один
счет Т-Банка вместо двух, уберите его UD из `Infra`, из `accounts()` и из `ACCOUNTS`.
```rust,ignore
/// 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 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`).
```rust,ignore
/// 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), Box> {
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::(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` в примере полсекунды для имитаций. Для настоящего брокера берите секунды.
```rust,ignore
/// 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) -> mpsc::UnboundedReceiver {
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` показывается, но не трогается.
```rust,ignore
/// 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 {
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 {
// 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`: сначала очередь читается до конца, потом
сверка.
```rust,ignore
/// 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 {
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,
) -> 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(())
}
```
## Заявка: журнал, вызов, журнал
```rust,ignore
/// 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` нельзя.
## Разбор общего канала
```rust,ignore
/// 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` по ответу, а сверка при старте исправит все остальное.
События лишь ускоряют запись итогов. Поэтому для примера не важно, что статусы БКС приходят после подключения сокета в фоне
([ловушка в тестах](mocks.md#ловушки-тестов)): потеряно только раннее событие, итог узнает сверка.
События маркетдаты (`bcs-md`, `tbank-md`, `finam-md`, `alor-md`) инфраструктура пропускает: их читает стратегия. `Overflow` счета
помечает его как «сверить до торговли».
## Остановка
```rust,ignore
/// 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
```rust,ignore
//! 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;
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;
/// 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,
}
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 {
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::() 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 {
self.orders
.iter()
.filter(|(_, (a, s))| a == account && !matches!(s, State::Done(_)))
.map(|(cid, _)| *cid)
.collect()
}
}
/// 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> {
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()?,
})
}
}
/// 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 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,
}
/// 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), Box> {
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::(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}"))
}
/// 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 {
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,
) -> 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(())
}
/// 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 {
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 {
// 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");
}
/// 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,
}
}
/// 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),
_ => {}
}
}
/// 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;
}
}
/// 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) -> mpsc::UnboundedReceiver {
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()
}
/// 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> {
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,
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,
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> {
use brokapi_mocks::{alor as malor, bcs as mbcs, finam as mfinam, tbank as mtbank};
// 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,
),
};
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> {
let dir = std::env::temp_dir();
let r = run(&dir).await?;
for (account, cid) in &r.cids {
println!("{account}: {cid} -> {:?}", r.journal[cid]);
}
Ok(())
}
```
## Как проверить
```bash
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` для каждого,
предварительно дочитав канал ([Если что-то пошло не так](recovery.md#пришел-overflow-или-ошибка-degraded)).
---
# Как собрать исследовательский движок
Инструкция. Цель: выгрузить длинную историю свечей и сделок по нескольким бумагам у нескольких брокеров, не нарушая лимитов,
записать поток событий в файл и проиграть его из файла столько раз, сколько нужно для разбора. Правила: [Контракт для
агентов](../agent-contract.md). Код ниже - куски программы `examples/research.rs`. Она запускается тестом против имитаций брокеров: имитации, как настоящие брокеры, отклоняют запрос длиннее предела и отдают ряд под заданный период.
## Что получится
```mermaid
flowchart TB
subgraph BR[Брокеры, у каждого свой счетчик лимитов]
B[БКС: 1440 баров на запрос]
T[Т-Банк: от суток до 10 лет по таймфрейму]
F[Финам: осторожные 500 баров]
A[АЛОР: предел не назван, окно задает бот]
end
BR --> W[CandlePages: окна по пределу, ожидание RateLimited, склейка без дублей]
W --> D[(Файлы баров по бумагам файл сам себе контрольная точка)]
BR --> L[Живой поток событий]
L --> R[record: файл events.log]
R --> P[replay: те же события из файла]
L --> A1[analyze: живой разбор]
P --> A2[analyze: разбор из файла]
```
Из схемы следует: история и живой поток независимы. Историю читают окнами по запросу, живой поток записывают как есть, и тот же
разбор (`analyze`) читает и живой канал, и проигрыш из файла, получая одно и то же.
## Подключение
```toml
[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"] }
```
```rust,ignore
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`: читает все в память одним вызовом.
## Файлы с барами
Один файл на бумагу: текст, строка на бар (`время, открытие, максимум, минимум, закрытие, объем`). Текст нарочно: его читает
любая программа, а начало файла остается правильным файлом. **Контрольной точкой служит сам файл:** где он кончается, там
продолжается выгрузка. Состояние не может разойтись с данными, потому что отдельного состояния нет.
```rust,ignore
/// `//_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::().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`, и только потом просится следующая.
```rust,ignore
/// Waits out `RateLimited` for any read. EVERY read spends quota, `find` and `last_trades` included, not only candles.
async fn waiting(mut call: F) -> Result
where
F: FnMut() -> Fut,
Fut: std::future::Future