29 августа 2022

Дневник разработки деривативной криптобиржи #2

Всем привет, на связи снова Алекс и мы продолжаем разбираться с внутренним устройством криптобиржи. Сегодня давайте поговорим о более высокой материи — а именно об архитектуре трейдинга. На эту тему очень мало публикаций, иногда есть материалы о принципах работы “больших” бирж (традиционных типа NASDAQ или Чикагская биржа CME). 

Редко, но бывает, на публику выдают подробности внутренней кухни — наверное, из последних, это было представление архитектуры LMAX Disruptor в 2012. Еще можно отметить редчайший пример — книгу Майкла Льюиса Flash-boys (экранизация затянулась, хоть первоначально готовилась в 2016-м), в которой группа высокочастотных трейдеров замечает нерыночные методы в поведении торговых систем и логично приходит к выводу, что если хочешь, чтобы-то что-то работал как надо — сделай это сам. Так возникла биржа IEX (Investors Exchange), основанная в 2012-м и позиционирующая себя как справедливая биржа с равными условиями (в том числе и техническими). 

Но о внутрянке криптобирж есть гораздо меньше информации. Поэтому, если мы не можем изучать в подробностях, как работают другие — будем создавать систему под свои возможности и пожелания. 

Говорят, что на бирже главное — это скорость работы и надежность. Для простого человека, трейдера это, безусловно, так. С технической стороны такая обывательская оценка превращается в десятки и десятки внутренних параметров, которые требуется отслеживать и минимизировать (или наоборот, стремиться выжать максимум). Но все сходятся в одном — быстрая работа трейдинга (или движка, как его могут называть) — это важно. 

Но движок (trading engine) для пользователя, и даже для части сотрудников — это некий черный ящик, куда залетают данные и выходят сделки. Архитектурно — это почти что верно. Мои коллеги на предыдущем проекте даже написали интересную математическую работу, посвященную моделированию биржевого движка как конечного автомата (deterministic state machine). 

Мы придерживаемся схожего подхода. И так — движок или trading engine — это группа сервисов (микро-сервисов) запущенная на выделенном сервере и обслуживающая всю торговую активность по одному инструменту. Именно эти моменты для нас и важны — движок не монолит, не единый код, делающий все и сразу (пусть и быстрее), а разделен на несколько со-зависимых сервиса, каждый делающий свою работу. 

Бывалые разработчики могут сказать — а чего так, ведь можно было бы создать одно приложение, запустить в разных потоках (multi-threaded service) и работал бы быстрее. Соглашусь в одном — да, работать такая система будет быстрее (хоть и степень этого “быстрее” далеко не так очевидна). И мы как раз на этой и предыдущей недели провели много экспериментов именно с реализацией многопоточной обработки. К сожалению, этот путь не самый лучший — он усложняет код биржи и его понимание, сильно затрудняет будущие изменения и вообще в целом менее надежен. 

Мы пошли другим путем — разделили функционал движка на независимые модули-сервисы, объединили их вместе используя быструю шину передачи данных, при этом оставив за каждым модулем заботу о своих данных и их сохранности. Такой выделенный супер-сервер мы назвали tradingBox

Think inside the box 

Внутри tradingBox-а все модули работают через центрального брокера, который хранит данные (те данные модулей, которые они делают доступными для других) и пересылает сообщения. Сообщения — это единица обмена информации между модулями. Центральная часть у нас — это специализированная in-memory база Redis (немного подкрученная в настройках для максимальной производительности) и нам доступны два вида коммуникации — очереди сообщений с хранением данных и чистый Pub/Sub ( когда сообщения рассылаются по подписке, но без гарантий). 

