Жизненный цикл ордера — как всё связано
Зачем отдельный раздел после matching engine
В разделе 4 мы препарировали самый интересный кусок биржи — стакан и matching engine. Но matching engine живёт не в вакууме. До него ордер проходит половину всей инфраструктуры, после него — вторую половину. И если раздел 4 отвечал на вопрос «как быстро и честно исполнить сделку», то раздел 5 отвечает на вопрос, который инженер услышит в продакшене в три часа ночи: «почему этот конкретный ордер застрял и где именно».
Цель этого раздела — взять один ордер и провести его от нажатия кнопки в мобильном приложении до execution report, который получит клиент в ответ. По пути мы заглянем в каждый сервис, посмотрим, что он делает, какие причины для отказа у него есть и какие следы он оставляет в логах.
Карта пути — девять остановок
Сначала — общая картина. Ордер проходит примерно девять логических стадий. Они могут физически находиться в одном процессе или в девяти разных сервисах — это деталь реализации. Но логически они всегда именно такие:
- Клиент формирует запрос и подписывает его
- Gateway (REST или WebSocket) принимает HTTP/WS-запрос
- Auth-слой проверяет подпись и ключ API
- Rate limit списывает токены с бюджета клиента
- Pre-trade risk валидирует параметры и ограничения
- Ledger резервирует баланс (locks)
- Matching engine исполняет ордер по стакану
- Ledger фиксирует сделки (settle)
- Market data fanout и user notification доставляют события наружу
На хорошей бирже всё это укладывается в 10–50 миллисекунд от первого байта запроса до первого байта ответа. Если где-то по пути что-то ломается, клиент обычно видит либо ошибку, либо тишину — и второй вариант хуже первого.
Здесь важно не перепутать две разные величины. Сам матчинг в разделе 4 мы мерили десятками микросекунд — и это правда, ядро matching engine действительно обрабатывает один ордер за ~50–100 μs. Но 10–50 миллисекунд, о которых идёт речь сейчас, — это wall-clock round-trip для обычного клиента с телефоном где-нибудь за океаном. Основная часть этого бюджета уходит не на матчинг, а на публичный интернет, TLS-рукопожатие, запись резерва в леджер и обратную сериализацию ответа. Матчинг в этом бюджете — округление.
Клиенты, которым эти миллисекунды дороги, — маркет-мейкеры и HFT-фирмы — решают задачу радикально: арендуют стойку в том же датацентре, где крутится matching engine (colocation), подключаются напрямую через кросс-коннект по оптике, работают через FIX поверх TCP вместо HTTP/JSON, используют сетевые карты с kernel bypass (Solarflare/Onload, DPDK) и держат сессии пре-аутентифицированными. Для такого клиента ACK приходит за доли миллисекунды, и инженерный бюджет биржи считается уже в микросекундах, как и подобает. Число «10–50 мс» — это то, что видит телефон у вас в кармане; реальный внутренний бюджет биржи гораздо жёстче.
- •подпись HMAC по телу запроса
- •timestamp + recvWindow для защиты от replay
- •TCP-соединение к edge endpoint
От «нажал кнопку» до «пришло подтверждение» на хорошей бирже проходит порядка 10–50 миллисекунд. За это время ордер пролетает через десяток сервисов, каждый из которых может отказать и каждый из которых оставляет след в логах и телеметрии. Именно поэтому в разделе 9 так много внимания уделяется observability: без трассировки этой цепочки восстановить, что пошло не так, практически невозможно.
Полистайте этот трейс целиком хотя бы один раз. Обратите внимание: на каждом шаге есть payload (что именно передаётся дальше) и effect (что происходит в этом сервисе). Это почти скелет распределённой трассировки — на реальной бирже оно так и хранится в каком-нибудь Jaeger или Tempo.
Gateway и auth — тонкий слой на краю
Первое, что встречает запрос — gateway. Его единственная задача — как можно быстрее принять или отвергнуть. Ничего «умного» в gateway быть не должно: никаких обращений к базе данных, никаких вызовов risk-движка, никакой бизнес-логики. Только парсинг заголовков, терминация TLS, роутинг и трассировка.
Сразу за ним — auth. На большинстве бирж аутентификация работает одним из двух способов:
- HMAC-подпись запроса (Binance, Bybit, Kraken REST) — клиент подписывает тело запроса секретным ключом, биржа проверяет подпись у себя. Ключ никогда не летит по сети.
- Токен сессии (веб-интерфейс) — обычная сессия после OAuth-логина, живёт в cookie.
Для программного трейдинга всегда HMAC. Алгоритм очень важен в деталях:
- В подпись попадает timestamp. Биржа отвергает запросы, старше
recvWindow(обычно 5000 мс) — это защита от replay-атак. - В подпись попадает тело запроса целиком. Если кто-то перехватит запрос и поменяет цену, подпись уже не сойдётся.
- Ключи имеют scope: только чтение, спот-торговля, вывод. Если у ключа есть право «торговать», но нет права «выводить», он ничего не сможет вывести даже при утечке.
Это не «защита сервера от плохих клиентов». Это защита самих клиентов от того, что кто-то украдёт их ключ и попробует им воспользоваться. HMAC — инструмент доверия между сторонами, а не обычный auth.
Rate limit — бюджет, а не блокировка
Дальше — rate limit. На биржах это не «один запрос в секунду» и не «IP бан». Это бюджет в токенах, модель token bucket:
- У клиента есть bucket, в который капают N токенов в минуту (например, 1200)
- Каждый запрос стоит сколько-то токенов:
GET /api/v3/ping— 1 токен,POST /api/v3/order— 1 токен,POST /api/v3/order/test— 0 токенов, массовое снятие всех ордеров — 50 токенов - Пока в bucket есть токены, запросы проходят; как только bucket пуст — 429
TOO_MANY_REQUESTS
Модель именно такая, потому что она терпима к всплескам. Нормальный маркет-мейкер работает плавно, но в момент резкого движения рынка хочет быстро снять и поставить много ордеров. Token bucket это позволяет: он копит токены в спокойные секунды и тратит их в шумные.
Важная деталь: rate limit почти всегда по ключу API, а не по IP. У одного клиента может быть несколько ключей, и у каждого свой bucket. У одного IP могут работать несколько клиентов (например, колокация у брокера). Делать rate limit по IP — путь в минное поле, это даже в документациях специально подчёркнуто.
Pre-trade risk — шесть проверок в нужном порядке
Самая плотная часть пути — pre-trade risk. Это не одна проверка, а последовательность независимых проверок, каждая из которых может отказать с собственным кодом. Задача этого слоя — не пустить к matching engine ничего, что матчиться не должно.
Обратите внимание: проверки идут от дешёвых к дорогим. Авторизация и rate limit работают на edge-слое без обращения к базе; ценовой коридор смотрит в кэш index price; баланс — последняя и самая тяжёлая проверка, она требует залочить средства в леджере. Переставлять их местами не стоит: хороший reject-путь — это дешёвый reject-путь.
Про порядок проверок стоит сказать отдельно. Он не случайный. Проверки идут от дешёвых к дорогим:
- Авторизация — кэш ключей в памяти, почти бесплатно
- Rate limit — обновление счётчика в Redis или локальной памяти
- Валидация схемы и tick/lot size — чтение конфига символа, тоже быстро
- Статус инструмента — проверка флага в кэше
- Ценовой коридор — сравнение с index price, одна математическая операция
- Баланс и залочивание средств — запись в леджер, самая дорогая операция из всех
Если первой ставить проверку баланса, то биржа будет тратить дорогой вызов леджера на мусорные запросы, которые не прошли бы даже базовую валидацию. Это классическая ошибка архитектора, который ещё не работал в high-throughput системах. На биржах это быстро становится очевидно: порядок имеет значение.
Про один из этих чеков — ценовой коридор — стоит сказать подробнее. Price band защищает от fat-finger ошибок. Если клиент случайно ставит лимитную покупку биткоина по 50 USDT при рыночной цене 50 000, биржа отвергает её не потому что «эта цена плохая», а потому что разница от index price слишком велика. На Binance такая проверка есть на каждом символе и называется PERCENT_PRICE_BY_SIDE. Это не сервис клиенту — это защита самой биржи от того, что через неё пройдёт нелепая сделка, которую потом придётся отменять вручную и объясняться в твиттере.
Залочивание баланса в леджере
Шестая проверка особенная. Если все предыдущие проверки — это «можно ли вообще этот ордер принимать», то эта — «есть ли у клиента чем за него заплатить». И она изменяет состояние системы, в отличие от всех предыдущих.
Механика простая: в леджере у клиента есть два числа по каждой валюте — available (доступно) и locked (залочено). Когда клиент ставит лимитный ордер на покупку 1 BTC по 50 115 USDT, биржа:
- списывает 50 115 USDT с
available - прибавляет 50 115 USDT к
locked - записывает, что эти 50 115 залочены именно под этот
orderId
Эти 50 115 клиент больше не может потратить на что-то другое, пока ордер висит в стакане. Если ордер исполнится — они превратятся в BTC. Если клиент его отменит — они вернутся в available. Если часть исполнится, часть останется — сколько надо, столько и вернётся.
Вся эта механика подробно разбирается в разделе 6 про леджер. Пока достаточно понимать: резерв — это отдельная транзакция, и если она не прошла (не хватило баланса), ордер получает reject с кодом INSUFFICIENT_BALANCE и дальше не идёт.
Matching — и что происходит сразу после
Матчинг мы уже разобрали в разделе 4 во всех подробностях. В контексте жизненного цикла важно одно: matching engine возвращает не «один ответ», а последовательность событий:
- ноль или больше
Tradeсобытий (по одному на каждое пересечение) - ровно одно событие
OrderUpdateс итоговым статусом ордера:ACCEPTED,PARTIALLY_FILLED,FILLEDилиREJECTED
Эта последовательность — источник правды для всего, что происходит дальше. Ledger, market data, notification — все они подписаны на поток событий от matching engine и реагируют на них.
Конечный автомат ордера выглядит так:
Три состояния терминальные: REJECTED, FILLED, CANCELLED. Из них ордер больше никуда не уходит — он просто живёт в истории. Всё остальное — это переходные состояния, в которых он проводит максимум несколько секунд (а то и микросекунд).
Три состояния терминальные (REJECTED, FILLED, CANCELLED) — из них ордер больше никуда не уходит. Всё остальное — переходные, и в них ордер проводит микросекунды или секунды. Полная история состояний ордера остаётся в журнале событий — это важно для аудита, расследований и просто ответов на вопросы «а что случилось с моим ордером вчера в 14:32».
Вот как этот автомат выглядит в коде. Обратите внимание: переход — это не просто смена поля state, это отдельное событие, которое уходит в несколько подписчиков.
public enum OrderState {
NEW,
REJECTED,
ACCEPTED,
PARTIALLY_FILLED,
FILLED,
CANCELLED;
public boolean isTerminal() {
return this == REJECTED || this == FILLED || this == CANCELLED;
}
}
public final class Order {
private final String orderId;
private final long totalQty;
private long remainingQty;
private OrderState state = OrderState.NEW;
public void accept() {
require(state == OrderState.NEW, "can only accept from NEW");
state = OrderState.ACCEPTED;
}
public void reject(String reason) {
require(state == OrderState.NEW, "can only reject from NEW");
state = OrderState.REJECTED;
}
public void onFill(long filledQty) {
require(state == OrderState.ACCEPTED || state == OrderState.PARTIALLY_FILLED,
"fills allowed only after ACCEPTED");
remainingQty -= filledQty;
state = (remainingQty == 0) ? OrderState.FILLED : OrderState.PARTIALLY_FILLED;
}
public void cancel() {
require(state == OrderState.ACCEPTED || state == OrderState.PARTIALLY_FILLED,
"only live orders can be cancelled");
state = OrderState.CANCELLED;
}
private static void require(boolean cond, String msg) {
if (!cond) throw new IllegalStateException(msg);
}
}
require-проверки — не паранойя. Это защита от багов в вызывающем коде: если случайно придёт onFill на уже CANCELLED ордер, лучше упасть на месте и понять причину, чем тихо записать «исполнение», которого на самом деле не было.
placeOrder() — весь путь на одной странице
Теперь соберём всё, что мы разобрали, в один псевдокод. Это очень упрощённая версия placeOrder, но скелет именно такой на любой реальной бирже:
public PlaceOrderResult placeOrder(PlaceOrderRequest req, RequestContext ctx) {
// 1. auth — уже сделан на gateway-слое, тут только проверяем scope ключа
if (!ctx.apiKey.canTrade(req.symbol)) {
return PlaceOrderResult.reject("FORBIDDEN", "key has no trade scope");
}
// 2. rate limit
if (!rateLimiter.tryConsume(ctx.apiKey.id, req.cost())) {
return PlaceOrderResult.reject("TOO_MANY_REQUESTS", "rate limit exceeded");
}
// 3. схема запроса и характеристики символа
SymbolInfo sym = symbolRegistry.get(req.symbol);
if (sym == null) return PlaceOrderResult.reject("INVALID_PARAMETER", "unknown symbol");
if (sym.status != TRADING) return PlaceOrderResult.reject("SYMBOL_HALTED", sym.status.name());
if (!sym.tickOk(req.price)) return PlaceOrderResult.reject("INVALID_PARAMETER", "price tick size");
if (!sym.lotOk(req.qty)) return PlaceOrderResult.reject("INVALID_PARAMETER", "qty lot size");
// 4. ценовой коридор
long index = markPriceCache.get(sym);
if (!priceBand.allows(req.price, index, sym.maxDeviationBps())) {
return PlaceOrderResult.reject("PRICE_OUT_OF_BAND", "price too far from index");
}
// 5. балансовая бронь
long reserve = computeReserve(req, sym);
LockResult lock = ledger.lock(ctx.userId, sym.quoteFor(req.side), reserve, req.clientOrderId);
if (!lock.ok()) {
return PlaceOrderResult.reject("INSUFFICIENT_BALANCE", lock.reason());
}
// 6. отправка в matching engine
Order order = Order.create(req, ctx.userId);
order.accept();
eventBus.publish(new OrderAccepted(order));
matchingEngine.submit(order);
return PlaceOrderResult.accepted(order.getOrderId());
}
Ни одна проверка не «дублируется». Каждая делает ровно то, что не могла сделать предыдущая, и делает это за минимальные ресурсы. Если выкинуть хоть одну — рано или поздно упадёт продакшен и придётся её возвращать.
Важный момент, который теряется в псевдокоде: matchingEngine.submit(order) — асинхронный. Это означает, что функция placeOrder возвращает клиенту «принято, orderId такой-то» до того, как ордер действительно исполнился. Клиент узнает результаты сделок отдельными execution reports через WS user-data stream. Именно поэтому в ответе на placeOrder возвращается статус ACCEPTED (приняли, кладём в очередь), а не сразу FILLED или PARTIALLY_FILLED.
Cancel, amend и почему это непросто
Отмена ордера кажется тривиальной: «удалить из стакана, разлочить средства». Но в продакшене у этого простого действия есть тонкий нюанс — не-атомарность с операцией place.
Классический сценарий: клиент хочет «подвинуть» лимитку на пару тиков выше. Наивная стратегия — отменить старую и поставить новую. Проблема в том, что между отменой и новой заявкой успевает прилететь встречный taker, и старая лимитка исполняется ровно перед отменой. Клиент думает, что у него новый ордер, а на самом деле у него старая сделка плюс новый ордер.
Решение у индустрии общее: специальная атомарная операция cancelReplace (у Binance именно так и называется) — одна транзакция внутри matching engine, которая либо атомарно отменяет старый и ставит новый, либо не делает ничего. У Binance это отдельный endpoint POST /api/v3/order/cancelReplace в Spot API (developers.binance.com/docs/binance-spot-api-docs). Там же документированы два режима политики: STOP_ON_FAILURE (если отмена не прошла, новый ордер не ставится) и ALLOW_FAILURE (новый ставится независимо от результата отмены).
Для клиентов, которые много «двигают» лимитки (маркет-мейкеры), это не просто удобство, а основной способ взаимодействия с биржей. Обычный цикл cancel + place — медленнее, тратит два токена rate limit вместо одного и оставляет race window. Специальный cancelReplace решает все три проблемы.
Partial fills — «ничего не теряется и ничего не дублируется»
Когда ордер исполняется не целиком, происходит сразу несколько вещей, и их согласованность — не бесплатно:
- Matching engine эмитит
Tradeevents — по одному на каждое пересечение с встречной заявкой - Ledger подписан на этот поток и для каждого trade обновляет
lockedи позиции обеих сторон - Market data fanout публикует те же trade events наружу как публичный поток сделок
- User notification service шлёт execution reports обоим участникам (maker и taker)
И всё это — один и тот же поток, на который подписано несколько разных получателей. Это не шина сообщений для красоты — это фундамент надёжности. Если ledger упадёт, а matching engine уедет вперёд, нужно иметь возможность «перечитать» события с какой-то позиции и довосстановить состояние. Именно поэтому поток обычно строят как append-only журнал с монотонно возрастающими sequenceNumber-ами.
Это тот самый паттерн, который мы уже видели в разделе 4 под названием LMAX: один писатель, много читателей, перезапуск возможен в любой момент. В разделе 6 про леджер мы увидим, как именно ledger-слой обрабатывает поток событий, но сейчас важно зафиксировать одно: partial fill — это не одно событие, это маленькая серия событий, которые должны все дойти до всех подписчиков, иначе состояние системы разъедется.
Failure modes — что если ledger упал, а matching уже успел
Теперь самый неприятный вопрос. Что если matching engine успел исполнить сделку и записать её в журнал, а ledger в этот момент упал и не успел обновить балансы? С точки зрения «публичной реальности» сделка уже состоялась: она ушла в market data fanout, её увидели клиенты, её увидели графики. Отменить её нельзя. Значит, нужно довосстановить баланс, когда ledger поднимется обратно.
Именно для этого весь поток событий от matching engine — append-only журнал, из которого можно перечитать с любой позиции. Когда ledger перезапустится, он спросит: «на какой sequence я остановился?» — и продолжит с того места. Все пропущенные события он обработает в правильном порядке, и итоговое состояние будет таким же, как если бы падения не было.
Этот паттерн называется event sourcing и вместе с LMAX-подходом из раздела 4 образует архитектурный фундамент всей биржи. Он означает, что любое состояние в системе — это функция (journal, start_state). Любой сервис можно убить, перезапустить, заменить новой версией — пока журнал на месте, всё восстанавливается. Подробный разбор того, как event sourcing ложится на учётное ядро — с проекциями, снапшотами и реплеем журнала, — ждёт нас в разделе про леджер.
Другой интересный failure mode: notification succeeded, everything else failed. Клиент получил execution report «FILLED», но леджер ошибся и не зафиксировал позицию. Это катастрофа: клиент верит, что у него есть BTC, а у биржи нет записи об этом. Именно поэтому в хорошо сделанных системах порядок обновлений жёсткий: сначала ledger, потом notification. Если клиенту пришёл execution report, он гарантированно отражает уже зафиксированное состояние.
У Coinbase этот принцип даже формализован через sequence numbers в их Advanced Trade WebSocket API: каждое сообщение в потоке имеет монотонно возрастающий sequence_num, клиент может проверить, что он не пропустил ни одного события, и если пропустил — инициировать снапшот и наверстать. Это как раз тот инструмент, которым клиент защищается от редких, но катастрофичексих багов на стороне биржи. См. раздел про каналы и sequencing в документации Advanced Trade: docs.cdp.coinbase.com/advanced-trade.
Что вы теперь знаете
Если раздел 4 научил вас устройству сердца биржи, то раздел 5 — устройству её кровеносной системы. Вы понимаете:
- Почему на биржах 9 шагов, а не 1 — каждый шаг делает отдельное действие, и порядок важен
- Почему gateway и auth — тонкие слои — они должны быть максимально быстрыми, любая логика в них дорога
- Почему rate limit — бюджет, а не ban — лучше пропустить всплеск, чем жёстко резать средний поток запросов
- Почему pre-trade risk — лестница, а не параллель — дешёвые проверки первыми, дорогие последними
- Почему cancel+new place не равно cancelReplace — атомарность спасает от race conditions, которые клиент сам никогда не увидит
- Почему partial fill — это серия событий, а не одно — согласованность распределённой системы строится на журнале, а не на мгновенности
- Почему failure mode важнее happy path — биржа существует, пока её восстанавливаемая часть больше её невосстанавливаемой
В следующем разделе мы спустимся на уровень леджера — той самой системы, вокруг которой вертелся весь жизненный цикл ордера. Разберём, почему биржа в самой своей сути — это двойная запись, какие инварианты обязаны держаться в любой момент, почему любое «удаление» или «исправление» строчки запрещено, и как из этой модели выводятся все failure modes, которые мы только что обсудили. А уже в седьмом разделе мы выйдем наружу и посмотрим на стык с блокчейном — кастоди, горячие и холодные кошельки, HSM и MPC, и почему именно эта часть исторически была местом самых громких катастроф от Mt. Gox до FTX.
Дополнительное чтение
- Binance Spot API — основная документация, endpoint reference для place / cancel / cancelReplace: developers.binance.com/docs/binance-spot-api-docs
- Coinbase Advanced Trade API — документация WebSocket user channels и sequence numbering: docs.cdp.coinbase.com/advanced-trade
- Kraken REST API reference — альтернативная модель rate limit и схема endpoint'ов: docs.kraken.com/rest
- Bybit V5 API — унифицированный интерфейс спот и деривативов, хороший пример современного API дизайна: bybit-exchange.github.io/docs/v5/intro
- Martin Fowler, «Event Sourcing» — архитектурный паттерн, лежащий в основе append-only журнала биржи: martinfowler.com/eaaDev/EventSourcing.html