Торговый робот: что нужно для автономной работы с реальными деньгами

от автора

Материал посвящён разработке программного обеспечения. Саму стратегию и результаты её работы я не раскрываю, инвестиционных рекомендаций здесь тоже нет.

Если стратегия теряет деньги, её не спасут идеальная архитектура, модный стек и сотни автоматических тестов. Можно завернуть убыточный алгоритм хоть в Kubernetes — прибыльным он от этого не станет.

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

Недавно я закончил разработку торгового робота для работы через T-Invest API. В этой статье покажу чем боевой торговый продукт отличается от скрипта, который научился отправлять заявки брокеру.

Для меня граница довольно простая.

Если робот работает только тогда, когда пользователь сидит рядом, смотрит в лог и готов в любой момент вмешаться, это ещё не автономная торговля. Это полуавтоматическая система с дополнительным источником стресса.

Боевой продукт начинается тогда, когда можно уйти на встречу или уехать в отпуск и не проверять каждые пять минут, что робот там натворил.

Интерфейс торгового робота: настройки, заявки, позиции и журнал работы.

Интерфейс торгового робота: настройки, заявки, позиции и журнал работы.

Первый прототип почти всегда выглядит прилично

На базовом уровне любой торговый робот устроен элементарно:

  • получить рыночные данные

  • проверить торговое условие

  • сформировать сигнал

  • отправить заявку брокеру

Такой прототип можно написать довольно быстро. И он даже будет торговать. Проблема в том, что реальный рынок не обязан вести себя так аккуратно, как код из учебного примера.

Заявка может исполниться частично. API может не ответить. Ответ может потеряться. Пользователь может вручную изменить портфель. Цена может оказаться старой. Приложение может закрыться в тот момент, когда брокер уже принял поручение, но локальное состояние ещё не обновилось.

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

Заявку отправили — это ещё не сделка

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

Поэтому сначала заявка создаётся внутри самого робота. Она получает собственный идентификатор и локальный статус PREPARED. Только после этого запрос отправляется брокеру. Упрощённо техническое состояние выглядит так:

PREPARED → SENT / FAILED / CANCELLED

При этом SENT не означает, что заявка полностью исполнена. Отдельно сохраняются брокерский статус и фактически исполненный объём.

В базе остаются идентификатор заявки у брокера, запрошенное количество лотов, цена исполнения, итоговая сумма и текст ошибки, если она возникла.

Позиция робота меняется только после подтверждённого исполнения. Запросили десять лотов, исполнилось четыре — значит, позиция увеличилась на четыре. Не на десять и не на предполагаемый объём. Звучит банально, но торговый код нередко пишут примерно так:

await buy() position += quantity

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

Робот не имеет права считать весь портфель своим

На одном брокерском счёте может одновременно работать робот и торговать сам пользователь. Например, робот купил пять лотов. Через час владелец счёта самостоятельно докупил ещё три. У брокера теперь восемь лотов, но робот открыл только пять. Если программа просто получает общий размер позиции и считает его своим, то при выходе она может продать все восемь лотов.

Для меня это не мелкая неточность. Это прямой косяк реализации: робот полез распоряжаться бумагами, которые ему не принадлежат. Поэтому внутри приложения отдельно учитываются три величины:

robot_lots = 6  # сколько купил сам роботbroker_lots = 10  # сколько фактически находится у брокераexternal_lots = 4  # — сколько появилось помимо робота

Перед запуском система синхронизируется с реальным портфелем. Если пользователь вручную продал часть позиции, количество лотов робота уменьшается. Если у брокера бумаг больше, чем числится за роботом, разница считается внешней позицией. Робот её видит, но автоматически не трогает. Брокер остаётся источником фактического состояния. Однако знать только общий объём мало — нужно понимать происхождение позиции.

Перезапуск не должен обнулять память робота

Ещё одна граница между скриптом и боевой системой — поведение после перезапуска.