Сразу отмечу, что да — это немного нетипичное применение Redis-а, мы комбинируем его и как шину обмена данными, а по факту — как сверх-быструю и легкую замену очередям Kafka/RabbitMQ и как легкое хранилище данных. От применения узкоспециализированных систем сообщений (ZeroMQ/Nanomessage) мы сейчас сознательно отказались, чтобы не усложнять работу, при этом если будет достигнута такая нагрузка, что архитектура “захлебнется”, замену можно будет внедрить без большого переписывания. Впрочем, у ZeroMQ, по крайней мере в предыдущих версиях, были свои проблемы, а автор перспективной Nanomessage вообще, похоже, забил на проект (вернее, переключился на новую версию — NNG).

Модуль tradingGateway — это главное связующее звено движка с внешним миром. Он единственный знает о существовании другого мира вокруг — так как подключен одновременно и к внутренней шине сообщений и к внешнему кластеру. Сама биржа взаимодействует с торговым модулем через этот единственный канал данных, в который поступает поток сообщений или orderFlow

Строго говоря, orderFlow это не чистый поток ордеров, там есть разные классы сообщений, но суть в том, что принимая каждое сообщение, торговая система изменяет свое внутреннее состояние (даже не так, состояния, одного или нескольких модулей). Да, главным драйвером изменений выступают ордера или торговые заявки. Но в нашем случае деривативной биржи, главными выступают даже не заявки — а события изменения индекса (markPrice) и сообщения про отмену ордера. 

Про индекс наверное, сразу понятно — все внутренние расчеты привязаны к нему, каждый его “тик”, изменение, означает не только  смену виртуальных прибылей и убытков (Unrealized PnL), но и постоянные переоценки уровней ликвидаций. А вот про отмену ордеров не так просто понять. На самом деле это также связано с ценой маркировки. Фьючерсная торговля напрямую зависит от индекса, который задает коридор цен и трейдера вынуждены подстраиваться под него, ведь не успеешь среагировать — и уже твой ордер забрал более быстрый и агрессивный конкурент, позиция открыта, не прошло и секунды, а уже появился убыточной… Когда происходит колебание цены, сразу за этим приходят в движение роботы алготрейдеров — в зависимости от возможностей биржи, они или корректируют выставленные ранее заявки или же снимают и пере-выставляют новые ордера по новым ценам. Поэтому ордербук оптимизируется под скорость не только добавления новой заявки, но и под быструю отмену большого количества ордеров. 

Модуль orderBook как раз хранит в себе ту самую книгу заявок (стакан), которую видят трейдеры в терминале. Хотя внутри сервиса, алгоритмически, она представлена совсем в другом виде, нежели показано на экране. Для простоты работы всех других систем биржи, сервис orderBook-а периодически (раз в секунду или быстрее) создает “снимок” (snapshot) и перекодирует его так, чтобы было удобно показать человеку. Там уже нет столь подробной информации, лишь ценовые уровни и объемы заявок.

Matcher — или механизм сопоставления торговых ордеров, алгоритм, определяющий, какая заявка будет сведена с какой, какая точная финальная цена покупки или продажи. Он работает далеко не так просто, как может показаться, но упрощенно — если цена покупателя равна или лучше, чем цена, которую запросил продавец — он сводит такие ордера и говорит — Deal done! Для максимальной скорости этот модуль интегрирован в orderBook, а не вынесен в отдельный сервис (хотя мы еще думаем над этим решением).

positionManager — специальный сервис, управляющий позициями (открытыми контрактами). Напомню, что в мире деривативов мы торгуем перпетуал фьючерсами (perpetual futures contract), то есть — контрактами между двумя трейдерами, который после открытия не имеет срока давности (или экспирации). Одна из сторон ставит на падение (Short), вторая ожидает роста (Long). Для каждого отдельного трейдера — его позиция, это просто число, количество его контрактов и по какой цене. Но биржевой движок не может так работать, он ведет полный и точный учет каждой позиции. К примеру, ваш ордер на покупку 100 контрактов по цене 21340$ исполнен. Для вас, как трейдера — у вас открыта одна позиция на 100 контрактов. А на стороне биржи — это может быть 2 разные позиции (по 40 и 60 контрактов) с разными контрагентами, так и 100 разных позиций по 1-му контракту! Поэтому требования к скорости работы у positionManager-а еще выше, чем у самой торговой системы! К тому же, считать uPnL при каждом изменении цены нужно то по всем-всем позициям в отдельности, проверять ликвидации также по каждой. Поэтому даже при неспешных торгах и нескольких сотнях ордеров, позиций единовременно может быть многие тысячи. 

