Oluwa.io
Все тексты
·6 мин чтенияразработкакриптонадёжностьтрейдинг

Как построить надёжное исполнение криптоордеров

Надёжность исполнения криптоордеров строится на конечных автоматах, ключах идемпотентности и безжалостной сверке. Уроки создания MadaiOps.

Автор Vitor Lima

Большинство криптотрейдинговых продуктов продают скорость и график. Сложность вовсе не в том, чтобы рисовать свечи. Она в том, чтобы в любую миллисекунду ответить на обманчиво простой вопрос: каково истинное состояние моего ордера прямо сейчас? Когда мы начинали строить MadaiOps, наше внутреннее приложение для управления ордерами и торговыми операциями, именно этот вопрос организовывал всё остальное. Скорость — это фича. Правда — это продукт. Надёжность исполнения криптоордеров — это то, что у вас есть, когда приложение способно ответить на этот вопрос честно, даже пока биржа деградирует. А приложение, которое показывает исполнение, которого не было, — или скрывает то, что реально произошло, — разрушает доверие так, что никакой выигрыш в латентности этого не отыграет.

Этот пост — про инженерию, которая делает приложение для ордеров заслуживающим доверия: конечный автомат, идемпотентность, сверка через WebSocket и режимы отказа, которые всплывают только под нагрузкой. Мы придерживаемся жёстких взглядов на всё это, потому что именно скучные решения удерживают вас на плаву.

Моделируйте ордер как явный конечный автомат

Лучшее решение, которое мы приняли, — перестать относиться к ордеру как к строке, которую вы UPDATE, и начать относиться к нему как к конечному автомату с именованными, перечисленными переходами. Ордер живёт в состояниях вроде PENDING_NEW, WORKING, PARTIALLY_FILLED, FILLED, CANCEL_REQUESTED, CANCELED, REJECTED и EXPIRED. Каждый переход — это функция от текущего состояния плюс входящего события, а недопустимые переходы отклоняются громко, а не молча проглатываются.

Выигрыш: неоднозначность превращается в проблему уровня валидации, а не в инцидент в два часа ночи. Ордер в состоянии FILLED никогда не может вернуться в WORKING. Событие об исполнении, пришедшее для ордера в состоянии CANCELED, — это не «невозможно», это реальная гонка, и конечный автомат заставляет вас решить, что она означает. Обычно это значит, что отмена проиграла гонку, ордер на самом деле исполнился, а ваш локальный CANCELED был ошибкой. Смоделируйте ордера как изменяемые строки — и это событие просто перезапишет что-то; вы ничего не заметите, пока сверка — или пользователь — вас не поймает.

Главный вывод

Перечислите свои состояния прежде, чем напишете хоть один вызов API. Если вы не можете нарисовать диаграмму на маркерной доске, вы ещё не понимаете свою предметную область — и биржа научит вас этому жёстким способом. Храните переходы, а не только текущее состояние. Журнал событий «только на добавление», описывающий, что произошло, бьёт единственную изменяемую колонку статуса, потому что журнал воспроизводим, а колонка — это догадка.

Идемпотентность не опциональна

Сети делают повторные попытки. Ваша собственная логика ретраев делает повторные попытки. Пользователь дважды нажимает «Купить». Любое из этого может отправить один и тот же ордер дважды, а на бирже дублирующий ордер — это реальные деньги. Защита — сгенерированный на клиенте ключ идемпотентности (большинство бирж называют его clientOrderId или newClientOrderId), прикреплённый к каждому ордеру в тот момент, когда пользователь его подтверждает, ещё до того, как запрос покинет устройство или сервер.

Правило, которое мы соблюдаем: ключ генерируется один раз, сохраняется локально перед сетевым вызовом и переиспользуется дословно при каждой повторной попытке. Если первая попытка отвалилась по таймауту без ответа, ретрай несёт тот же ключ, и биржа либо создаёт ордер один раз, либо возвращает уже существующий. В любом случае вы сходитесь к единственному ордеру. Сгенерируйте ключ после сбоя — и вы построили машину по производству дублирующих ордеров.

Это распространяется на отмены и модификации. Запрос на отмену тоже должен быть идемпотентным: отмена уже отменённого ордера — это no-op, возвращающий успех, а не ошибка, запускающая очередной цикл ретраев. Мы везде трактуем «желаемое конечное состояние уже достигнуто» как успех. Звучит тривиально. Это устраняет целый класс штормов повторных попыток.

Главный вывод

Сохраняйте ключ идемпотентности перед действием, а не после успеха. Разрыв между «я это отправил» и «я знаю, что это сработало» — ровно то место, где исчезают деньги, и ключ — единственное, что этот разрыв закрывает.

