Спотовый матчинг-движок — маленький конечный автомат в кольце жёстких ограничений: приоритет цены и времени, частичные исполнения, хвост задержки, который не уезжает под всплеском нагрузки. Вот как устроены стакан, цикл сведения, журнал и стенд реплея — и где язык перестаёт помогать.
У матчинг-движка спотовой биржи узкая работа. Он берёт упорядоченный поток команд — новая заявка, отмена, замена — применяет их к стакану по приоритету цены и времени и выдаёт упорядоченный поток событий: сделки, обновления стакана, отказы, подтверждения.
Вся сложность идёт от трёх ограничений, надстроенных над этой работой: результат должен быть одинаковым при каждом реплее того же входа, остаток частично исполненной заявки должен сохранять своё место в очереди, а хвост задержки не должен уезжать при всплеске.
Что amBrain может подтвердить публично: построенная нами мини-биржа работает в проде на колокации MOEX, а горячие пути наших торговых систем написаны на Rust. Две цифры, которые мы публикуем, — рыночные данные доставляются быстрее 5 мс, пред-трейд проверки риска укладываются в 1 мс — замерены на этих путях, а не на цикле матчинга, описанном ниже. Остальная статья — о том, как мы проектируем движок, а не бенчмарк одного из них.
Стакан — это две стороны, каждая из которых упорядоченная по цене коллекция уровней. Уровень — не число: это очередь стоящих заявок по данной цене в порядке поступления. Сведение постоянно трогает лучший уровень и редко — глубокие, поэтому структура выбирается под этот шаблон доступа, а не ради изящества.
Следствие интрузивных очередей и индексных handle'ов в том, что стоящая заявка за свою жизнь никогда не переезжает в памяти. Её место в очереди — свойство её ссылок, а не того, где она лежит, и именно это делает частичные исполнения дешёвыми дальше.
Входящая агрессивная заявка идёт по противоположной стороне от лучшей цены вглубь. На каждом уровне она обходит FIFO-очередь с начала. Она останавливается, когда цена уровня перестаёт устраивать входящую заявку или когда её количество доходит до нуля.
Частично исполненная стоящая заявка сохраняет своё место. Остаток остаётся в начале своей очереди с исходным номером поступления, потому что исполнение меняет количество и больше ничего. Частично исполненная агрессивная заявка, если это обычный лимит, становится стоящей в конце своего ценового уровня с новым номером поступления — она пришла сейчас, а не раньше.
Семантика типов заявок — решения, принимаемые на границе этого цикла, а не внутри него. Immediate-or-cancel отбрасывает остаток вместо того, чтобы поставить его в стакан. Fill-or-kill сначала делает сухой прогон и либо исполняется целиком, либо отклоняется. Post-only отклоняется, если заявка пересеклась бы при поступлении. Держать это вне цикла — значит оставить цикл единственным местом, где меняется состояние стакана.
Защита от самосделок, минимальные количества, проверка тика и лота тоже относятся к тому, что идёт до цикла. Заявка, дошедшая до сведения, уже доказано корректна, поэтому в цикле нет ветвей ошибок, которые его замедляли бы или вызывали споры.
Средняя задержка редко бывает проблемой. Проблема — худшее наблюдение во время всплеска, то есть тогда, когда движок важнее всего и когда пауза stop-the-world вероятнее всего и случится. Под управляемой средой со сборщиком мусора эту паузу планирует сборщик, а не вы, и приходится она на середину того всплеска, который мусор и породил.
Ручное выделение памяти — уменьшенная версия той же проблемы. Аллокатор общего назначения может обходить списки свободных блоков, брать лок или просить память у ядра, и именно этот вызов попадает в хвост. Лечение одно в обоих случаях: не выделять память на горячем пути вовсе.
Движок детерминирован, когда одна и та же входная последовательность даёт ту же выходную, байт в байт, на другой машине и через год. Всё, что читает системные часы, планирование потоков или порядок обхода хеш-таблицы внутри пути сведения, это свойство ломает.
Поэтому отметки времени — вход, а не то, что движок читает сам. Секвенсор ставит штамп на команду, когда её принимает, а цикл сведения обращается со штампом как с данными. Случайность, если она нужна, берётся из генератора с зерном, и это зерно — часть журнала.
Восстановление — не фича, прикрученная после того, как заработало сведение. Движок пишет append-only журнал принятых команд в порядке последовательности, а стакан в памяти есть не что иное, как результат свёртки этого журнала. Восстановление после сбоя — это его повторное проигрывание.
Журнал пишет движок, но долговечность — свойство пути хранения и того, на скольких машинах есть запись до отправки подтверждения. Это решение про репликацию и железо, и именно там на самом деле выигрывается или проигрывается время восстановления.
Детерминизм — это то, что делает движок тестируемым. Раз один и тот же вход даёт один и тот же выход, записанная сессия становится регрессионным тестом, а найденный однажды сбой воспроизводится точно, а не отлавливается заново.
Эталонная модель стоит больше, чем кажется. Две реализации, написанные по одной спецификации, расходятся ровно там, где спецификация была двусмысленной, а правила сведения полны двусмысленностей на краях: пересекающиеся лимиты, нулевые остатки, отмены, гоняющиеся с исполнениями.
Rust убирает целый класс проблем, а не ускоряет цикл сам по себе. Сборщика мусора нет, поэтому никакая пауза не планируется у вас за спиной. Владение делает дисциплину единственного писателя тем, что проверяет компилятор, а не тем, что должно заметить ревью. Handle'ы в слабе и интрузивные связи, легко дающие ошибку в языке без времён жизни, здесь проверяемы. Паника при переполнении целых в debug-сборках ловит класс багов, тихо портящих стакан.
Честная граница в том, что почти всё, что определяет хвост задержки, — не язык:
Rust и стоит чего-то. Borrow checker тормозит первые недели дизайна, который ещё движется, экосистема биржевых протоколов тоньше, чем в более старых языках, а unsafe-блоки вокруг lock-free структур требуют той же дисциплины ревью, что и аналогичный код где угодно. Выбор Rust — решение про хвост задержки и про безопасность памяти в ядре с единственным писателем, а не про комфорт разработчика.
amBrain строит торговую инфраструктуру в Ереване с 2019 года, горячие пути — на Rust; построенная нами мини-биржа работает в проде на колокации MOEX. Если вы проектируете матчинг-движок и хотите пройти по структуре книги заявок, формату журнала или стенду воспроизведения — такой разговор стоит провести до первой строки горячего пути.
Принесите текущую архитектуру и тот сценарий отказа, который вас беспокоит, — разберём его вместе за полчаса.
Записаться на звонок