Флот автономных ботов и бэктест-движок: инженерия надёжности
Собственная внутренняя разработка в NDA-домене «финтех»: десяток автономных сервисов, которые принимают решения в реальном времени по потоку внешних событий. Кейс не про доходность — про инженерию: сбор данных с провенансом, демоны, которые не умирают тихо, бэктест по реальной истории вместо веры в красивую кривую, и риск-контур, который выключает систему раньше человека.
Автономная система ломается тихо
У фонового процесса нет пользователя, который пожалуется. Он продолжает крутиться, писать логи и честно рапортовать «жив» — при том что данных он уже не получает, а решения принимает по устаревшему состоянию. Между поломкой и её обнаружением проходят часы, и всё это время система выглядит здоровой.
Три реальных сценария из этого проекта. Первый: внешний API поменял одно слово в названии
инструмента — парсер перестал распознавать часть потока, поломку заметили спустя
10 часов. Второй: сокет открыт,
исключений нет, события идут — но события одного конкретного типа не приходят уже
20+ минут; общий watchdog по «последнему событию» молчал, потому что остальные
типы продолжали течь. Третий: watchdog часами писал
restarts=0 failures=0,
пока два процесса были мертвы — pgrep -f
без якоря матчил командную строку соседних шеллов, включая собственную.
И четвёртый класс, самый дорогой: система не падает, а тихо врёт. Офлайн-симулятор определял исход сделки по неверному моменту фиксации — совпадение с реальным правилом расчёта оказалось 51%, то есть подбрасывание монеты. Всё, что было построено поверх этого симулятора, месяцами выглядело как аналитика, а было шумом. Пока никто не проверил само правило расчёта, отличить одно от другого было нечем.
Пять контуров, каждый проверяется отдельно
Система принятия решений в реальном времени — это не «алгоритм». Это пять независимых контуров: сбор данных, живучесть демонов, наблюдаемость, офлайн-проверка гипотез и риск-контур. Ломается любой из них по отдельности, поэтому каждый должен быть проверяем без остальных.
Событийный WS-поток + REST-догон 5 с. Каждая запись в SQL с провенансом: источник, время загрузки, загрузчик
Python double-fork launcher вместо bash-детача, systemd Restart=always + journald
Watchdog отдельно на каждый тип события, canary на дрейф схемы внешнего API, дайджест в мессенджер
Excursion-replay по историческим леджерам: реальные экскурсии, дедуп, сегментация по режимам
Многослойный монитор по расписанию, файл-HALT как kill-switch, возврат в работу только через скрипт
Bash не отцепляет демон на macOS — это проверено, а не мнение
Перезапуск процесса из скрипта выглядит тривиально ровно до первого зависания. Пять вариантов
фонового запуска были проверены эмпирически, и все пять оставляли родительский шелл висеть в
__wait4
на якобы отцепленном потомке — один процесс провисел так 1 ч 32 мин:
nohup … & disown— классика, которая не работаетset -m+ disown, запуск в подоболочке, вложенные варианты форка — тот же результат- в macOS нет
setsid(1), поэтому «правильного» шелл-способа просто не существует
Решение — один общий launcher на Python:
fork → setsid → fork → execvp,
внук гарантированно получает PPID=1.
Все перезапуски в проекте идут только через него; для launchd-задач дополнительно выставлен
AbandonProcessGroup,
иначе система прибирает потомков вместе с завершившимся скриптом.
Живучесть меряется по каждому типу события отдельно
Базовый watchdog отслеживает время последнего события любого типа и убивает процесс, если тишина длится дольше 360 секунд — порог подобран эмпирически, на 180 с ловились ложные срабатывания на штатных паузах цикла. Супервизор поднимает сервис обратно, поэтому watchdog может позволить себе просто выйти с особым кодом возврата.
Но это лечит только полную смерть соединения. Реальный инцидент был тоньше: перестал приходить один тип события, остальные текли как обычно — и общий счётчик тишины не сработал ни разу. Отсюда правило, которое теперь применяется ко всем сервисам: liveness — это не «процесс жив», а «когда я последний раз получил то, ради чего я живу». У каждого критичного типа события свой счётчик, в health-отчёт выводится «сколько минут назад» по каждому потоку, плюс превентивная переподписка по таймеру.
Проверка «процесс жив» должна быть честной
Шаблоны pgrep -f
обязаны быть заякорены на конец аргументов
(…/book_recorder\.py$).
Без якоря проверка матчит любой соседний шелл, у которого искомая строка оказалась в командной
строке — включая сам вызывающий скрипт. Мониторинг, который сам себя считает живым процессом,
хуже отсутствия мониторинга: он выдаёт зелёный статус на мёртвую систему.
SQL-first и провенанс на каждую строку
Правило для всех новых писателей: данные пишутся в SQL, а JSONL допустим только как скользящий
буфер с ретеншеном ≤48 ч и ежедневной ротацией. У каждого датасета — строка в
data_provenance
(источник, статус данных, заметки аудита, можно ли удалять), у каждой записи —
ingested_at
и ссылка на исходный датасет. Повод был предельно бытовой: аудит нашёл десятки гигабайт
дубликатов, которые никто не мог удалить, потому что никто не мог доказать их происхождение.
- живые писатели открывают соединение на flush и сразу закрывают — блокировка держится миллисекунды, а не всё время жизни процесса
- база с непрерывной записью — single-writer; аналитические подключения только
read_only=True - новый живой писатель получает отдельный файл базы, а не делит его с существующим
Записи о решениях не удаляются никогда
Постмортем, из которого выросло отдельное правило: около пяти недель истории решений исчезли под давлением на диск — штатная ротация архивов снесла файлы, которые невозможно восстановить. Ответ — append-only хранилище: харвест всех записей о входах и закрытиях из живых прогонов и из архивов, дедуп по хешу строки (операция идемпотентна, можно гонять хоть каждые 15 минут), синхронизация на офсайт-копию. Главное правило вшито в код чистки: харвест выполняется до любого удаления, и если он не удался — удаление отменяется целиком. Диск дешевле данных, всегда.
Событийная реакция вместо опроса по таймеру
Опрос раз в 30 секунд — нормальное решение, когда события редкие. Здесь окна принятия решения живут секунды, поэтому реакция идёт на входящее WS-событие, а REST остаётся догоняющим сканом с интервалом 5 с. Полный путь измерен по компонентам, чтобы спорить о задержках можно было цифрами, а не ощущениями:
- подключение и подписка — ~40 мс; запрос детали — ~26 мс; чтение внешнего состояния — ~35 мс; отправка — ~41 мс
- криптографическая подпись — 0,24 мс после замены чисто-питоновского бэкенда на нативный; до замены она была на три порядка дороже и съедала весь выигрыш
- фоновые кэши и параллельные запросы вместо последовательных ожиданий — ещё десятки миллисекунд
- быстрый путь целиком — 250–350 мс; ниже без смены архитектуры не опуститься, дальше упор в сеть
Ничего экзотического — всё держится на дисциплине
WS-клиенты, циклы принятия решений, watchdog-корутины рядом с рабочим кодом
Событийная реакция как основной путь, опрос — только догоняющий скан на 5 с
SQL-first хранилище, data_provenance, single-writer + read-only для аналитики
10 сервисов на одном хосте, перезапуск без ручных действий, логи в одном месте
Фоновые задачи на macOS с гарантированным PPID=1 и AbandonProcessGroup
Синхронизация хранилища записей, резолверы исходов, периодические сверки
Только там, где потребителю нужен сырой поток; всё остальное — в SQL
Баг закрывается тестом, который бы его поймал, — иначе он вернётся
Статус всех сервисов + «сколько минут назад» по каждому потоку событий
Правила с датой доказательства, инцидентом и условиями отмены
Система не стала непадающей — она стала честной
порог watchdog по типам событий + canary на дрейф схемы
на одном хосте, Restart=always + journald, перезапуск без человека
совпадение с реальным правилом расчёта после фикса — на всей проверенной выборке, она пока небольшая
Журнал инвариантов вместо устной памяти
Каждое найденное правило записывается как пронумерованный инвариант: дата, когда правило доказано, инцидент, который его породил, инструкция «как применять» и — отдельным пунктом — условия, при которых правило перестанет действовать. Правило не появляется из головы: оно появляется после инцидента и содержит способ его воспроизвести. За счёт этого одна и та же ошибка не переоткрывается через полгода на новом сервисе.
Бэктест, который не льстит
Проверять фикс на живом запуске — значит ждать дни и всё равно не иметь статистики. Вместо этого фикс прогоняется по историческим леджерам методом excursion-replay: у каждой закрытой записи сохранены реальные экстремумы движения, поэтому контрфактический результат считается по тому, как всё двигалось на самом деле, а не по пересимуляции. Методика зафиксирована как набор обязательных правил:
- сначала метрики без допущений — распределения и доли достижения уровней; если движения нет вообще, никакая настройка выходов не поможет
- дедуп реплик: один и тот же сигнал размножается по конфигурациям примерно в 12 раз — без дедупа выборка фиктивно раздувается
- сегментация по режиму рынка и по типу сигнала; смешанное число прячет, что система выигрывает в одном режиме и проигрывает в другом
- границы для неоднозначных случаев: если порядок событий внутри бара неизвестен, считаются оптимистичная и пессимистичная оценки; вывод принимается, только если он одинаков в обеих
- контроль случайным входом: та же логика выхода на случайных входах должна проигрывать — иначе «эффект» создан не сигналом, а механикой выхода
- n ≥ 30 в каждой ячейке, out-of-sample и walk-forward, поправка на множественную проверку гипотез
Практический эффект неприятный и полезный одновременно: большинство идей, которые выглядели работающими, на этой методике отваливаются. Из восьми проверенных механических стратегий выжили две. Это и есть результат — знать, что остальные шесть не работают, до того как за них заплачено.
Риск-контур выключает раньше человека
Многослойный монитор ходит по расписанию и проверяет несколько независимых условий остановки. Останов оформляется файлом-маркером, который видят все процессы, — это простой и надёжный kill-switch без сетевой зависимости. Дальше действует дисциплина: маркеры остановки не редактируются руками, возврат в работу — только через штатный скрипт, который проверяет причину останова. И отдельное правило для новых фильтров: сначала audit-режим, где фильтр только логирует своё решение, и лишь после накопления данных он получает право блокировать.
Общий итог, если убрать детали: цифрам, которые выдаёт система, стало можно верить, а отказы стали видимыми в минутах, а не в часах. Для автономной системы это и есть главная фича — всё остальное строится поверх.
Где ещё ложится та же методология
Это кейс не про трейдинг. Это типовая задача «автономный процесс принимает решения по потоку внешних событий, и никто не жалуется, когда он ломается». Контур переносится почти без изменений:
- → Интеграции с внешними API, где формат меняется без предупреждения — canary на дрейф схемы вместо разбора инцидента постфактум
- → Фоновые обработчики и очереди, где «процесс жив» ≠ «работа идёт»: liveness по типам событий, а не по PID
- → ML и аналитика, где офлайн-оценка расходится с продом — сначала проверяется правило расчёта целевой метрики, потом модель
- → Автономные LLM-агенты, которые тратят деньги или совершают действия — им нужны тот же kill-switch, audit-режим и лимиты
- → Датасеты под аудит — провенанс на строку единственный способ через полгода ответить, откуда взялась цифра в отчёте
- → Телеметрия, IoT, логистика — тот же класс: поток событий, узкие окна реакции, отказы без единого сообщения об ошибке
- Launcher с двойным форком + шаблоны юнитов systemd и launchd — фон, который действительно отцепляется
- Watchdog по типам событий + canary на дрейф схемы внешнего API + health-отчёт со временем последнего события
- SQL-first слой с data_provenance, паттерном open-on-flush и разделением writer / read-only
- Append-only хранилище записей: харвест до любой чистки, отмена чистки при сбое харвеста, офсайт-копия
- Методика офлайн-проверки: дедуп, сегментация, границы для неоднозначных случаев, контроль случайным входом
- Формат журнала инвариантов: дата доказательства, инцидент, как применять, когда правило умрёт
Если процесс работает без надзора, вопрос не «упадёт ли», а узнаете ли вы об этом
Начинать имеет смысл с двух измерений: через сколько минут вы узнаете о тихом отказе и считает ли офлайн-оценка то же самое, что прод. Обычно этих двух проверок хватает, чтобы найти первые две-три дыры — до того, как они найдутся сами.
Аудит за 5 000 ₽ — с конкретным отчётом и сметой
Расскажу что внедрить в вашем бизнесе в первую очередь, какая будет окупаемость, и нужен ли вообще AI для вашей задачи (иногда — нет).
Или просто напишите свой вопрос — отвечу в течение 2 часов