Идея no-code торгового бота проста: вы описываете стратегию в форме, а программа выполняет её за вас. Сложность не в форме. Она в том, что за каждое упрощение, которое вы предлагаете пользователю, где-то в системе приходится платить, обычно в том месте, где вы этого не ожидали.
Вот три вещи, в которых я ошибся насчёт этого, и то, чему каждая из них нас на самом деле научила. Они расположены по степени того, насколько сильно я их недооценил.
Главное
- API бирж дают сбой незаметно чаще, чем громко. HTTP 200 с меньшим объёмом данных, чем вы запросили, это тот тип отказа, от которого нужно защищаться проектированием.
- Никогда не доверяйте результату бэктеста, пока не протестируете сам симулятор. Красивая на вид кривая доходности может быть получена из сломанного прогона.
- Арифметика с плавающей точкой на количествах в ордерах порождает ошибки смещения на один шаг, которые проявляются только на определённых парах.
- Решение никогда не хранить средства пользователей убирает целый класс проблем и создаёт другую, меньшую, но более острую: ваша система должна быть корректной, при этом никогда не имея возможности что-либо отменить.
- Каждое удобство, которое вы предлагаете пользователю, где-то в движке становится ограничением. Такой обмен обычно оправдан, но он никогда не бесплатен.
1. Я предполагал, что неудавшийся запрос будет выглядеть как отказ
Именно это стоило больше всего времени, и именно об этом я в первую очередь предупредил бы любого разработчика.
Мы загружаем исторические свечи с бирж, чтобы прогонять бэктесты. Наивная ментальная модель такова: запрос либо успешен, либо возвращает ошибку. Вы получаете свои данные или получаете не-200 и обрабатываете его. Реальные API бирж под нагрузкой ведут себя не так. Они возвращают HTTP 200 с меньшим числом свечей, чем вы запросили, и без каких-либо признаков того, что что-то пошло не так.
Вот реальный случай из наших логов, и цифры точные. Долгопериодный бэктест запросил примерно 536 000 свечей. В 16:34 он получил 58 000. Статус 200. Никакого поля ошибки. Никакого предупреждения. Тот же самый запрос, без изменений, в 17:54 получил полные 535 000.
Подумайте, что это значит, если вы не проверяете. Движок спокойно симулирует стратегию на одиннадцати процентах предполагаемой истории, выдаёт правдоподобную на вид кривую доходности и сообщает результат. Пользователь читает число, которое ошибочно так, что ничто в выводе этого не показывает. Это хуже сбоя. Сбой честен.
Исправление концептуально скучное, и его было легко пропустить: после загрузки проверьте, что полученные данные действительно покрывают запрошенный период, и рассматривайте недобор как отказ, даже если транспорт сообщил, что всё в порядке. Покрытие это свойство, которое нужно проверять явно, а не то, которое можно принять на веру.
За первым уроком прятался урок второго порядка. Как только мы начали обнаруживать усечение, очевидным ходом было завершить прогон с ошибкой. Но цифры выше показывают, что эти усечения временные: идентичный запрос час спустя вернул полные данные. Полный отказ сжёг бы квоту пользователя из-за того, что по сути является временным сбоем на стороне биржи. Поэтому правильное поведение это повторить попытку один раз перед отказом, а это означало переработку пути обработки ошибок так, чтобы повторяемое условие не помечалось окончательно как отказ на выходе.
Из этого вышли два правила, и оба я взял бы в любой проект, который общается со сторонним API:
- Проверяйте форму успешного ответа, а не только его код статуса. Код статуса говорит вам, что запрос завершился. Он не говорит, что ответ это то, что вы запросили.
- Различайте временные отказы и постоянные, прежде чем решить, что делать. В момент возникновения они выглядят одинаково, а заслуживают противоположных реакций.
2. Я предполагал, что результат бэктеста это результат
Родственная, но отдельная ошибка, и она глубже проблемы усечения.
Когда вы строите симулятор, свои усилия на тестирование вы тратите на стратегии. Правильно ли ведёт себя логика DCA? Срабатывают ли выходы там, где должны? Это интересный вопрос, поэтому именно туда естественным образом уходит внимание.
Проблема в том, что сломанный симулятор выдаёт вывод, который выглядит точно так же, как у рабочего. Прогон, который незаметно обработал часть данных, всё равно выдаёт число. Прогон, чьи условия выхода ни разу не были вычислены, всё равно выдаёт число, причём подозрительно плоское. Мы видели результаты, возвращавшиеся ровно +0.00% при нуле сделок, а это самый громкий возможный сигнал, что с механикой что-то не так, и его же легче всего неверно истолковать как «эта стратегия ничего не делает».
Сдвиг, который это исправил, изменил то, что мы тестируем. Тестируйте симулятор, а не стратегию. Конкретно это означает проверять вещи, которые стратегия не должна иметь возможности изменить: что число обработанных свечей совпадает с числом загруженных, что прогон по известному периоду действительно охватывает этот период, что конфигурация, гарантированно вызывающая срабатывание, порождает сделки. Когда эти инварианты выполняются, странно выглядящий результат это реальная находка о стратегии. Когда нет, результат это шум в костюме.
Общая форма этого для всего, что симулирует: симулятор, который не может дать сбой громко, даст сбой тихо, а тихий сбой в системе, которая производит числа, это худший тип отказа из существующих. Люди действуют по числам. Они не действуют по стек-трейсам.
3. Я недооценил, насколько большой работой является точность в расчёте размера ордера
Эта задача не является интеллектуально трудной. Она просто гораздо больше, чем выглядит, и прячется в месте, где ошибки дорого стоят.
Биржи не принимают произвольные количества. У каждой торговой пары есть шаг для количества (step size) и шаг для цены (tick size), и ордера, которые им не соответствуют, отклоняются. Поэтому каждое количество, которое вычисляет система, перед отправкой должно быть округлено вниз до допустимого шага. Звучит как одна вспомогательная функция.
Затем появляется плавающая точка. Рассмотрите округление вниз 0.29 до шага 0.01. Умножьте на масштаб, и IEEE-754 даёт вам 28.999999999999996, что округляется вниз до 28, что масштабируется обратно в 0.28. Значение уже было выровнено по шагу. Арифметика сместила его на целый шаг вниз, и сделала это незаметно, в сторону меньшего ордера. Исправление требует эпсилона на шаге масштабирования, достаточно малого, чтобы по-настоящему меньшее значение вроде 0.289999 всё равно корректно округлялось вниз, и достаточно большого, чтобы поглотить шум представления.
Это однострочное исправление, найти которое потребовало настоящей отладки, потому что симптом это не исключение. Это ордер чуть меньше, чем задумано, на одних парах и не на других, в зависимости от того, где шаг попадает относительно двоичного представления.
Более широкий вывод и причина, по которой я отношу это к недооценённому: точность в торговой системе это не вспомогательная функция, это категория работы. Шаги количества и шаги цены различаются от пары к паре, от биржи к бирже и между спотом и фьючерсами. Минимальные значения ордера это не одно число, которое можно найти и захардкодить. Они приходят от биржи для каждого символа, а на некоторых площадках они зависят от текущей цены. Всякий раз, когда вы пишете одну константу для того, что биржа определяет для каждой пары, вы пишете баг с часовым механизмом.
Мы показываем это пользователям, а не скрываем, потому что альтернатива это ордер, который отклоняется по причинам, которых они не видят.
4. Ограничение, которое я не ожидал полюбить
Последнее это не ошибка. Это решение, последствия которого я недооценил в хорошем смысле.
Freya никогда не хранит торговые средства пользователей. Боты торгуют через API-ключи биржи с разрешением читать и торговать, но никогда выводить. Деньги всё время остаются на биржевом счёте пользователя.
Я ожидал, что это будет маркетинговым аргументом. Оказалось, что архитектурным, и это сделало систему меньше. Нет реестра хранения средств, нет внутреннего баланса, который нужно сверять с внешним, нет очереди на вывод, нет политики холодного кошелька для торгового капитала. Целая категория самых трудных проблем в финансовом ПО просто отсутствует, потому что мы никогда не брали на себя то, что их создаёт.
На её месте появляется нечто более узкое и острое: ваша система должна быть корректной в реальном времени, потому что она не может ничего отменить. При хранении средств вы, в принципе, можете отменить внутренний перевод. Размещение ордера на чужом биржевом счёте становится окончательным в момент исполнения. Это переносит весь инженерный вес на корректность до отправки запроса, а не на сверку после, и такой обмен я совершил бы снова.
Что я сказал бы себе в самом начале
- Исходите из того, что каждый сторонний API иногда завершается успехом некорректно, и проектируйте его обнаружение прежде, чем оно вам понадобится.
- Пишите инвариантные тесты для своего симулятора прежде, чем писать проверки о стратегиях. Тесты стратегий бесполезны, пока не проходят тесты симулятора.
- Относитесь к точности как к области, а не как к помощнику.
- Выбирайте свои ограничения рано. Те, что вы принимаете заранее, обходятся гораздо дешевле тех, что вы обнаруживаете позже.
Ни одна из этих проблем не была экзотической. Это были обычные инженерные проблемы, которые при планировании я отнёс к «мелким», а именно так они и становятся дорогими.