Сверяйте безжалостно: WebSocket — это подсказка, а не истина

Биржи пушат обновления ордеров по WebSocket, и они прекрасны, когда работают: исполнения меньше чем за секунду, живые объёмы частичных исполнений, подтверждения отмен. Но WebSocket — это поток, который можно пропустить. Вас будет отключать. Сообщения приходят не по порядку, теряются во время переподключения или просто не приходят никогда, потому что биржа деградировала. Если состояние вашего ордера — это только то, что последним сказал сокет, ваше приложение уверенно ошибается в ту же секунду, как сокет икнул.

Поэтому мы гоняем два цикла. Быстрый цикл потребляет WebSocket и применяет события оптимистично. Медленный цикл опрашивает REST API — GET /order и эндпоинты открытых ордеров — по интервалу и после каждого переподключения, а затем сверяет этот снимок с локальным состоянием. REST — источник истины; сокет — низколатентная подсказка. Когда они расходятся, побеждает REST, а расхождение мы логируем, потому что расхождение — это баг-репорт, который система написала за вас.

Порядок следования важен. Большинство бирж помечают обновления монотонным порядковым номером или полем updateTime. Мы отбрасываем любое событие, которое старше последнего применённого для этого ордера, что делает доставку не по порядку безвредной. При переподключении верный ход такой: переподписаться, немедленно вытянуть полный REST-снимок, чтобы закрыть пробел, и только потом снова доверять живому потоку. Пропустите снимок — и вы упустите исполнение, случившееся за те две секунды, что вы были офлайн.

Главный вывод

Никогда не позволяйте потоку реального времени быть единственным, кто пишет состояние. Сокет делает вас быстрыми; цикл сверки делает вас правыми. Нужны оба, и когда они конфликтуют, правота бьёт скорость каждый раз.

Частичные исполнения и разрывы связи: две вещи, которые вас выдают

Частичные исполнения — это место, где наивные приложения тихо врут. Ордер на 1.0 BTC, который исполнился на 0.3, потом на 0.4, а затем завис, — это не «открыт» и не «завершён». Это PARTIALLY_FILLED с 0.7 исполненными и 0.3 оставшимися, и каждое из этих чисел должно отслеживаться из отдельных событий об исполнении, а не выводиться из единственного поля статуса. Мы накапливаем исполненное количество и средневзвешенную по объёму цену из дискретных исполнений, так что позицию всегда можно реконструировать из примитивов. Пользователю, который видит «исполнено», нужно знать, исполнено сколько и по какой средней цене. Всё, что меньше, — это полуправда.

Разрывы связи усугубляют это. Отвалитесь в офлайн посреди исполнения — и WebSocket никогда не расскажет вам про те 0.4, так что ваше локальное состояние устарело. Именно поэтому существует дисциплина «переподключился — сделай снимок»: REST-снимок возвращает кумулятивное исполненное количество, и цикл сверки закрывает пробел так, что пользователь ни разу не увидит неверное число. Режим отказа, против которого мы проектируем, — это не «приложение упало»; падения честны. Это «приложение продолжило работать и показало что-то ложное».

Главный вывод

Отслеживайте кумулятивное исполненное количество как первичный факт и выводите из него всё остальное. Никогда не храните «оставшееся» как независимое поле, которое вы мутируете. Выводите его (заказано − исполнено), чтобы оно не могло разойтись с реальностью.

Правдивый под давлением бьёт умного

Существует стойкий соблазн быть умным: предсказывать исполнения до подтверждения, прятать «временные» ошибки, сглаживать переподключение так, чтобы UI никогда не мерцал. Мы научились сопротивляться почти всему этому. Торговому оператору не нужна гладкая ложь; ему нужно честно знать, что соединение только что упало и состояние сверяется. Видимое состояние «переподключение, проверяю ордера» стоит больше, чем бесшовный UI, который в итоге показывает вчерашнюю правду.

Конкретно: мы выносим неопределённость наружу, а не заклеиваем её. Когда локальное состояние и биржа давно не сверялись, приложение так и говорит. Когда ордер в состоянии CANCEL_REQUESTED, но отмена ещё не подтверждена, оно показывает ровно это, а не преждевременный CANCELED. Оптимистичный UI хорош для приложения со списком дел. Для денег оптимизм — это обязательство, за которое вы платите тикетами в поддержку и потерянным доверием.

Главный вывод

Когда сомневаетесь, покажите сомнение. Самое обнадёживающее, что финансовое приложение может сделать под стрессом, — это отказаться гадать.

Заключительная мысль

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