Горячие клавиши

← и →: предыдущая и следующая глава

S или /: поиск по книге

?: показать эту подсказку

Esc: закрыть подсказку или поиск

Круг команд: заявки

Страница объясняет, что происходит с заявкой от вызова place до исполнения и что бот делает в каждом случае. Три правила, на которых держится вся библиотека, - в начале книги. Схема самого круга - на Карте, здесь ее подробности. Исходы и коды отказов собраны на странице Ошибки и что с ними делать, порядок действий после неясного исхода - на странице Если что-то пошло не так.

cid: номер заявки

cid (ClientOrderId) - это UUID, который бот создает сам: ClientOrderId::new_v4(). Библиотека номера не придумывает. Причины:

  • Библиотека ничего не помнит между запусками. Она не пишет на диск. После рестарта бот приносит список своих cid, и по нему заявки на счете делятся на свои и чужие (Круг жизни).
  • Номер нужен до отправки. Если ответа нет, cid - единственная нить, по которой можно спросить брокера: «что с заявкой, которую я пытался поставить?». Если бы номер выдавала библиотека, он потерялся бы вместе с ответом.
  • Случайный UUID не требует согласования. Столкновение практически невозможно, ни один процесс ни с кем не координируется.

Правила для бота:

  • Один cid - одна заявка. Новая попытка - новый cid. Старый cid библиотека повторно не отправит: вернет Rejected с кодом duplicate_place, в сеть ничего не уйдет. Это касается и тех попыток, которые закончились отказом брокера. Зачем так жестко: после таймаута первая заявка могла дойти. Что брокер сделает с повтором того же номера, зависит от брокера (LabelOnly, см. ниже), и надеяться, что он отбросит дубль, нельзя.
  • Единственное исключение: вызов отвергнут до отправки, когда cid еще не помечен использованным. Так бывает при NotReconciled, Degraded и ShuttingDown, при ошибке входа (missing_price, invalid_qty, идентификатор инструмента другого брокера) и при RateLimited. Заявки нет, cid годен, после устранения причины его можно использовать снова (у всех брокеров). Не уверены - берите новый cid, это всегда безопасно.
  • Перед вызовом place запишите cid в свой журнал (состояние «отправляю»). Иначе после падения процесса между отправкой и записью вы потеряете заявку, о которой не знаете.
  • Для изменения заявки (replace) нужен новый cid: старый указывается как original_cid.

Как cid уходит к брокеру

БрокерКуда кладемОбратимо
БКСполе clientOrderId, UUID с дефисами как естьда
Т-Банкполе order_id (это ключ идемпотентности, 36 символов), UUID с дефисами как есть. Брокер возвращает его же как order_request_id, по нему работают отмена и чтение статусада
Финамполе client_order_id вмещает всего 20 символов, UUID туда не помещается. Поэтому полный номер кладется в комментарий заявки в виде bc1: и 32 символа, а в client_order_id идет короткая метка из 20 символов, получаемая из UUID. Метка по себе номер не восстанавливаетда, если Финам возвращает комментарий без изменений
АЛОРполе comment заявки (до 100 символов), UUID с дефисами как есть, и тот же текст в заголовке X-REQID команды. Комментарий возвращается в списке заявок, в потоке и в сделкахда
Bybitполе orderLinkId (до 36 символов), UUID с дефисами как есть. Повтор того же orderLinkId биржа отвергает (код 110072), но как долго она помнит закрытую заявку, в документации не названода

У всех «защиту от повторной отправки той же заявки» библиотека пока считает отсутствующей (LabelOnly). Что делает брокер с повтором, измерят только живые прогоны. Это не меняет правило: повторять заявку нельзя.

Что происходит в place

Порядок проверок у всех брокеров один, и он показан на схеме круга: Карта. Сначала шлюз команд, потом проверка входа без сети, потом квота у счетчика лимитов, потом очередь по бумаге, потом шлюз еще раз, и только после этого cid помечается использованным и команда уходит к брокеру. Почему именно так:

  • Дешевые проверки первыми. Шлюз, вход и квота не требуют ни сети, ни ожидания. Отказ на любой из них ничего не тратит: ни cid, ни времени в очереди.
  • Квота до очереди. Отказ по квоте приходит сразу, а не после ожидания за другими командами по бумаге.
  • Шлюз проверяется второй раз после очереди. Команда могла простоять в очереди за другой командой по той же бумаге, и за это время шлюз закрылся: оборвался поток заявок или исполнений (Event::Reconnected), переполнился буфер (Overflow), началась остановка. Закрытый шлюз значит, что в сеть ничего не уходит, и это верно и для команды, которая уже стоит в очереди: она получает Degraded или ShuttingDown, а не уходит к брокеру. Для cancel и cancel_stop так же.
  • cid помечается как можно позже, прямо перед отправкой. Пока вызов стоит в очереди, номер чистый: если вызов отменили или он не дошел до отправки, номером можно воспользоваться снова. Пометка сразу после очереди и до сети: второй place с тем же cid увидит ее и не уйдет.
  • Отказ duplicate_place приходит уже после квоты и очереди, поэтому единица квоты на него потрачена, хотя в сеть ничего не ушло.

