Перейти к содержимому
VC
Кейс № 26 · Python · Данные

Флот автономных ботов и бэктест-движок: инженерия надёжности

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

Индустрия
Финтех (NDA) · собственная разработка
Стек
Python · asyncio · WebSocket · DuckDB / SQLite
Формат
Собственный инструмент, не клиентский проект
Итог
Тихие отказы стали видимыми
01 · Боль

Автономная система ломается тихо

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

Три реальных сценария из этого проекта. Первый: внешний API поменял одно слово в названии инструмента — парсер перестал распознавать часть потока, поломку заметили спустя 10 часов. Второй: сокет открыт, исключений нет, события идут — но события одного конкретного типа не приходят уже 20+ минут; общий watchdog по «последнему событию» молчал, потому что остальные типы продолжали течь. Третий: watchdog часами писал restarts=0 failures=0, пока два процесса были мертвы — pgrep -f без якоря матчил командную строку соседних шеллов, включая собственную.

И четвёртый класс, самый дорогой: система не падает, а тихо врёт. Офлайн-симулятор определял исход сделки по неверному моменту фиксации — совпадение с реальным правилом расчёта оказалось 51%, то есть подбрасывание монеты. Всё, что было построено поверх этого симулятора, месяцами выглядело как аналитика, а было шумом. Пока никто не проверил само правило расчёта, отличить одно от другого было нечем.

02 · Решение

Пять контуров, каждый проверяется отдельно

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

01
Сбор

Событийный WS-поток + REST-догон 5 с. Каждая запись в SQL с провенансом: источник, время загрузки, загрузчик

02
Демоны

Python double-fork launcher вместо bash-детача, systemd Restart=always + journald

03
Наблюдаемость

Watchdog отдельно на каждый тип события, canary на дрейф схемы внешнего API, дайджест в мессенджер

04
Бэктест

Excursion-replay по историческим леджерам: реальные экскурсии, дедуп, сегментация по режимам

05
Риск

Многослойный монитор по расписанию, файл-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 мс; ниже без смены архитектуры не опуститься, дальше упор в сеть
03 · Стек

Ничего экзотического — всё держится на дисциплине

Python 3 · asyncio

WS-клиенты, циклы принятия решений, watchdog-корутины рядом с рабочим кодом

WebSocket + REST fallback

Событийная реакция как основной путь, опрос — только догоняющий скан на 5 с

DuckDB / SQLite

SQL-first хранилище, data_provenance, single-writer + read-only для аналитики

systemd · Restart=always · journald

10 сервисов на одном хосте, перезапуск без ручных действий, логи в одном месте

launchd + double-fork launcher

Фоновые задачи на macOS с гарантированным PPID=1 и AbandonProcessGroup

Cron / таймеры

Синхронизация хранилища записей, резолверы исходов, периодические сверки

JSONL rolling buffer (≤48 ч)

Только там, где потребителю нужен сырой поток; всё остальное — в SQL

Регресс-тесты на каждый фикс

Баг закрывается тестом, который бы его поймал, — иначе он вернётся

Дайджест в мессенджер

Статус всех сервисов + «сколько минут назад» по каждому потоку событий

Журнал инвариантов (I-NNN)

Правила с датой доказательства, инцидентом и условиями отмены

PythonasyncioWebSocketDuckDBSQLitesystemdjournaldlaunchdcrondouble-forkwatchdogdata_provenance
04 · Результат

Система не стала непадающей — она стала честной

Порог обнаружения тихого отказа
10 ч 6 мин

порог watchdog по типам событий + canary на дрейф схемы

Сервисов под супервизором
10

на одном хосте, Restart=always + journald, перезапуск без человека

Точность офлайн-симулятора
51% 100%

совпадение с реальным правилом расчёта после фикса — на всей проверенной выборке, она пока небольшая

Журнал инвариантов вместо устной памяти

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

Бэктест, который не льстит

Проверять фикс на живом запуске — значит ждать дни и всё равно не иметь статистики. Вместо этого фикс прогоняется по историческим леджерам методом excursion-replay: у каждой закрытой записи сохранены реальные экстремумы движения, поэтому контрфактический результат считается по тому, как всё двигалось на самом деле, а не по пересимуляции. Методика зафиксирована как набор обязательных правил:

  • сначала метрики без допущений — распределения и доли достижения уровней; если движения нет вообще, никакая настройка выходов не поможет
  • дедуп реплик: один и тот же сигнал размножается по конфигурациям примерно в 12 раз — без дедупа выборка фиктивно раздувается
  • сегментация по режиму рынка и по типу сигнала; смешанное число прячет, что система выигрывает в одном режиме и проигрывает в другом
  • границы для неоднозначных случаев: если порядок событий внутри бара неизвестен, считаются оптимистичная и пессимистичная оценки; вывод принимается, только если он одинаков в обеих
  • контроль случайным входом: та же логика выхода на случайных входах должна проигрывать — иначе «эффект» создан не сигналом, а механикой выхода
  • n ≥ 30 в каждой ячейке, out-of-sample и walk-forward, поправка на множественную проверку гипотез

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

Риск-контур выключает раньше человека

Многослойный монитор ходит по расписанию и проверяет несколько независимых условий остановки. Останов оформляется файлом-маркером, который видят все процессы, — это простой и надёжный kill-switch без сетевой зависимости. Дальше действует дисциплина: маркеры остановки не редактируются руками, возврат в работу — только через штатный скрипт, который проверяет причину останова. И отдельное правило для новых фильтров: сначала audit-режим, где фильтр только логирует своё решение, и лишь после накопления данных он получает право блокировать.

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

05 · Применимость

Где ещё ложится та же методология

Это кейс не про трейдинг. Это типовая задача «автономный процесс принимает решения по потоку внешних событий, и никто не жалуется, когда он ломается». Контур переносится почти без изменений:

  • Интеграции с внешними 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 часов