Круг жизни: шлюз и сверка
После запуска библиотека не знает, какие заявки у бота были раньше: она ничего не хранит на диске. А на счете
могут стоять заявки с прошлого запуска, заявки, которые исполнились, пока бот был выключен, и заявки, поставленные
руками. Сверка (reconcile) разбирает это и только после нее открывается шлюз торговых команд. Это третий круг
механики: он решает, можно ли сейчас торговать. Схема трех кругов вместе - на Карте.
Шлюз команд
У каждого UD есть шлюз торговых команд (CommandGate). Чтение работает всегда: order_state, active_orders,
executions, portfolio, подписки. Закрывается только торговля: place, cancel, replace, place_stop,
cancel_stop возвращают ошибку и в сеть не уходят. На схеме: состояния шлюза и переходы между ними.
stateDiagram-v2 state "Не сверен<br>NotReconciled" as NR state "Торговля открыта" as OPEN state "Перегрузка<br>Degraded" as DEG state "Остановка<br>ShuttingDown" as SD [*] --> NR: start NR --> OPEN: reconcile успешен OPEN --> DEG: Overflow в Lossless-буфере DEG --> OPEN: бот дочитал канал и reconcile OPEN --> SD: shutdown NR --> SD: shutdown DEG --> SD: shutdown SD --> [*]
Шлюз закрыт в трех случаях, и у каждого своя причина:
NotReconciled: после запуска. Пока бот не знает, какие заявки уже стоят у брокера, новая команда может задвоить заявку или спорить с той, которую бот не видит.Degraded: переполнился буфер подписки без потерь. События о своих заявках могли пропасть, и локальная картина заявок с дырой. Торговать при дыре нельзя (Круг событий).ShuttingDown: идет остановка. Выхода нет, коннектор закрывается (Запуск, рестарт и остановка).
Если закрыто несколько условий одновременно, первым проверяется ShuttingDown, затем Degraded, затем
NotReconciled. Успешная сверка снимает NotReconciled и Degraded. Из обоих закрытых состояний выход один -
сверка. Что делает каждая ошибка шлюза и как из нее выйти: Ошибки и что с ними делать.
Что бот передает и что получает
Бот передает список cid заявок, которые он считает живыми (открытыми или с неизвестным исходом). Получает
ReconcileReport с тремя списками:
| Список | Что в нем | Что делать |
|---|---|---|
confirmed | заявки, которые есть и у бота, и у брокера. Пришли целиком (OrderSnapshot): состояние, исполнено, цена | Продолжать вести. Обновить в журнале состояние и исполненный объем |
closed_offline | cid из вашего списка, которых нет среди живых у брокера: заявка исполнилась, снята, отклонена или так и не была принята, пока бот был выключен | Выяснить итог: order_state(cid) и история executions(...). Занести итог в журнал |
foreign | заявки, которые есть у брокера, но нет в вашем списке: поставлены руками, из приложения или другим роботом | Не трогать. Не снимать и не менять. Решение, что с ними делать, принимает человек |
У чужих заявок cid подставной: брокер вашего номера не знает. Он стабильный (один и тот же для одной заявки) и
выглядит как UUID, но выдан библиотекой из номера брокера, а не вами. Почему foreign нельзя трогать: бот не
знает, зачем человек поставил эту заявку, и снятие может сломать чужую стратегию или защитный стоп.
В коде порядок такой. Подписка идет до сверки, сверка принимает список из журнала, три списка разбираются каждый по-своему:
// Second run, after a restart. The gate is closed after `start`: commands return NotReconciled.
let (ud, mut events) = start_ud(cfg).await?;
// Subscribe BEFORE the reconcile: an event between the snapshot and the subscription would be lost.
ud.subscribe_order_state().await?;
// The journal says which orders the bot thinks are alive.
let report = ud.reconcile(&journal).await?;
for order in &report.confirmed {
println!(
"confirmed {}: still at the broker, filled {}",
order.cid,
order.filled.decimal()
);
}
for cid in &report.closed_offline {
// Find out how it ended: order_state(cid) and executions(...), then write it to the journal.
println!("closed offline {cid}: ask order_state and write the result");
}
for order in &report.foreign {
// Not ours. Do not touch: a person decides.
println!("foreign {}: shown to a person", order.cid);
}
# After a restart the gate is closed: commands raise NotReconciled.
# Subscribe BEFORE the reconcile: an event between the snapshot and the subscription would be lost.
await client.subscribe_order_state()
# The journal says which orders the bot thinks are alive.
report = await client.reconcile(ledger)
for order in report.confirmed:
print(f"confirmed {order.cid}: still at the broker, filled {order.filled}")
for cid in report.closed_offline:
# Find out how it ended: order_state(cid) and executions(...), then write it to the journal.
print(f"closed offline {cid}: ask order_state and write the result")
for order in report.foreign:
# Not ours. Do not touch: a person decides.
print(f"foreign {order.cid}: shown to a person")
Первая торговая команда идет только после этого. Сначала подписка, потом сверка, потом заявка, а до сверки заявка получает
NotReconciled:
ud.subscribe_order_state().await?; // before the reconcile, or an event in between is lost
let report = ud.reconcile(&journal.live()).await?; // an empty slice for a bot without a journal
// report.confirmed, report.closed_offline, report.foreign: handle them (see the lifecycle page)
ud.place(request).await?; // before the reconcile this is Error::NotReconciled
await client.subscribe_order_state() # before the reconcile, or an event in between is lost
report = await client.reconcile(journal.live()) # an empty list for a bot without a journal
# report.confirmed, report.closed_offline, report.foreign: handle them (see the lifecycle page)
await client.place(cid=brokapi.new_cid(), instrument=instrument, side=brokapi.Side.BUY, qty=qty, price=price)
# Before the reconcile the line above raises brokapi.NotReconciled.
Порядок на схеме такой: бот поднимает UD (шлюз закрыт), подписывается на статусы, берет из журнала cid живых
заявок, зовет reconcile, получает три списка, шлюз открывается, итоги идут в журнал.
%%{init: {'sequence': {'width': 90, 'actorMargin': 30, 'mirrorActors': false}}}%%
sequenceDiagram
participant J as Журнал бота
participant Bot as Бот
participant UD as UD, коннектор счета
participant Br as Брокер
Bot->>UD: start, шлюз закрыт
Bot->>UD: подписка на заявки и сделки
J->>Bot: cid живых заявок
Bot->>UD: reconcile(cid)
UD->>Br: заявки счета
Br-->>UD: список
UD-->>Bot: три списка
Note over UD: шлюз открыт
Bot->>J: итоги в журнал
Если reconcile вернул ошибку (нет связи, лимит), он не открыл шлюз: подождите и вызовите еще раз. Ошибка при
этом ничего не говорит о заявках, сверка просто не состоялась. Пока шлюз закрыт, ставить заявки нельзя, и это
правильно: бот все еще не знает, что стоит на счете.
Почему порядок именно такой
Пошаговая инструкция - на странице Запуск, рестарт и остановка бота. Здесь причины:
- Подписка идет до сверки. Сверка делает снимок счета в одно мгновение. Событие, которое пришло между снимком и подпиской, иначе было бы потеряно: в снимке его еще нет, а подписка его уже не получит. Подписки при закрытом шлюзе разрешены именно поэтому.
- Список
cidберется из журнала бота. Только бот знает, какие заявки он считал живыми. Библиотека не пишет на диск, и без этого списка она не отличит ваши заявки от чужих. reconcileтолько читает, поэтому его можно вызывать в любой момент и сколько угодно раз. Он не снимает и не меняет заявок. Единственное, что он меняет, - состояние шлюза.- Сверка не открывает шлюз при ошибке. Если часть списка не прочиталась, результат нельзя считать полным.
Как сверка устроена у брокеров
| Брокер | Как | Нюансы |
|---|---|---|
| Т-Банк | один запрос списка живых заявок счета. cid из вашего списка, которого среди живых нет, идет в closed_offline | в списке нет заявок, закрытых за время обрыва, их находят через order_state и executions |
| БКС | решает ответ на GET по каждому cid вашего списка, не список заявок. Заявка жива (новая, частично исполнена, заменяется, отменяется): confirmed, со снимком из этого ответа. Заявка закончилась (исполнена, отменена, отклонена) или брокер не знает номер (not_found): closed_offline. Затем читается список активных заявок, из него берутся только foreign | пустой ответ без отчета и любая другая ошибка по одному из cid прерывают всю сверку: по неясному ответу заявку «закрытой» не объявляют, иначе бот поставит вторую. Что настоящий БКС отвечает на неизвестный cid именно not_found, проверить на живом счете |
| Финам | один запрос списка заявок счета, cid каждой заявки читается из ее комментария. Не найденный в списке cid и найденный, но не активный, идут в closed_offline | зависит от того, что Финам возвращает комментарий заявки без изменений и что отдает GetOrders для закрытых заявок |
| АЛОР | два запроса: список заявок дня и список стоп-заявок. cid каждой заявки читается из ее комментария. cid из вашего списка, которого нет в списках, и найденный закрытый идут в closed_offline, работающая стоп-заявка идет в confirmed | заявки прошлых дней, если они не живы, в списке дня не видны: такой cid попадает в closed_offline, итог закрытой заявки читает order_state или executions. Номер, которого брокер не знает, не запрашивается |
| Bybit | один запрос списка рабочих заявок счета, cid каждой читается из orderLinkId. cid из вашего списка: жив (в том числе ожидающая стоп-заявка) - confirmed, иначе поиск по orderLinkId в списке и в истории: закрыта или не найдена - closed_offline. Чужие рабочие заявки - foreign | отмененную заявку без исполнений биржа держит в истории 24 часа, дальше она не находится, и reconcile относит ее к closed_offline («не видел сегодня»), как у остальных брокеров |
Список БКС ограничен: в нем только заявки с 26 января 2026 года и не больше 1000 последних (ограничение документации
БКС). Поэтому живая заявка может в нем не оказаться, и именно поэтому судьбу заявки из вашего списка решает GET, а список
нужен только для foreign. Окно поиска списка заканчивается на сутки позже локальных часов: часы машины могут отставать от
брокерских, и свежая заявка не должна выпасть.
Стоп-заявки
Обычная сверка стоп-заявки не показывает: ни в confirmed, ни в foreign. cid стоп-заявок в ваш список для
reconcile класть не надо: они попадут в closed_offline, потому что среди обычных живых заявок их нет.
Для стопов после старта вызывайте active_stop_orders() (см. Стоп-заявки). У Т-Банка после
рестарта cid в этом списке пустой, снимать стоп можно только по номеру брокера.
Что хранит бот, а что нет
| Что | Где хранится |
|---|---|
Журнал заявок: cid, состояние, номер брокера, исполнено | бот |
| Какие заявки считались живыми на момент остановки | бот (из журнала) |
| Номера брокера стоп-заявок Т-Банка | бот, если нужна отмена после рестарта |
Связь cid и номера брокера, состояние заявок текущего запуска | библиотека, в памяти, после остановки исчезает |
| Подписки | библиотека, в памяти. После рестарта бот подписывается заново |
Библиотека не ведет журнал на диске и не восстанавливает состояние сама. Она дает основу для этого: полный
упорядоченный поток событий (каждый переход состояния заявки - событие), cid в каждом событии заявки и сверку,
которая сравнивает список бота со счетом.
Когда звать reconcile, кроме старта (после Overflow, после долгого обрыва, при сомнении в журнале): Если что-то
пошло не так.
Листать можно клавишами Влево и Вправо.