Все, что находится выше отправки, не доходит до брокера: можно не сомневаться, что заявки нет. На схеме ниже

  • тот же путь во времени: бот, UD-коннектор и брокер.
%%{init: {'sequence': {'width': 90, 'actorMargin': 40, 'mirrorActors': false}}}%%
sequenceDiagram
  participant Bot as Бот
  participant UD as UD-коннектор
  participant Br as Брокер
  Bot->>UD: place(cid, бумага, цена)
  UD->>UD: шлюз, квота, очередь по бумаге
  UD->>Br: заявка с cid
  alt принята
    Br-->>UD: номер брокера
    UD-->>Bot: OrderAck
  else отказ
    Br-->>UD: ошибка
    UD-->>Bot: Rejected
  else нет ответа
    UD-->>Bot: Unknown, без повтора
    Bot->>UD: order_state(cid)
  end
  Br-->>UD: статусы и исполнения
  UD-->>Bot: Event::Order, Event::Execution

Ответ на place и статусы приходят разными путями: ответ - результатом вызова, статусы и исполнения - событиями в общем канале. Порядок между результатом вызова и событием не гарантирован: событие может прийти и после ответа, и раньше него. Поэтому после OrderAck заявка еще не исполнена, это только «брокер принял». Ждать исполнения нужно по Event::Order и Event::Execution, а не по ответу.

Исходы команды

Любая команда (place, cancel, replace, стоп-команды) заканчивается одним из трех видов исхода, и они отвечают на главный вопрос: дошла ли команда до брокера.

  1. Не дошла. Закрыт шлюз (NotReconciled, Degraded, ShuttingDown), не прошла проверка входа или Unsupported, не хватило квоты (RateLimited), неверная настройка (Config). Заявки нет, делать с ней нечего, исправляется причина.
  2. Дошла и получила ответ. Ok(OrderAck) - брокер принял. Rejected - брокер точно отказал, заявки нет.
  3. Дошла, ответа нет. Unknown (и Transport на команде, если брокер мог ее получить). Заявка могла встать. Это единственный исход, где нельзя ни повторять, ни считать, что заявки нет.

Таблица «исход -> что значит -> почему бывает -> что делать» лежит в справочнике ошибок, там же коды Rejected. Особенности перевода ответов в исходы:

  • БКС. Отказ может прийти не ошибкой HTTP, а внутри обычного ответа 200 (статус заявки 8, rejectReason). Библиотека читает оба случая и возвращает Rejected.
  • Т-Банк. Брокер может принять запрос и ответить статусом «отклонено» внутри ответа. Это тоже Rejected (код REJECTED).
  • Проверка входа до отправки у всех одна. Количество: положительное, целое там, где брокер берет целые (БКС, Т-Банк), и кратное лоту, если лот известен. Иначе Rejected с кодом invalid_qty. Ничего не округляется: дробное количество там, где брокер берет целые, это отказ. Количество в единицах инструмента, не в лотах, у любого брокера (Деньги, цены и количества). Цену коннектор сверяет с шагом, если шаг ему передан (price_grid()): цена не на шаге - Rejected { price_off_step } до отправки, квота и cid не тратятся. Идентификатор инструмента другого брокера (например SymbolMic Финама у Т-Банка) это Config, без сети. Как округлить цену точно и как передать шаг - Деньги, цены и количества.

Как это выглядит в коде

Номер придумывает бот и записывает в журнал до вызова. Вкладка выбирает язык, выбор запоминается для всей книги.

let cid = request.cid; // made by the bot: ClientOrderId::new_v4()
journal.sending(cid)?; // on disk BEFORE the call
let result = ud.place(request).await;
cid = brokapi.new_cid()  # made by the bot
journal.sending(cid)  # on disk BEFORE the call
result = await client.place(cid=cid, instrument=instrument, side=brokapi.Side.BUY, qty=qty, price=price)

