Округление кажется решённой задачей. У вас есть число, есть размер шага, вы округляете до ближайшего допустимого значения. Одна служебная функция, двадцать строк, готово навсегда.
Но это не одна функция. В системе, которая отправляет ордера на несколько бирж, точность это целая предметная область, и баги, которые она порождает, объединяет одно неприятное свойство: они не выбрасывают исключение. Они порождают ордер, который слегка неверен, или ордер, который отклоняется по причине, невидимой пользователю.
Вот как эта работа выглядит на самом деле, на трёх реальных проблемах из нашего пути исполнения ордеров.
Главное
- IEEE-754 делает выравнивание по шагу ненадёжным: умножение 0.29 на 100 даёт 28.999999999999996, что при округлении вниз даёт значение на целый шаг меньше нужного.
- Три биржи применяют три несовместимых правила минимального ордера. Одна проверяет стоимость в USDT, другая число контрактов, третья и то, и другое.
- Наш худший баг в этой области возник из-за того, что мы выдумали правило, которого у биржи нет, и стали отклонять по нему корректные ордера.
- Никогда не синтезируйте лимит, которого API вам не дал. Если у площадки нет минимума по стоимости, вычислять его из других полей это фабрикация, а не осмотрительность.
- Отсутствующие метаданные биржи должны вызывать ошибку, а не подставлять значение по умолчанию. Неверное значение по умолчанию тихо порождает неверные ордера.
1. Арифметическая проблема: округление сдвигает ваше число
Начнём с простейшего случая, потому что именно его считают безопасным.
Биржа принимает количества, кратные размеру шага. У вас есть 0.29 и шаг 0.01. Это значение уже допустимо, поэтому округление вниз должно ничего не менять. Стандартная реализация умножает, округляет вниз и делит обратно:
0.29 / 0.01 = 28.999999999999996 ← IEEE-754, не 29
floor(...) = 28
28 × 0.01 = 0.28
Значение, которое уже было выровнено, сдвинулось на целый шаг вниз, тихо, в сторону меньшего ордера. Ни исключения, ни предупреждения, и происходит это лишь при определённых сочетаниях значения и шага, что как раз и делает баг незаметным.
Исправление это эпсилон, применяемый при умножении: достаточно большой, чтобы поглотить шум представления, но достаточно маленький, чтобы действительно меньшее значение вроде 0.289999 всё равно округлялось вниз корректно. О том, как я это обнаружил, я писал в статье что я недооценил, создавая эту платформу; здесь же важна категория, к которой это относится.
Потому что как только вы признаёте, что арифметика над количествами ненадёжна, вы начинаете смотреть на всё остальное, что касается количества. И вот там ждут настоящие сюрпризы.
2. Три биржи, три несовместимых минимума
Каждая площадка применяет минимальный ордер. Вы ожидаете, что правило будет отличаться значением, а не формой. Оно отличается формой:
| Биржа | Правило |
|---|---|
| Binance | qty × price >= minNotional (проверка стоимости в USDT) |
| Bybit | qty >= minOrderQty и notional >= minNotional (две проверки) |
| OKX perpetuals | qty >= minSz (число контрактов, вообще без проверки стоимости) |
Читайте эту таблицу как три разных вопроса об одном и том же ордере. Binance спрашивает, сколько он стоит. OKX спрашивает, сколько это контрактов. Bybit спрашивает и то, и другое, и ордер может удовлетворять любой одной проверке по отдельности и всё равно быть отклонён.
На OKX есть ещё одна тонкость: количества выражаются в контрактах, а не в монетах, поэтому размер контракта должен переводить одно в другое, прежде чем всё это обретёт смысл. Количество, означающее «0.5 BTC» на одной площадке, означает «50 контрактов» на другой.
Архитектурный вывод, к которому мы пришли: каждая биржа проверяет собственные ордера. В движке стратегии нет ни единого условия о том, с какой площадкой он общается. Он передаёт ордер адаптеру, и адаптер применяет собственные правила. Каждый раз, когда мы пытались объединить эти правила за одной абстракцией, абстракция превращалась в ложь в тот момент, когда появлялся четвёртый случай.
3. Дорогой случай: выдумывание несуществующего правила
Это баг, которого я больше всего хотел бы уберечь другого инженера, потому что ошибка выглядит как добросовестность.
У OKX perpetuals нет минимума по стоимости. Мы этого не знали. Рассуждая по аналогии с другими площадками, мы вычислили его: минимальное число контрактов, умноженное на размер контракта и на цену. Выглядело разумно. Давало правдоподобную цифру в USDT. И было вымыслом.
Где это сломалось, стоит разобрать подробно. DCA-стратегия размещает свои страховочные ордера ниже цены входа. Наш синтетический минимум вычислялся при одной цене, а затем сравнивался с ордерами по более низкой цене, поэтому то же количество, которое проходило наверху лестницы, ниже проваливалось.
Результат: ордера ровно в 0.1 контракта, что и есть биржевой минимум и было бы принято, отклонялись нашим собственным валидатором ещё до того, как покидали нашу систему. Биржа их вообще не видела. Со стороны пользователя стратегия просто не делала того, на что была настроена.
Урок обобщается далеко за пределы трейдинга:
Не синтезируйте ограничение, которого API вам не дал. Если площадка задаёт минимум в контрактах, то минимум в контрактах и есть. Выведение эквивалента на основе стоимости это не осторожность, это выдумывание правила с последующим применением его против пользователя.
Признак, задним числом, был в том, что наше вычисленное значение зависело от цены, тогда как настоящее правило биржи от неё не зависело. Всякий раз, когда у вашей версии ограничения есть входной параметр, которого нет у настоящего, вы построили нечто иное, чем собирались смоделировать.
4. Отсутствующие метаданные должны быть ошибкой, а не значением по умолчанию
Последняя часть это политика, а не баг, и именно она удерживает первые три от тихого повторения.
Каждая биржа даёт нам метаданные по каждому символу: размер шага, шаг цены, минимальное количество, размер контракта. Любое из них может отсутствовать, и заманчиво поставить запасное значение. Нет размера шага? Предположим 0.001. Нет минимального количества? Предположим ноль и пусть решает биржа.
Мы делаем наоборот: обязательное поле, которого нет, выбрасывает ошибку, немедленно, до того как построен хоть какой-то ордер. Ничто ниже по цепочке не получает шанса строиться на догадке.
Обоснование это асимметрия исходов. Выброшенная ошибка это громкий сбой, который проявляется сразу, перед тем, кто может его исправить. Значение по умолчанию это тихий неверный ответ, отправляющий ордер неправильного размера и обнаруживающий себя позже, в деньгах. Между ошибкой, которую нельзя игнорировать, и числом, которое нельзя проверить, ошибка всякий раз обходится дешевле.
Это также означает, что слой метаданных честен насчёт того, что он знает. Код, читающий обязательное поле, получает реальное значение или исключение. Он никогда не получает заглушку, притворяющуюся реальными данными.
Что стоит вынести
- Считайте выравнивание по шагу и шагу цены арифметикой, которая может сдвинуть ваше значение, а не форматированием.
- Не объединяйте правила, которые действительно разные. Проверка по каждой площадке остаётся корректной по мере добавления площадок; общая абстракция над несовместимыми правилами нет.
- Никогда не выводите лимит, которого API не задавал. Если у вашей версии ограничения есть входной параметр, которого нет у настоящего, это другое ограничение.
- Падайте громко на отсутствующих метаданных. Значения по умолчанию превращают видимую ошибку в невидимый ордер с неверной ценой.
Работа с точностью неблагодарна и никогда не попадает в список фич. Но каждый из этих багов порождал либо ордер, который был неверен, либо ордер, которого не существовало вовсе, а в системе, которая торгует от чьего-то имени, это та же категория провала, что и прямая потеря его денег.
