Silent deadlock: как трейдинговый бот может молчать 13 дней — и что мы сделали
В предыдущей статье опционный бот стабилизировал payoff и закрыл серию багов вокруг live-торговли. Снаружи это выглядело как «всё работает» — бот живой, позиции на месте, дашборд рисует ровную белую линию payoff. Внутри тем временем накапливались три проблемы, которые не выдавали себя стандартными ошибками. О них — и о том, как мы научили бота сам сообщать, что он застрял, — эта статья.
От «работает» к «умеет замечать, когда работает неправильно»
Лайв-запуск — это не финиш. Это момент, когда у проекта впервые появляются проблемы, которых не было ни в backtest, ни в dry-run. Самый опасный их подкласс — те, что не пишут error в лог. Бот думает, что всё нормально, мониторинг тоже, а на счёте тем временем теряется день, два, неделя.
За последние две недели мы поймали три таких кейса. Два из них чинились быстро, третий — медленно и поучительно.
Три production-урока
BUG-122: token service latency
Опционный бот ходит за свежим API-токеном к небольшому локальному сервису. В нормальной ситуации это занимает десятки миллисекунд. В какой-то момент после live-launch токен-сервис стал блокировать write-lock на всё время HTTP-запроса к Finam — и любой второй вызов ждал секунды или вылетал по таймауту. Бот логировал «authentication retry» как обычный ретрай и продолжал работать с просроченным токеном.
Фикс был простой: освобождать лок на время сетевого вызова и поставить явные таймауты на reqwest-клиенте. Заодно прибавился отдельный health-check: бот теперь ходит на /health токен-сервиса и проверяет латентность, а не просто факт «процесс жив».
BUG-121: confirmed-fill / premium ledger
Когда бот форсированно закрывает или конвертирует ногу спреда, он раньше записывал ожидаемый payoff в options_entry_prices сразу же. На дашборде линия payoff на секунду «прыгала», потом возвращалась — это бот сам себе писал прогноз, а не подтверждение от биржи.
Решение — confirmed-fill semantics: новая таблица pending_forced_actions собирает ожидающие действия, а ledger обновляется только после того, как Finam подтвердит fill через trades-стрим. Линия payoff больше не мерцает; и, что важнее, premium ledger теперь точно отражает реальные исполнения, а не намерения бота.
BUG-123: silent deadlock
Это главный герой статьи.
Симптом был такой: на бирже у бота 5 опционных позиций. Бот живой — пишет в журнал каждые 30 секунд. Внутренний учёт spread_mgr.active_spreads — пуст, ноль. На дашборде — никаких активных спредов. Никаких ошибок. Никакого алёрта. И 13 дней без единого нового спреда.
Биржевые позиции бот видел, но не знал, что они кому-то принадлежат внутри его собственного учёта. Любая попытка открыть новый спред упиралась в overlap-guard: «эта геометрия уже занята существующими позициями» — но в active_spreads ничего не было, чтобы guard мог считать её занятой. Классическая голодовка: бот не падает, не ошибается, просто отказывается торговать.
Как заметили
Никакой runtime exception нас не разбудил. Заметили во время очередного ручного аудита: на дашборде вкладка Spreads пустая, а в psql у бота при этом висят пять опционных позиций. Несоответствие между внешним состоянием (5 позиций на бирже) и внутренним (0 активных спредов в учёте) — единственный сигнал, который остался. Стандартный мониторинг тут не помог бы: метрики «бот живой» и «бот пишет в журнал» обе горели зелёным.
Как поймали
Сначала — руками. Тремя SQL-вставками в таблицу spread_state мы заново «прописали» три спреда, которые висели на бирже, но потерялись в учёте. После этого active_spreads у бота стало 3, и при следующем рестарте он подхватил их с пометкой Activating restored spread: Bear Call (geometry mode preserved). Голодовка закончилась.
Дальше — root cause analysis. Виноваты оказались два старых бага в комбинации: BUG-093 (heuristic-disabled branch не писал в spread_state) плюс BUG-120 (overlap-guard слишком строго блокировал входы). По отдельности оба считались известными ограничениями. В комбинации они дали failure mode, который не пишет ошибок и поэтому не попадает ни в один стандартный alert.

Если бот жив 20 минут и не открывает ни одного спреда — это уже информация, даже без явной exception в логе.
Что сделали: Stage 1 — detector
Сейчас в боте появилась простая, но важная проверка. Каждый старт + каждые 20 итераций (~10 минут) бот спрашивает себя:
«У меня есть позиции на бирже, но ноль активных спредов в учёте. Это нормально?»
Если ответ «нет» — бот пишет WARN BUG-123: bot starved в журнал и одну строку в новую таблицу bot_health_log (миграция 034 создаёт её специально под Stage 1).

Это observability, не автоматическое восстановление. Бот не пытается чинить себя сам — это будет Stage 2, и мы делаем его отдельно, когда увидим, что Stage 1 действительно ловит происходящее. Пока что наша гипотеза — за следующие 3 дня в bot_health_log будет 0 записей, то есть голодовка не повторится. Если появятся — Stage 2 становится срочным.
Что дальше
- 2026-04-28 09:00 MSK — автоматическая проверка по cron на сервере: psql + journalctl, отчёт в markdown. Если detector не сработал ни разу за 3 дня — система стабильна.
- Stage 2 (auto-restore) — отдельный цикл, отдельная статья. Идея: когда бот видит «позиции есть, спредов нет», он пытается восстановить учёт по геометрии брокерских позиций — но только в строго детерминированных случаях, чтобы не выдумать спред там, где его не было.
spread_state.source— отдельная колонка-провенанс на будущее: различаетlive_trade(нормальный путь),reconstructed_unique(Stage 2 auto-restore) иoperator_manual(ручной SQL). Три записи от 2026-04-23/24 ретроспективно отмечены какoperator_manual. На текущий Stage 1 detector она напрямую не влияет — это scaffolding под Stage 2.
Урок
После live-launch проект перестаёт оцениваться количеством фич. Он оценивается тем, насколько быстро понятно, что что-то идёт не так. 13 дней — слишком долго. Хотелось бы узнавать в течение часа.
Stage 1 detector — один маленький шаг в эту сторону. Не финал, но первый шаг, после которого silent deadlock больше не сможет молчать 13 дней.