Исходы разбираются так, что «заявки точно нет» и «заявка могла встать» никогда не путаются. На Unknown и Transport повтора нет, есть вопрос брокеру по тому же cid:

let cid = request.cid;
match ud.place(request).await {
    Ok(_) | Err(Error::Rejected { .. }) => {}
    Err(Error::Unknown | Error::Transport(_)) => {
        // The order may exist. NEVER place again. Ask about THE SAME cid (see the recovery page for the loop).
        let _state = ud.order_state(cid).await;
    }
    Err(other) => return Err(other.into()),
}
cid = brokapi.new_cid()
state = None
try:
    await client.place(cid=cid, instrument=instrument, side=brokapi.Side.BUY, qty=qty, price=price)
except brokapi.Rejected:
    pass  # the broker said no: there is no order
except (brokapi.Unknown, brokapi.Transport):
    # The order may exist. NEVER place again. Ask about THE SAME cid (see the recovery page for the loop).
    state = await client.order_state(cid)

В Python это исключения brokapi.Rejected, brokapi.Unknown и brokapi.Transport из brokapi.errors: те же имена и тот же текст, что у Error в Rust. Если задачу, которая ждет place, отменить в Python, команда все равно доходит до конца: после этого тоже спрашивайте order_state.

Почему нет автоповтора

Заявка, которая ушла к брокеру и осталась без ответа, может находиться в трех состояниях: брокер ее не получил, получил и поставил, получил и отклонил. Из «нет ответа» этого не понять. Если отправить заявку еще раз, а первая все-таки встала, на счете окажутся две заявки. Для денег это худший исход, поэтому библиотека повторяет только то, что безопасно (чтение), и никогда не повторяет команды.

То же относится к тому, что библиотека не делает сама после ошибки: не пробует другую форму запроса, не меняет количество и цену, не ставит заявку заново.

У всех брокеров таймаут на place дает Unknown, второй вызов с тем же cid не уходит в сеть (duplicate_place), а заявка, которую брокер все-таки принял, находится через order_state.

Что делать вместо повтора: спросить брокера по cid, это описано в Если что-то пошло не так.

cancel

cancel(cid) снимает заявку по вашему cid. Исходы те же, что и у place. Особенности:

  • Ok(()) значит: брокер подтвердил отмену. У БКС команда синхронная, и «отменено» говорится только тогда, когда брокер сам ответил, что заявка отменена.
  • Заявка уже исполнилась: БКС вернет Rejected с кодом already_filled, остальные - отказ брокера.
  • Unknown: отмену можно повторить вручную после проверки order_state. Повторная отмена новых заявок не создает. Библиотека сама ее не повторяет.
  • У БКС, если после синхронной отмены заявка по ответу еще жива, результат тоже Unknown: «отменено» не объявляется без подтверждения. Так бывает со статусом 6 «Отменяется (в процессе отмены)»: заявка еще стоит в стакане и может исполниться раньше, чем отмена дойдет до конца. Такой статус приходит как состояние Unknown, не Cancelled. Cancelled будет, когда брокер пришлет статус 4, а если заявка успела исполниться, придет Filled.
  • Отмена идет через отдельное ведро лимитов, постановка ее не вытесняет (Лимиты запросов).

replace: изменить заявку

replace(ReplaceOrderRequest { cid, original_cid, qty, price }) меняет количество и (или) цену. cid - новый номер, original_cid - заявки, которую меняем. Количество и цена, которых вы не указали, берутся от исходной заявки (коннектор для этого один раз читает ее у брокера).

БрокерКакОсобенности
БКСPOST /orders/edit/sync: брокер снимает исходную заявку и ставит новую под новым cidУ частично исполненной заявки количество нужно указать явно (qty_required). Что означает количество при правке (весь объем или остаток), в документации не сказано: укажите количество явно
Т-БанкReplaceOrder. Новый cid становится ключом идемпотентностиБиржевой номер после замены может измениться, ориентируйтесь на cid
Финамнет. replace возвращает Unsupported("replace_order") сразу, без сетиВ API Финама такого метода нет. Отмену и новую заявку библиотека не подменяет: они не атомарны (старая может исполниться между шагами, новую могут отклонить после отмены). Решение за ботом
АЛОРPUT лимитной заявки по номеру: брокер снимает исходную и ставит новую под новым cid в комментарииРыночную заявку заменить нельзя (not_replaceable). У частично исполненной количество нужно указать явно (qty_required)
Bybitнет. replace возвращает Unsupported сразу, без сетиИзменение заявки у биржи (POST /v5/order/amend) оставляет заявку и ее orderLinkId, а replace обещает новую заявку под новым cid. Одно за другое не выдается: снимите заявку и поставьте новую, либо измените через raw() и помните, что cid прежний

