Стакан и matching engine
Стакан и matching engine — сердце биржи
В предыдущем разделе мы построили карту биржи: gateway, auth, risk, матчинг, леджер, кошельки. Каждый блок на ней важен. Но один — matching engine — определяет, есть у биржи рынок или нет. Стоит ему тормозить — маркет-мейкеры уходят. Стоит ему быть нечестным — уходят все остальные. Стоит ему сломаться — биржа умирает.
Представьте: трейдер нажимает «купить» в мобильном приложении. Через миллисекунду заявка превращается в сделку, а ещё через миллисекунду об этом узнают десятки тысяч клиентов по всему миру. Между нажатием и сделкой — несколько сотен микросекунд работы одной конкретной системы. Именно её мы сейчас разберём: сначала структуру данных, потом алгоритм, потом инженерные решения, которые за всем этим стоят.
Два вида заявок — и почему это важно
Прежде чем лезть в структуру стакана, разберёмся с двумя фундаментальными типами заявок, потому что весь matching engine построен вокруг разницы между ними.
Лимитная заявка (limit order) — это инструкция бирже: «купи мне 0.5 BTC, но не дороже $50 100» или «продай мои 2 ETH, но не дешевле $3 200». Трейдер фиксирует максимально допустимую цену (для покупки) или минимально допустимую (для продажи). Если рынок прямо сейчас не предлагает подходящую цену — заявка встаёт в очередь и ждёт. Может ждать секунды, может — часы, а может так и не дождаться. Зато трейдер точно знает: сделка либо состоится по приемлемой цене, либо не состоится вовсе.
Рыночная заявка (market order) — прямая противоположность: «купи мне 0.5 BTC прямо сейчас, по любой доступной цене». Никакого лимита. Движок берёт лучшее из того, что есть в стакане, и исполняет немедленно. Быстро — да. Но за скорость приходится платить: если в стакане мало предложений, заявка «проедет» несколько уровней цены вверх, и средняя цена окажется хуже ожидаемой. Это называется проскальзывание (slippage), и мы увидим его в действии чуть ниже.
Почему эта разница — фундаментальная? Потому что лимитные заявки формируют стакан: они стоят в очереди и ждут встречного интереса. Рыночные заявки потребляют стакан: они приходят и немедленно съедают то, что в нём накопилось. Matching engine — это механизм, который сталкивает одних с другими. И структура данных, которую он для этого использует, — это как раз стакан.
Стакан — это не просто список заявок
Limit order book (в трейдерском обиходе — просто «стакан») хранит все неисполненные лимитные заявки по одному торговому инструменту. Для пары BTC/USDT это значит: все желающие купить биткоин за доллары с одной стороны и все желающие продать — с другой.
У этих двух сторон есть стандартные имена, которые встречаются в любом документе про торговлю, и с ними стоит разобраться сразу. Bid (от английского to bid — «предлагать цену на аукционе») — это заявки на покупку: покупатели показывают, сколько они готовы заплатить за актив. Ask (буквально «просить», «запрашивать») — это заявки на продажу: продавцы указывают, за сколько они готовы отдать актив. Логика простая: покупатель предлагает, продавец просит. В русскоязычной среде обычно говорят просто «бид» и «аск», реже — «покупка» и «продажа»; во всех случаях имеются в виду одни и те же две стороны стакана.
Каждая заявка описывается четырьмя параметрами: сторона (bid или ask), цена, количество, время поступления. В стакане в любой момент могут лежать десятки или сотни тысяч таких заявок, и их состав меняется сотни раз в секунду.
Но отдельные заявки — это не то, что видит трейдер. Заявки на одну и ту же цену естественно группируются: всё, что стоит по 50 100, лежит в одной пачке; всё, что по 50 105, — в другой. Такая пачка называется уровнем цены (price level). У уровня есть главный агрегированный показатель — суммарное количество на этом уровне: если на 50 100 стоят три заявки по 0.5, 1.0 и 0.8 BTC, уровень «показывает» 2.3 BTC. Именно это число биржа публикует наружу как глубину (depth) на уровне. Кто конкретно стоит за этими 2.3 BTC — три участника или тридцать, маркет-мейкер или розничный трейдер, — публично не видно. Клиент видит агрегат: цена → суммарное количество.
Уровень — это удобная единица мышления не только для клиентов. Matching engine внутри тоже оперирует именно уровнями: он ищет лучший уровень, съедает его сверху вниз, переходит на следующий. «Лучший bid» и «лучший ask», с которых начинается весь алгоритм, — это не отдельные заявки, а крайние уровни: самый высокий из бидовых и самый низкий из асковых.
Из этого наблюдения сразу следует, что структура данных должна поддерживать три операции одинаково быстро:
- Найти лучшую цену — лучший bid (самый высокий) и лучший ask (самый низкий).
- Добавить новую заявку в правильное место по цене, а внутри цены — в конец очереди.
- Удалить или уменьшить существующую заявку при отмене или частичном исполнении.
Вы могли подумать: почему бы просто не использовать HashMap<Double, List<Order>>? Потому что нам нужен не только доступ по ключу, но и упорядоченный обход. Лучший ask — это не произвольная цена, а минимум из всех. И минимум нужно находить за константу, а не за O(n).
Инженерно это решается парой отсортированных структур: одна для бидов (в обратном порядке — от высокой цены к низкой), вторая для асков (в прямом). На каждом уровне цены — обычная FIFO-очередь заявок в порядке поступления. В продакшене это чаще всего skip list, сбалансированное дерево или массив уровней при плотной сетке цен. Выбор зависит от профиля нагрузки, но интерфейс у всех один.
Живой стакан
Хватит абстракций. Вот реальный стакан, с которым можно поиграть: кнопки добавляют лимитные заявки с обеих сторон и запускают рыночные сделки. Обратите внимание на три числа, которые биржа публикует клиентам каждую миллисекунду — best bid, best ask и разницу между ними, spread. Именно это обычно показывает клиентское приложение как «цену биткоина».
Поиграйте минуту. Несколько вещей, которые стоит заметить:
- Спред — это валюта биржи. Маркет-мейкер зарабатывает тем, что покупает по бид и продаёт по аск. Чем у́же спред, тем хуже ему и тем лучше обычному трейдеру. Одна из задач биржи — создать условия, в которых маркет-мейкерам выгодно держать узкий спред.
- Глубина важнее последней цены. Можно «нарисовать» любую последнюю цену одной маленькой сделкой, но нельзя «нарисовать» миллион долларов глубины — за ним должны стоять реальные деньги реальных участников. Когда кто-то говорит «у этой монеты нет ликвидности», он имеет в виду именно тонкий стакан.
- Market-заявка съедает уровни сверху вниз. Покупка на 0.8 BTC не исполняется «по цене X» — она исполняется по best ask, пока тот не кончится, потом по следующему уровню, пока не наберётся объём. На тонком рынке это превращается в проскальзывание — и именно оно станет одной из причин для предтрейд-рисков в разделе 5.
Сделки и исполнения — что происходит, когда заявки встречаются
Когда входящая заявка сталкивается со встречной в стакане, происходит то, ради чего вся эта система существует. У этого события есть два названия, и различие между ними — не педантизм, а важный технический факт, который потом всплывает в леджере, в API и в статистике трейдера.
Сделка (trade, в трейдерском обиходе — «трейд») — это публичная запись о том, что один конкретный объём по одной конкретной цене перешёл от одной стороны к другой: «0.5 BTC по 50 100 USDT, время такое-то». Именно сделки образуют ленту трейдов, которую клиенты видят на экране; именно из них биржа считает last trade price, объёмы за период, свечи на графике. Трейд — это то, что рынок знает публично.
Исполнение (execution, fill) — то же самое событие, но с точки зрения одной стороны заявки. Один трейд всегда порождает два исполнения: одно для maker'а, одно для taker'а. Это приватные факты — они принадлежат конкретному клиенту, попадают в его историю, в его баланс в леджере, в расчёт комиссии. Когда трейдер видит в своём приложении строчку «исполнилось: 0.5 BTC по 50 100, комиссия 0.02 USDT» — это его fill, а не публичный трейд.
И важное следствие, которое понадобится нам буквально через пару абзацев: одна заявка может порождать несколько исполнений. Market buy на 1.0 BTC, которая съедает три уровня стакана сверху вниз, — это три исполнения, три трейда, три разных встречных мейкера. Клиент увидит это как «частичное исполнение: 0.3 по 50 100, 0.4 по 50 105, 0.3 по 50 110, средняя цена 50 105». Пока остаток заявки больше нуля, её статус — PARTIALLY_FILLED; когда остаток уходит в ноль, статус меняется на FILLED. Жизненный цикл заявки в разделе 5 целиком построен вокруг этого различия между заявкой, её исполнениями и порождёнными сделками.
Приоритет исполнения — «кто первый встал, того и сделка»
Биржа должна ответить на вопрос: если на одном уровне цены стоят две заявки, какая из них исполнится первой? Стандартный ответ индустрии — price-time priority (приоритет сначала по цене, потом по времени). Сначала лучшая цена. Внутри цены — кто раньше пришёл, тот раньше и исполнится.
Price-time priority — правило простое: сначала лучшая цена, потом время поступления. Никаких «наших» и «привилегированных» клиентов, никакой произвольной сортировки. Для биржи это не опциональная вежливость, а контракт с рынком.
Это не правило приличия, а контракт с рынком. Маркет-мейкеры строят свои стратегии вокруг этого правила: если биржа вдруг решит, что заявки «своих» клиентов имеют приоритет, маркет-мейкеры уйдут к конкуренту в тот же день. Нарушение price-time priority — одна из худших вещей, которые matching engine может сделать.
Есть ещё pro rata-приоритет (пропорциональное распределение на уровне), но на крипторынках его используют редко. По умолчанию — price-time.
Алгоритм матчинга — шаг за шагом
Теперь — сам алгоритм. Приходит лимитная заявка на покупку: LIMIT BUY 1.0 BTC @ 50 115. Что с ней делает движок?
- Смотрит на противоположную сторону стакана — на ask.
- Берёт лучший (минимальный) ask. Если его цена меньше или равна 50 115 — сводим. Если больше — останавливаемся.
- Списываем со встречной заявки столько, сколько можно:
min(остаток входящей, остаток встречной). - Порождаем событие Trade: цена = цена встречной заявки (maker), количество = списанное, две стороны — maker и taker.
- Если встречная заявка полностью исполнена — удаляем из очереди. Если частично — уменьшаем остаток.
- Если у входящей заявки остался объём — возвращаемся к пункту 2.
- Если остаток ушёл в ноль — заявка исполнена целиком. Если остаток ещё есть, а следующий ask уже дороже, чем мы готовы платить, — остаток встаёт в стакан как новая bid-заявка.
Самое важное: цена сделки равна цене maker'а — того, чья заявка уже стояла в стакане. Maker получил ровно то, что просил. Taker получил лучшее из доступного. Это фундамент maker-taker-модели, на которой биржи зарабатывают: taker платит более высокую комиссию (он «забирает ликвидность»), maker — более низкую или даже получает возврат части комиссии (он «предоставляет ликвидность»).
Давайте прогоним этот алгоритм на конкретном примере. Входящая заявка — купить 1.0 BTC по цене не выше 50 115. В стакане уже лежат четыре встречные заявки.
Приходит лимитный ордер: купить 1.0 BTC по цене не выше 50 115. Матчинг-движок смотрит на лучшие аски.
Обратите внимание на детали, которые видны только при пошаговом разборе:
- Одна входящая заявка порождает несколько trade events. Клиент увидит их как частичные исполнения — в разделе 5 мы увидим, как это отражается в состоянии заявки (
PARTIALLY_FILLED). - На уровне
50 100два мейкера. Входящая заявка сначала съедаетA1(Carol, пришла первой), потомA2(Dave). Price-time priority в действии. - Последний шаг зависит от сценария. Если остатка хватило — заявка встаёт в стакан как новый bid. Если не хватило — статус
FILLED, и работа с ней закончена.
Типы заявок — небольшой зоопарк
Лимитная — не единственный тип заявки, который биржа принимает. Каждый тип — это дополнительный контракт между клиентом и движком: что движок обещает сделать и чего не делать. Минимальный набор, который поддерживает любая серьёзная биржа, — семь вариантов.
Исполнится сразу, цена — какая дадут. Съедает верхние уровни пока не наберёт объём.
Исполнится по указанной цене или лучше. Если встречных нет — встаёт в стакан.
Спит, пока цена не дойдёт до триггера. Потом превращается в market-ордер.
Триггер активирует лимитный ордер. Защита от проскальзывания ценой контроля.
Что смогли — исполнили немедленно, остаток отменили. Никогда не остаётся в книге.
Либо исполнить целиком и немедленно, либо не исполнять вообще. Всё или ничего.
Если бы исполнился против встречного — отменится. Гарантирует статус мейкера и его рибейт.
Несколько практических замечаний:
- Market-заявка опасна на тонких рынках. Если в стакане мало глубины, market buy на 10 BTC может выгрести весь ask на три уровня вверх и исполниться по средней цене, далёкой от last trade. Защита — лимитная заявка с «потолочной» ценой.
- Post-only — любимый инструмент маркет-мейкеров. Их бизнес-модель построена на скидке к комиссии за предоставление ликвидности. Если заявка случайно исполнится как taker, экономика ломается.
POST-ONLYговорит движку: «если моя заявка не может встать в стакан — отмени, не исполняй». - IOC и FOK — про немедленность. IOC выполняет что может, остаток отменяет. FOK — всё или ничего: либо исполнить целиком прямо сейчас, либо не исполнять вообще. FOK удобен для крупных заявок, когда клиенту важнее гарантия объёма, чем цена.
- Stop-заявка — не «спасение от потерь», а заявка с триггером. Пока цена не дошла до триггера, заявка не в стакане и не видна рынку. Когда триггер сработал, она превращается в обычную market- или limit-заявку и проходит по тому же пути.
Код — limit order book в сорока строках
До этого мы говорили словами и картинками. Теперь — покажу код. Ниже — минимальная реализация стакана на Java в учебной форме: достаточно, чтобы увидеть структуру, недостаточно для продакшена. Но именно с такой заготовки реальные движки обычно и начинаются.
public final class LimitOrderBook {
// Асковая сторона: от меньшей цены к большей — быстрый доступ к best ask.
private final NavigableMap<Long, PriceLevel> asks = new TreeMap<>();
// Бидовая сторона: от большей цены к меньшей — быстрый доступ к best bid.
private final NavigableMap<Long, PriceLevel> bids = new TreeMap<>(Comparator.reverseOrder());
public long bestBid() {
return bids.isEmpty() ? 0L : bids.firstKey();
}
public long bestAsk() {
return asks.isEmpty() ? Long.MAX_VALUE : asks.firstKey();
}
public void addResting(Order order) {
NavigableMap<Long, PriceLevel> side = (order.side() == Side.BUY) ? bids : asks;
side.computeIfAbsent(order.priceTicks(), PriceLevel::new).enqueue(order);
}
public void cancel(Order order) {
NavigableMap<Long, PriceLevel> side = (order.side() == Side.BUY) ? bids : asks;
PriceLevel level = side.get(order.priceTicks());
if (level == null) return;
level.remove(order);
if (level.isEmpty()) side.remove(order.priceTicks());
}
}
final class PriceLevel {
private final long priceTicks;
private final ArrayDeque<Order> queue = new ArrayDeque<>();
PriceLevel(long priceTicks) { this.priceTicks = priceTicks; }
void enqueue(Order o) { queue.addLast(o); }
void remove(Order o) { queue.remove(o); }
Order head() { return queue.peekFirst(); }
boolean isEmpty() { return queue.isEmpty(); }
long priceTicks() { return priceTicks; }
}
Несколько инженерных замечаний, которые в учебниках обычно пропускают:
- Цена — это
long, а неdouble. Плавающая точка в финансовой математике — источник багов на годы. Цена хранится в тиках (минимальная единица изменения цены; для BTC/USDT это обычно0.01 USDT→longв центах). Все сравнения и расчёты — в целых числах. ArrayDeque, а неLinkedList. Последний выглядит «естественнее» для очереди, но у него плохая локальность данных и постоянное создание объектов на каждый элемент.ArrayDeque— массив с кольцевыми индексами: хорошо ложится на процессорный кэш и не нагружает сборщик мусора. Есть один неприятный нюанс, который мы здесь прячем:queue.remove(o)вArrayDeque— это O(n) линейный поиск по очереди, и для cancel-тяжёлой нагрузки (а отмен на бирже всегда больше, чем исполнений) это плохо. Настоящие движки хранят заявки в интрузивном двусвязном списке — у каждогоOrderсвои указателиprev/nextна уровне цены, иcancel()становится O(1). В учебном коде показываем форму, в продакшене платят за O(1).TreeMap— временная заглушка. Для серьёзных объёмов вместоTreeMapиспользуют skip list или массив уровней с фиксированным шагом. Но интерфейс остаётся тем же, и алгоритм выше работает поверх любого из них.
Теперь — match(). Это тот самый метод, который делает биржу биржей. Его стоит прочитать построчно.
public List<Trade> match(Order incoming) {
List<Trade> trades = new ArrayList<>();
NavigableMap<Long, PriceLevel> opposite =
(incoming.side() == Side.BUY) ? asks : bids;
while (incoming.remaining() > 0 && !opposite.isEmpty()) {
Map.Entry<Long, PriceLevel> bestEntry = opposite.firstEntry();
long makerPrice = bestEntry.getKey();
if (!crossesPrice(incoming, makerPrice)) {
break; // цена перестала устраивать — остаток пойдёт в стакан.
}
PriceLevel level = bestEntry.getValue();
while (incoming.remaining() > 0 && !level.isEmpty()) {
Order maker = level.head();
long filledQty = Math.min(incoming.remaining(), maker.remaining());
trades.add(new Trade(
makerPrice, filledQty, maker.id(), incoming.id()
));
maker.reduceBy(filledQty);
incoming.reduceBy(filledQty);
if (maker.remaining() == 0) {
level.remove(maker); // мейкер исполнен — убираем из очереди.
}
}
if (level.isEmpty()) {
opposite.remove(makerPrice); // уровень опустел — удаляем.
}
}
if (incoming.remaining() > 0 && incoming.type() == OrderType.LIMIT) {
addResting(incoming); // остаток встаёт в стакан как новый resting-ордер.
}
return trades;
}
private boolean crossesPrice(Order incoming, long makerPrice) {
return incoming.side() == Side.BUY
? incoming.priceTicks() >= makerPrice
: incoming.priceTicks() <= makerPrice;
}
Это — весь алгоритм матчинга. Сорок строк. Всё остальное, что делает движок в реальности, — обвязка: риск-проверки до вызова, публикация trade events после, репликация журнала на резерв, логирование, метрики.
Именно поэтому matching engine — не «микросервис» и не «горизонтально масштабируемая система». Весь горячий путь — один метод в одном потоке на одном CPU-ядре. Всё, что вокруг, может масштабироваться как угодно. Само ядро — нет.
Почему всё это в одном потоке
Один из первых вопросов, который задаёт инженер, впервые увидевший описание matching engine: «Почему не распараллелить? Это же 2026 год, у меня 64 ядра на сервере.» Ответ — в детерминизме и латентности.
Детерминизм. Биржа должна гарантировать, что одни и те же входные заявки в одном и том же порядке всегда приводят к одному и тому же результату. Это нужно для репликации (резервный движок должен прийти в то же состояние, что и основной), для разбора инцидентов (почему вчерашняя сделка исполнилась именно так?), для аудита и для регуляторов. Многопоточный код недетерминирован по определению: результат зависит от того, какой поток выиграл гонку. Чтобы вернуть детерминизм в многопоточной среде, нужны блокировки. А блокировки…
…убивают латентность. Одна блокировка — это как минимум несколько сотен наносекунд в хорошем случае и несколько микросекунд в плохом. При бюджете в 10–50 микросекунд на один матчинг-раунд несколько таких блокировок — это весь бюджет. Проще не брать блокировок вообще и не использовать многопоточность там, где она создаёт больше проблем, чем решает.
Байты из gateway превращаются в структуру ордера в памяти.
Баланс, лимиты позиции, price bands, self-trade prevention — всё в памяти.
Переход к best bid / best ask, обход нужных уровней.
Списание встречных ордеров, изменение остатков, удаление пустых уровней.
Trade events в ring buffer → market data, ledger, notifications.
Как же масштабировать? По символам. Matching engine для BTC/USDT — один процесс на одном ядре. Движок для ETH/USDT — другой процесс на другом ядре. Они независимы, никогда не пересекаются и масштабируются горизонтально без ограничений. Когда биржа говорит «мы обрабатываем 1,4 миллиона сделок в секунду» — это суммарно по всем символам, а не по одному. Эту модель мы уже видели на схеме в предыдущем разделе — шардирование по торговым парам.
LMAX-паттерн: как принято в индустрии
Теперь — про инженерный паттерн, на котором построены все публично описанные matching engine, включая Coinbase, Kraken и, судя по утечкам, Binance. Паттерн получил название «LMAX-архитектура» по имени британской биржи, которая в 2011 году опубликовала подробное описание своего подхода в статье Мартина Фаулера. С тех пор инженеры называют это просто «LMAX-way» или «single-writer pattern».
Суть — в трёх правилах:
- Один поток владеет матчинг-ядром. Никаких блокировок. Вся логика исполнения — чистая функция от последовательности входящих команд.
- Входы приходят через кольцевой буфер (ring buffer). Продюсеры (например, gateway) складывают команды в буфер без блокировок, используя только атомарные операции с индексами. Потребитель (матчинг-ядро) читает команды одну за другой.
- Выходы расходятся по нескольким независимым потребителям параллельно. Trade events из того же ring buffer'а одновременно читаются рассылкой рыночных данных, леджером и журналом на диск. Потребители не мешают друг другу и не блокируют писателя.
Теперь — как именно продюсеры и потребители двигаются по этому буферу, потому что это не очевидно из «складывают и читают». Буфер — массив фиксированного размера, скажем, 1024 слота. Каждый участник — продюсер и каждый потребитель — держит свой cursor: монотонно растущий номер последнего обработанного слота. Продюсер перед записью в слот N проверяет: прошёл ли самый медленный потребитель позицию N − 1024? Если да — можно писать, содержимое слота уже никому не нужно, и его спокойно перезаписывают. Если нет — продюсер ждёт самого медленного. Это единственная форма синхронизации во всей системе: сравнение двух long через атомарную операцию. Никаких блокировок, никаких очередей — только индексы.
Потребители двигаются независимо друг от друга: matching может стоять на позиции 1000, журнал — на 997, рассылка рыночных данных — на 998. Продюсер смотрит на минимум из их курсоров, и именно он определяет, можно ли писать дальше. Получается естественный back-pressure: если журнал отстаёт, продюсер тормозит вместе с ним, и нигде в системе ничего не переполняется. Сами слоты — заранее созданные объекты, которые переиспользуются по кругу: продюсер перезаписывает их поля, потребители читают. Никто никому ничего не передаёт — все работают с одним и тем же массивом, каждый по своему индексу.
Gateway кладёт входящие ордера в хвост — один поток, без блокировок.
Single-thread: читает по одному событию, обновляет книгу, пишет trade events.
Параллельно пишет входящий поток на диск — база для репликации и восстановления.
Слоты распределены один раз при старте. Никаких new в горячем пути — а значит, никаких пауз GC внутри матчинг-цикла. Потребители двигаются по кольцу независимо, producer ждёт только когда догоняет самого медленного из них.
Что важно не только для матчинга:
- Никаких выделений памяти на горячем пути. Объекты в ring buffer'е создаются один раз при старте и переиспользуются. Никаких пауз сборщика мусора во время матчинг-раунда. Трейдеру безразлично, сколько у вас памяти. Ему важно, чтобы движок не замирал на 200 миллисекунд ровно в момент его заявки.
- Хорошая локальность данных. Кольцевой буфер — это массив фиксированного размера; процессорный кэш ложится на него идеально.
- Репликация ring buffer'а — это репликация состояния. Резервный движок подписывается на тот же поток команд, прогоняет их через свой экземпляр — и приходит в то же состояние, что и основной. В разделе 9 мы увидим, как на этом строится отказоустойчивость с детерминированным воспроизведением журнала.
У всех этих приёмов есть общее название — mechanical sympathy (дословно «механическая симпатия»). Термин пришёл из автогонок: Джеки Стюарт говорил, что быстрый пилот обязан чувствовать свою машину — понимать, что она может и чего не может, и строить свой стиль вокруг её физики. Мартин Томпсон, один из авторов LMAX и Disruptor, перенёс эту идею в инженерию: быстрый код обязан чувствовать железо, на котором работает. CPU-кэш, префетчер, предсказатель переходов, false sharing между ядрами — это не детали реализации, а граничные условия дизайна, заданные физикой процессора. LMAX — это не «хороший дизайн» в абстрактном смысле; это matching engine, сделанный под реальное железо, а не под абстрактную модель вычислений.
В этом навыке мы говорим именно про паттерн, а не про конкретную библиотеку. Оригинальная LMAX опубликовала свою реализацию — Disruptor — и она до сих пор живёт в открытом доступе. Но когда инженеры на бирже говорят «сделали по LMAX-way», они обычно имеют в виду архитектурные принципы, а не конкретный Java-класс. Реализация может быть своя, на любом языке и под любую платформу. Правила — одни и те же.
Self-trade prevention и другие крайние случаи
Несколько подводных камней, про которые обычно не пишут в учебниках, но с которыми сталкивается любой настоящий matching engine.
Self-trade prevention (STP). Клиент может случайно (или намеренно) выставить две заявки — на покупку и на продажу — на один и тот же уровень. Если они встретятся в стакане, произойдёт «сделка с самим собой». Это выглядит как накрутка объёма и строго запрещено регуляторами в большинстве юрисдикций. Движок должен поймать такую ситуацию до совершения сделки и либо отменить более новую заявку, либо отменить обе. STP — не бонус, а юридическое требование.
Tick size. Биржа устанавливает минимальный шаг цены. Для BTC/USDT это обычно 0.01 USDT. Заявка на 50 100.005 просто не принимается — отказ на этапе валидации. Это сильно упрощает структуры данных: цена всегда укладывается в целые тики, и число возможных уровней ограничено.
Price bands. Если цена последней сделки — 50 100, движок не примет лимитную заявку на 75 000. Допустимый разброс от текущей цены ограничен коридором. Это защита от ошибок ввода — случая, когда трейдер промахивается на порядок при наборе цены (в индустрии это называют fat finger), — и от намеренных манипуляций.
Circuit breakers. При экстремальной волатильности (например, падение на 10% за минуту) биржа может приостановить торги по символу на несколько минут. В этот момент matching engine не принимает новых заявок, а старые ставятся в очередь. Это не техническая защита движка — это защита рынка от паники.
Краткая выжимка
- Стакан — это пара отсортированных структур. По цене — отсортированный контейнер, на каждом уровне — FIFO. Лучшая цена находится за константу, вставка — за логарифм.
- Price-time priority — контракт с рынком. Нарушить его — значит потерять доверие маркет-мейкеров. Движок должен быть детерминированным и честным.
- Алгоритм матчинга — сорок строк. Весь смысл в цикле: «пока есть остаток и пока встречный уровень подходит по цене, съедай голову очереди». Всё остальное — обвязка.
- Один поток, один процесс на символ. Детерминизм и латентность не оставляют выбора. Масштабирование — по символам, а не по ядрам внутри символа.
- LMAX-паттерн — индустриальный стандарт. Ring buffer, один пишущий поток, параллельные потребители событий. Без выделения памяти на горячем пути. Все публично известные биржевые движки построены на этих принципах.
В следующем разделе мы выйдем за пределы движка и проследим, что происходит с одной заявкой до и после матчинга: путь от нажатия кнопки через auth, rate limit, risk, блокировку баланса в леджере, сам матчинг — и обратно к клиенту через market data и уведомления. Именно тогда станет понятно, почему матчинг — это «всего» сорок строк: все сложности аккуратно вынесены наружу.
Дополнительное чтение
- Martin Fowler. The LMAX Architecture (2011) — martinfowler.com/articles/lmax.html. Каноническая статья про LMAX-паттерн. По ней учатся все, кто строит low-latency trading-системы. Читайте медленно — каждая страница плотная.
- LMAX Disruptor — технический whitepaper (2011) — lmax-exchange.github.io/disruptor. Оригинальный документ от авторов Disruptor. Разбирает ring buffer, sequencer, cache line padding и почему всё это работает быстрее блокировок.
- LMAX-Exchange/disruptor — github.com/LMAX-Exchange/disruptor. Исходный код библиотеки Disruptor. Apache-2.0, живой репозиторий, последний релиз — версия 4.0.0 в сентябре 2023. Полезно, если хочется увидеть, как паттерн выглядит в коде.
- Martin Fowler. Event Sourcing (2005) — martinfowler.com/eaaDev/EventSourcing.html. Теоретическая основа под отказоустойчивость matching engine с воспроизведением журнала. В разделе 9 мы увидим, как это применяется к резервированию.
- real-logic/aeron — github.com/real-logic/aeron. Современный наследник Disruptor-подхода от той же команды. Используется в высокочастотной торговле и в ряде криптобирж как транспорт для команд и событий.
- FIX Trading Community — fixtrading.org. Сайт сообщества вокруг FIX — стандартного протокола для институциональной торговли. Криптобиржи вроде Kraken поддерживают FIX-шлюз для крупных клиентов. К протоколу мы вернёмся в разделе 8.