Ликвидатор — это страшный сон и главный враг каждого деривативного трейдера. А по факту — это самый невезучий алготрейдер в мире! Именно он принимает на себя и управляет всеми теми позициями, которые исчерпали лимит маржи и которые надо продать (или, в самом худшем случае —  закрыть). Ликвидатор берет на себя все позиции, он имеет привилегию агрегировать позиции в одну, собирая из мелких одну большую. Он же и пробует выставлять их на рынок, чтобы поскорее избавиться. Но если все пошло не по плану — вынужден из своих средств добавлять недостающие суммы. 

executionEngine — обычный простой трудяга. Когда Matcher принимает решение, что ордер 1 надо свести со подходящим встречным ордером с ид, скажем, 42, он сразу теряет интерес к происходящему далее — ему бы со своей работой управиться, учитывая сжатые сроки (а время, отпущенное на метчинг составляет не более десятков миллисекунд). Поэтому дальше вступает в работу офисный клерк — executionEngine. Он еще раз проверяет возможность заключения сделки, формирует уведомления сервису торгового счета, рассчитывает параметры новой позиции — на все это у него уже нет такой спешки, хоть и надо сделать максимально быстро. 

fundingManager — отвечает за расчет платежей по ставке фондирования (funding rate). Напомним — это процентная ставка, которую платят стороны контракта друг другу, в зависимости от того, больше она 0 или меньше. Предназначена ставка для балансировки позиций на рынке перпетуал фьючерсов и, по сути, дополнительного стимулирования трейдеров держать позиции по тренду рынка. На нашей бирже мы планируем учитывать фандинг каждую минуту, а значит нам надо быстро вычислять для каждой отдельной позиции (а, напомню, их может быть много, десятки тысяч) сколько конкретно денег надо перевести с/на баланс. Несмотря на то, что математика там достаточно простая, но есть нюансы. С одной стороны в формуле сравнительно большие числа — notion amount или условный размер позиции (без учета левереджа), выражается в тысячах, а иногда и десятка-сотнях тысяч. С другой — процентная ставка на минутном интервале — очень незначительная, сотые и даже тысячные доли процента. С третьей стороны — отдельных позиций может быть под сотню тысяч. Что означает, что даже очень небольшая величина для каждой отдельной позиции может после многократного сложения превратиться в приятное и значимое число долларов на чьем-то счету. Так что требования к математическим расчетам фандинг-платежей высокие. И да, нельзя потерять ни один рассчитанный платеж ни для одной позиции — эти платежи накапливаются и постоянно суммируется. В эту минуту вы получили доллар, а уже в следующую вы вполне делаете выплату в пользу контрагента из этих средств. 

А чтобы жизнь не казалась легкой — не следует забывать, что существуют проверки корректности и безопасности. Например, сумма всех фандинг-платежей одной стороны должна в точности совпадать с выплатой другой стороны и в итоге каждый расчетный период должен сводиться в 0. Количество всех позиций шорт должно идентично совпадать с позициями лонг — и в количестве контрактов, и в размере позиций. Также, как и суммы заблокированной маржи. И Unrealized PnL — даже тот каждую секунду суммарно должен быть равен строго 0. Таки да, торговля перпетуал фьючерсами — это игра всегда с нулевой суммой! 

На этом пока закончу, спасибо за внимание. 

P.S. И следуя начатой в предыдущем выпуске традиции, несколько сложных (!) но интересных материалов, которые мы читали за эту неделю.

Обсудить в Discord!