Unknown на replace неприятнее, чем на place: непонятно, что с обеими заявками. Спрашивайте order_state по обоим номерам, и по исходному, и по новому.

Машина состояний заявки

Состояние приходит в Event::Order (поле state в OrderSnapshot) и в ответе order_state. На схеме: состояния заявки и переходы между ними. Каждый переход - событие Event::Order, и в каждом есть cid.

stateDiagram-v2
  [*] --> InFlight: place ушел к брокеру
  InFlight --> Acked: брокер принял
  InFlight --> Конец: отказ, Rejected
  InFlight --> Unknown: таймаут или обрыв
  Unknown --> Acked: order_state или сверка
  Unknown --> Конец: выяснилось
  Acked --> PartiallyFilled: часть исполнена
  PartiallyFilled --> PartiallyFilled: еще исполнение
  Acked --> Конец
  PartiallyFilled --> Конец
  state "Конец: Filled,<br>Cancelled, Rejected" as Конец
  Конец --> [*]

Из Unknown библиотека сама не выходит: только запрос состояния или сверка. Это сделано намеренно: любое «само выяснилось» было бы догадкой, а на догадке нельзя ставить вторую заявку. Из конечных состояний выхода нет, поэтому событие о конце можно считать окончательным.

СостояниеЗначение
InFlightкоманда отправлена, ответа еще нет. Внутреннее состояние
Unknownтаймаут или обрыв, а у БКС еще статус 6 «отменяется»: заявка может быть жива. Выход только через запрос состояния или сверку. Библиотека сама из него не выходит
Ackedброкер принял, заявка стоит в стакане. Есть номер брокера
PartiallyFilledисполнена часть: известно, сколько исполнено (filled) и сколько осталось (left)
Filledисполнена целиком. Конечное
Cancelledснята (по вашей отмене, по замене, по сроку). Конечное
Rejectedотклонена, с причиной. Конечное

Статусы брокеров переводятся в эти состояния. Статус, который библиотека не знает, у Финама считается рабочей заявкой, а неопределенный статус Т-Банка переводится в Unknown.

Порядок команд по бумаге

Команды по одному инструменту выполняются строго по очереди, в порядке вызова: отмена, отправленная после постановки, не обгонит ее. Если бы обгоняла, отмена пришла бы к брокеру раньше заявки, ответила бы «такой нет», а потом заявка встала бы и осталась на счете, хотя бот считает ее снятой. Реализовано блокировкой на инструмент (InstrumentLocks). Особенности:

  • Ключ блокировки - symbol@mic, а не весь идентификатор. Один инструмент, названный тикером, uid или figi, - одна очередь.
  • Блокировка снимается при любом исходе: отказ, ошибка сети, отмена вызова.
  • Отказ брокера бумагу не блокирует. Это проверяется отдельным сценарием (в БКС однажды ошибка оставляла бумагу занятой до перезапуска).
  • Для cancel бумага берется из заявок, которые поставил этот процесс. Если заявка осталась с прошлого запуска и бумага процессу неизвестна, cancel в очередь не встает.
  • Команды по разным бумагам идут параллельно.

Сколько ждать ответа

Время ожидания ответа на команду задается в настройке клиента брокера: у Т-Банка и БКС 30 секунд по умолчанию, у Финама 10 секунд. По истечении ответ Unknown. Пока команда ждет, она занимает очередь по бумаге, поэтому следующие команды по той же бумаге ждут за ней. Слишком короткий таймаут плодит Unknown на заявках, которые брокер успел бы принять, слишком длинный держит очередь по бумаге.

Что бот хранит

Минимум для работы с заявками: журнал cid (что отправлено, что подтверждено, что закрыто), пометка «проверить» у заявок с неясным исходом и номер брокера (broker_id), если он нужен для отмены стопов у Т-Банка (Стоп-заявки). Что хранит бот, а что библиотека: Круг жизни.

Листать можно клавишами Влево и Вправо.

brokapi by RunInfra