Робот не может утром загрузиться с пустыми переменными и решить, что заявок и позиций у него нет. Перед продолжением работы он должен получить портфель брокера, загрузить собственное состояние из базы и сверить одно с другим. Если локально числится позиция, которой у брокера уже нет, она обнуляется. Если реальный объём меньше — позиция робота уменьшается. Если больше — разница считается внешней.

С заявками ситуация сложнее.

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

Запуск робота без предварительной синхронизации — это, по сути, торговля с потерей памяти. На тестовом счёте можно поэкспериментировать. На реальном — нет.

Последняя цена иногда уже никому не нужна

API вернул цену — отлично. Только когда по этой цене прошла последняя сделка?

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

Я здесь придерживаюсь простой позиции: пропущенная сделка лучше сделки, открытой по протухшим данным.

Цены по рабочему списку запрашиваются пачками. Если данные не пришли по одному инструменту, весь торговый цикл не падает. Конкретная акция пропускается, причина записывается в журнал, а остальные продолжают обрабатываться. Останавливать всю систему из-за одного проблемного инструмента — слишком хрупкое решение.

Тестовый режим должен проверять торговлю, а не наличие кнопки

Можно добавить флаг:

LIVE_TRADING = False

При выключенном флаге заявки не отправляются. Формально тестовый режим существует. Практической пользы от него почти нет.

Нужно видеть не только сигнал, но и решение робота целиком: какой инструмент он выбрал, сколько лотов рассчитал, сколько денег потребовалось и почему операция была разрешена или пропущена. Поэтому в тестовом режиме приложение сохраняет полноценные планы сделок. Рыночные данные реальные, расчёты реальные, ограничения тоже реальные. Не отправляется только заявка брокеру.

Так можно посмотреть, как система действительно собиралась торговать, а не просто убедиться, что внутри программы иногда появляется слово BUY.

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

Сигнал не даёт роботу право немедленно покупать

Стратегия сформировала сигнал. Дальше система обязана проверить, можно ли эту сделку вообще совершать. В моём случае учитываются свободные деньги, общий лимит капитала робота, размер одной операции, стоимость лота, наличие уже открытой позиции и доступность инструмента для торговли через API.

Если пирамидинг не предусмотрен стратегией, вторую позицию по тому же инструменту робот не открывает.

Отдельно блокируется повторный вход сразу после выхода в том же цикле. Иначе можно получить довольно глупую ситуацию: робот закрыл позицию и через несколько секунд снова открыл её по всё ещё активному сигналу.

  • Торговая логика говорит, что хочется сделать.

  • Слой исполнения решает, разрешено ли это делать сейчас.

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

Почему я сделал отдельное приложение

Робот написан на Python. Графический интерфейс сделан на PySide6, обмен с брокером идёт через gRPC, локальное состояние хранится в SQLite.

Я не считаю, что каждому торговому роботу обязательно нужен GUI. Серверная система вполне может годами работать без единого окна. Но если приложение передаётся пользователю, интерфейс нужен. Человек должен видеть, что происходит с его деньгами, не разбирая логи в терминале.

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

Сетевые запросы и постоянный мониторинг выполняются не в основном потоке, поэтому окно не зависает во время обращения к API.

Есть и ручное управление: можно выставить или отменить заявку, закрыть позиции робота, временно запретить покупки либо продажи.

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

Готовое приложение собирается под Windows. Пользователь получает запускаемый продукт, а не папку с Python-файлами и инструкцию по созданию виртуального окружения.

А что насчёт отпуска?

В начале я написал, что боевого робота можно оставить работать не только на время встречи, но и уехать в отпуск. Здесь нужно разделять две задачи. Одно дело — автономная работа внутри торговой сессии. Робот запущен, соединение с брокером есть, база доступна, а пользователь несколько часов не смотрит на экран.

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

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

Интересно, где эту границу проводят другие разработчики и трейдеры. Какие сбои или расхождения состояний оказались самыми неприятными в ваших системах?

ссылка на оригинал статьи https://habr.com/ru/articles/1067006/