Skip to content

Наблюдаемость pm-engine: три приёмника и правила маршрутизации ​

Уровень: target. Источник правды по вопросу «куда идёт какое наблюдение и что оно означает». Схема аналитического хранилища — deploy/server/analytics/schema.sql (там же обоснование каждой таблицы). Реализация — crates/pm-engine/src/observability/.

Зачем документ ​

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

Разбор боевой сессии 12–16.08.2026 упёрся не в пороги, а в наблюдаемость: воронку входа за прошедшую сессию восстановить было нечем (кольцо 300 секунд), счётчики обнулялись каждым перезапуском (двадцать стартов за семнадцать часов торговли), а сравнение эпох не разрешалось (восемнадцать версий конфигурации на сорок две позиции). Отсюда три приёмника вместо одного и правило маршрутизации, которое здесь и записано.

Главное свойство всего слоя: НАБЛЮДЕНИЕ не имеет права мешать торговать. Отказ записи наблюдения оставляет движок работающим — в любом из трёх приёмников, включая часовые витрины в SQLite. Утверждение относится именно к наблюдениям: ДОМЕННАЯ персистентность движка (сигналы, заявки, позиции, журнал событий) под него не подпадает — это состояние, без которого продолжать работу нельзя, и её отказ торговлю останавливает. Обратное — недоступное хранилище НАБЛЮДЕНИЙ останавливает торговлю — есть тот класс зависимости, ради устранения которого хранилище вынесено в отдельный контейнер.

Три приёмника ​

ПриёмникЧто принимаетЗерноСрокОтказ означает
SQLite движка (engine-state.db)доменное состояние: сигналы, заявки, позиции, журнал событий; ПЛЮС две часовые витрины операторского дашборда (balance_hourly, budget_hourly) — см. названное исключение нижедоменное — строка предметной области; витрины — ЧАСОВАЯ корзинадоменное — пока не стёрто оператором; витрины — окно ЧТЕНИЯ 168 часовых корзин; физическая очистка идёт транзакцией УСПЕШНОЙ записи, поэтому без записей строки живут дольше окна (см. абзац об окне чтения ниже)доменная запись — ОТКАЗ ТОРГОВЛИ (это состояние, а не наблюдение); запись витрины — торговля идёт, наблюдение потеряно
VictoriaMetricsчисловые ряды с закрытыми меткамиобразец раз в СЕКУНДУ (deploy/server/observability/scrape.yml)12 месяцевпанели слепнут, торговля идёт
ClickHouse (pm_analytics)наблюдения решений, переходов, витриныстрока наблюденияпо сроку удержания таблицыархив не пополняется, торговля идёт

Правило маршрутизации — по ВОПРОСУ, а не по типу данных:

  • «какое сейчас значение» и «как оно менялось числом» → VictoriaMetrics;
  • «что именно произошло с этим рынком, этой заявкой, этой позицией» → ClickHouse;
  • «что движок обязан помнить, чтобы продолжить работу после перезапуска» → SQLite.

Названное исключение: часовые витрины операторского дашборда живут в SQLite. Это balance_hourly и budget_hourly — часовые ряды баланса кошелька и пары базового/эффективного бюджета стратегии за 168 часовых корзин. По букве правила выше числовая история принадлежит VictoriaMetrics, и для ИНЖЕНЕРНЫХ рядов тех же величин это остаётся верным: секундные ряды, rate() и сравнение «до и после» — там.

Исключение — принятое РЕШЕНИЕ, а не следствие какого-то запрета. Утверждать «единственный потребитель — дашборд» нельзя: денежная сверка форензики читает balance_hourly напрямую из engine.db (docs/analysis/harvester-engine-session-2026-08-12-postmortem.md:56-60). Утверждать «у дашборда только один источник» тоже нельзя: он ходит в Gamma и Data API, в Local API движка, в wallets.db и config.db, в Polygon RPC (crates/pm-engine-dashboard/src/routes/mod.rs:35-41, crates/pm-engine-dashboard/src/config.rs:10-23). Фактическое положение дел такое: клиента VictoriaMetrics у дашборда НЕТ, и рассмотренный вариант завести его отклонён по названным ниже причинам.

Против ряда из VictoriaMetrics, каждая причина проверяема:

  • Единый снимок пары. budget_hourly хранит base/effective одной строкой, рассчитанной тем же signed micro-USD helper, что и BUY gate; отдельный ряд пришлось бы повторно выводить из realized PnL и рисковать расхождением.
  • Операторский Reset сохраняет ряд. budget_hourly — наблюдаемая история применённого бюджета, поэтому не входит в wipe_runtime_data; Reset не должен удалять график, по которому оператор сравнивает перезапуски.
  • Панель обязана работать, когда стек наблюдаемости лежит. Отказ VictoriaMetrics по контракту выше ослепляет панели, а операторская страница «Обзор» — та поверхность, по которой оператор принимает решения о деньгах.

Границы исключения узкие и проверяемые: две НАЗВАННЫЕ таблицы, часовое зерно, окно ЧТЕНИЯ 168 корзин, штатный читатель — операторская панель через Local API. Любой другой числовой ряд по-прежнему идёт в VictoriaMetrics; расширять исключение «по аналогии» нельзя. Если те же величины понадобятся как ИНЖЕНЕРНЫЙ ряд — секундная дискретность, rate(), сравнение окон, — они заводятся отдельными gauge в VictoriaMetrics, а не расширением этих таблиц.

168 корзин — это окно ЧТЕНИЯ, а не обещание физической очистки. Ни у одной из двух витрин удаление не идёт по расписанию: оно выполняется той же транзакцией, что и УСПЕШНАЯ запись. Пока записей нет — простой процесса, длящийся отказ хранилища, откат на бинарь, который таблицы не знает, — не чистится ничего, и строки живут дольше окна.

Различие между витринами добавляется к этому, а не заменяет его: у budget_hourly предикат удаления глобальный по времени, поэтому первая же успешная запись снимает всё, что вне окна, у любой пары; у balance_hourly удаление ограничено кошельком-владельцем (crates/pm-engine/src/adapters/storage/sqlite.rs:4821-4824), поэтому строки покинутого кошелька не снимет и она — их никто не пишет, а значит и не чистит.

Ответ эндпоинта от этого не меняется: окно чтения фиксировано и фильтр по владельцу применяется всегда. Но считать таблицы физически ограниченными 168 корзинами нельзя.

Отказ записи витрины торговлю не останавливает. Ошибка record_balance_hourly журналируется предупреждением, задача продолжает работу и повторяет попытку на следующем срабатывании (crates/pm-engine/src/observability/balance.rs:277-293). Писатель budget_hourly журналирует отказ и повторяет свежий снимок не чаще чем через 60 секунд (BUDGET_HISTORY_RETRY_INTERVAL); он также не останавливает торговлю. Отказ ДОМЕННОЙ записи — другое дело: это состояние, а не наблюдение, и он останавливает торговлю.

Разрыв в ряду означает ровно одно: в корзине не было НИ ОДНОЙ успешной записи. Это и есть точная семантика, и другой у разрыва нет. Из неё следует, что одиночная ошибка записи разрыва ещё НЕ доказывает: писатель срабатывает чаще, чем сменяется корзина (по умолчанию раз в 60 с — DEFAULT_BALANCE_REFRESH_SECS, observability/balance.rs:71), и следующая успешная попытка того же часа запишет ту же корзину. Но и обратное неверно: ошибка ЕДИНСТВЕННОЙ попытки часа — либо последней при том, что ни одна предшествующая в этом часе не удалась, — оставляет час пустым: писатель мог запуститься незадолго до границы, и следующее срабатывание попадает уже в новую корзину. Поэтому сообщение об ошибке записи не вправе обещать ни разрыв, ни его отсутствие: оно сообщает о потерянном наблюдении, а разрыв определяется по корзине целиком.

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

Разграничение поверхностей: операторский экран против инженерной панели ​

Приёмники отвечают на вопрос «куда положить наблюдение». Это правило отвечает на второй вопрос — «где его ПОКАЗАТЬ», и оно отдельное, потому что одна и та же величина может быть законной в хранилище и вредной на экране.

Операторский дашборд несёт признаки состояния и риска. Каждый его индикатор обязан отвечать на вопрос о ДЕЙСТВИИ: торгуем ли по протухшей книге, успеем ли выйти, слепнем ли к движению цены, есть ли открытый денежный риск. Величина, не отвечающая ни на один такой вопрос, на операторский экран не выводится, сколько бы её ни было приятно видеть.

Инженерные величины живут рядами и панелями vmui. Там место перцентилям задержек, глубинам очередей, темпам и длительностям: они отвечают на вопрос «почему», который задают ПОСЛЕ того, как признак состояния показал «что».

Три запрета, каждый — из разобранного случая (2026-08-26).

  1. Подпись обязана называть показанную величину. Карточка «Очередь p95» питалась полем e2e_queued_p95_us, которого движок не шлёт, а проекция подставляла туда сквозную e2e_p95_us — другой промежуток. Карточка при этом не пустовала и выглядела достоверной. Отсутствие величины отображается как отсутствие; подстановка чужой запрещена.
  2. Величина простоя не может быть признаком перегрузки. Loop lag p95 меряет интервал между пробуждениями цикла драйвера, включая холостое ожидание, и упирается в такт стратегии. Замер по хранилищу: 0.1 события/с → p99 = 1.001 с; 178 событий/с → 0.041 с. Индикатор краснел тем вернее, чем ТИШЕ рынок, то есть показывал величину, обратную риску.
  3. Порог обязан иметь существующий источник. Порог > 500 мс = STARVED ссылался на docs/operations/scanner-loop-runbook.md, которого нет в репозитории. Ссылка на несуществующий документ — такой же дефект, как неверное число: проверить порог нечем.

Снятие индикатора не отменяет наблюдаемости. Величина, которая записывалась в бою, остаётся доступной хотя бы на одной поверхности — рядом метрик или контрактом провода. Перевод величины с провода на ряд — отдельная работа, и до неё провод остаётся законной поверхностью. Отдельный случай — величина, которая в бою НЕ записывалась вовсе: её отсутствие называется дефектом, а не представляется перенесённым наблюдением.

Сквозной охват стаканов ​

MarketOrderbookCoverage отвечает на один операторский вопрос: сколько токенов из действующего локального набора подписки дошло через три фактических этапа requested → book_seen → strategy_evaluated. Первый этап означает активную локальную подписку, второй — полный MarketEvent::Book текущего физического market WebSocket-подключения, третий — успешное завершение Strategy::on_market_data на снимке из той же эпохи подключения. Команда подписки, PriceChange, BestBidAsk, запись в очередь и кадр прежней эпохи следующий этап не подтверждают.

Источник — один процессный реестр под одним RwLock; из одного его снимка формируются числовые ряды и MetricsTick, а /seed читает тот же тип сводки. Состояния вычисляет движок:

  • NONE — requested=book_seen=strategy_evaluated=0 и незакрытых отказов activation нет; это состояние намеренно не различает пустой набор до подключения и при открытом соединении;
  • UNKNOWN — текущей connection epoch нет либо есть незакрытый отказ activation; оно намеренно объединяет состояние до первого подключения, разрыв соединения и потерю управляющих сообщений, а два последующих значения отсутствуют, а не равны нулю;
  • OK — непустая тройка равных чисел в текущей эпохе;
  • PARTIAL — действующая epoch и любое иное соотношение.

book_seen и strategy_evaluated — защёлки эпохи: они доказывают, что токен хотя бы один раз прошёл этап после открытия соединения. Они не доказывают текущую свежесть книги; этот риск показывает отдельная наблюдаемость возраста стакана.

Переподключение сохраняет requested, но начинает новую монотонную epoch и заново доказывает два следующих этапа. Удаление токена исключает его из всех трёх множеств; повторное добавление после удаления получает новую subscription_generation, поэтому запоздавший снимок прежнего поколения не может закрыть пробел.

VictoriaMetrics получает только низкокардинальные gauge:

  • pm_engine_market_orderbook_coverage_available;
  • pm_engine_market_orderbook_coverage_assets{stage}, где stage — requested|book_seen|strategy_evaluated;
  • pm_engine_market_orderbook_coverage_state{state}, где state — ok|partial|unknown|none, one-hot;
  • pm_engine_market_orderbook_coverage_shards{stage}, где stage — configured|requested|book_seen|strategy_evaluated.
  • pm_engine_market_orderbook_coverage_activation_rejections_open;
  • pm_engine_market_orderbook_coverage_activation_rejected_total{reason}, где reason — закрытый словарь unknown_generation|token_set_mismatch|lease_not_pending|invalid_shard.

Номер шарда и идентификаторы рынка метками не становятся. Значение pm_engine_market_orderbook_coverage_shards после configured — настроенное число шардов, после остальных значений stage — число шардов, где есть хотя бы один токен соответствующего этапа. Режим обработки остаётся в существующем pm_engine_market_data_dispatch_mode{mode="driver|sharded"}. При available=0 два downstream gauge публикуются как нули только вместе с признаком недоступности и отдельно от него не интерпретируются. Сам available означает наличие открытой connection epoch без незакрытого отказа activation; холодный NONE при этом остаётся измеренной нулевой тройкой. Режим и настроенное число шардов — факт конфигурации; фактическую работу шардов подтверждает только ненулевая пошардовая занятость этапов.

Live-поток, который не удалось атомарно связать с подготовленным поколением, lease, точным составом токенов и допустимым шардом, в тракт обработки не добавляется и увеличивает соответствующий bounded-counter. Поскольку discovery уже считает его condition tracked, реестр хранит ограниченную незакрытую защёлку: activation_rejections_open растёт, а status остаётся UNKNOWN до успешной activation того же condition либо явного Remove. Автоматического повтора или отката допуска нет; отклонённая стартовая пачка остаётся UNKNOWN до остановки процесса. Тестовые потоки с нулевой provenance не регистрируются и эти ряды не меняют.

Потеря сообщений control receiver также закрывает текущую epoch и оставляет coverage в UNKNOWN до следующего физического reconnect. Этот случай распознаётся по точному WARN market WS control channel lagged; unknown lifecycle transitions were missed — coverage gap recorded for every tracked token; кадры прежней физической сессии состояние не восстанавливают.

Точные идентификаторы доступны только по явному защищённому запросу GET /api/local/market-orderbook-coverage: ответ содержит ту же общую wire- сводку и два полных, отсортированных, непересекающихся списка requested_without_book/book_without_strategy_evaluation. Когда requested непуст, но connection epoch отсутствует, либо есть незакрытый activation rejection, списки не сериализуются: разность этапов в этот момент не измерена относительно полного принятого набора. Реестр копирует под общим lock только числовой снимок и shared-ссылки, а строки строит и сортирует после освобождения lock. Штатное обновление metadata освежает диагностические question/slug, не меняя provenance и этапы. Backend панели проксирует тело без кэша и пересчёта после проверки identity. Главная страница автоматически списки не загружает: свёрнутая шапка показывает только сводку, а её свежесть считает по локальному монотонному времени получения /seed или MetricsTick. После истечения окна панель показывает UNKNOWN и скрывает прежние числа, режим и число шардов.

Это наблюдение не является торговым гейтом: PARTIAL/UNKNOWN не включают Drain, не отменяют заявки и не меняют решения входа или выхода. Ошибка экспорта, сериализации либо клиентского чтения остаётся fail-soft по общему правилу слоя.

Блокировка опасной политики рангового выхода ​

В нормальном запуске доменная проверка не допускает к торговому контуру execution.rank_exit, способный забирать ликвидность. Runtime всё равно проверяет тот же инвариант до передачи SELL-владения, потому что внутренний обход конфигурации не должен отменить уже действующую защиту позиции.

Каждая отвергнутая попытка увеличивает pm_engine_rank_exit_protective_blocked_total{reason="invalid_execution_policy"}. reason входит в закрытый словарь и не содержит идентификаторов. Отдельная структурированная запись уровня ERROR с событием rank_exit_invalid_execution_policy содержит signal_uid, condition_id, token_id, роль и нормализованный отпечаток политики, но повторяется только при первом наблюдении либо смене несовместимого отпечатка. Успешная maker-only проверка и терминальное удаление сигнала снимают эту защёлку. Поэтому счётчик отвечает на вопрос «сколько попыток заблокировано», а журнал — «какие сигналы и какая форма политики были причиной», не создавая высококардинальные ряды.

Эпоха и запуск — две разные величины ​

ЭПОХА — период неизменных условий опыта: одна версия конфигурации, один счётчик операторских очисток. ЗАПУСК — один процесс.

Перезапуск эпоху НЕ РВЁТ. Иначе граница эпохи стала бы неотличима от границы процесса, а перезапусков в этом движке было двадцать за семнадцать часов, и каждый дробил бы выборку. Операторская очистка открывает новую эпоху ВСЕГДА: она стирает торговое состояние, то есть меняет условия сильнее любой правки настройки.

Реестр эпохи живёт в SQLite (telemetry_epoch_state) и ВНЕ операторской очистки: очистка меняет счётчик, а не стирает состояние. Стереть его значило бы после каждого сброса открывать эпоху с нулевым счётчиком и потерять саму возможность сказать, сколько сбросов было.

Строка эпохи несёт ПОЛНЫЙ СНИМОК определения стратегии, а не ссылку на версию: версии вытесняются по глубине удержания config.db, и сравнение эпох, опирающееся на ссылку, однажды перестанет разрешаться — молча, пустым результатом соединения.

Что означает пустое значение ​

Правило одно и оно важнее остальных: ноль и пустая строка — это ИЗМЕРЕННЫЕ значения, а NULL — отсутствие наблюдения. Смешение этих трёх вещей делает выборку правдоподобной и неверной одновременно.

ЗначениеСмыслПример
числонаблюдалось и равно этомуactual_filled_shares = 0 у выставленной заявки: она на бирже, налито ничего
NULLНЕ наблюдалосьsubmit_latency_ms у заявки, не дошедшей до биржи
'' (ключ события)рынок ВНЕ события: бинарный, не neg-riskсвойство рынка
(unknown) (ключ события)событие НЕ РАЗРЕШИЛОСЬ в момент снятия строкиграница наблюдения

Замер на clickhouse-local 24.8, из-за которого это правило пришлось закреплять типом колонки: узкая целочисленная колонка принимает null МОЛЧА и кладёт ноль. То есть «сколько потеряно, неизвестно» превратилось бы в «не потеряно ничего» — без ошибки, без предупреждения, с успешным ответом сервера. Поэтому ingest_gaps.rows_dropped объявлена широким типом.

Верность наблюдения: чем «не наблюдалось» отличается от «не записали» ​

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

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

Отсюда правило для любой новой колонки наблюдения: сторож ставится на стороне ЗАПИСИ, а не чтения. На стороне чтения пустое значение уже неотличимо от «событий не было», и тест зеленеет при сломанном механизме. Проверка, подтверждающая отсутствие чего-либо, обязана сопровождаться положительным контролем непустоты — иначе она подтверждает только собственную бесполезность.

Второе правило: ложь видна только при сверке двух наблюдений ​

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

Различить их удалось, только положив рядом: в потоке ноль наливов при трёх в витрине за ту же смену.

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

Место такой проверки — там, где оба наблюдения ещё формируются, а не в хранилище. В хранилище отсутствие строки снова неотличимо от «события не было».

Ключ события у заявки ​

(unknown) в ключе события означает «событие не разрешилось В МОМЕНТ СНЯТИЯ СТРОКИ» — и до августа 2026 это значение появлялось систематически у одного класса заявок, что делало его не границей наблюдения, а следом дефекта.

Слаг события у заявки брался не из её собственной строки, а соединением с таблицей ПОЗИЦИЙ по паре «рынок + токен». Следствие не зависело ни от кэша, ни от времени: у заявки, которая не налилась, позиции нет по определению, и ключ разрешался в (unknown). Замер разделил ровно: 26 отменённых входных заявок без позиции — ключа нет; 15 с позицией — ключ есть; смешанных случаев ноль. У продаж и налитых заявок ключ был всегда, потому что позиция у них есть всегда.

Наблюдение тем самым отвечало «к какому событию относилась заявка» только для удавшихся входов — там, где ответ и так известен, — и молчало там, где он нужен: у входов, которые не состоялись. Это отбор по исходу ВНУТРИ наблюдения, и он искажает любую оценку воронки входа.

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

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

Причина отмены заявки ​

Причина отмены не терялась — она записывалась не туда: уезжала дословным текстом в поле пояснения, а колонка кода оставалась пустой. Пустой законно: колонка reject_reason_code принадлежит домену ОТКАЗА и отвечает на вопрос «почему заявка не встала». Отмена отвечает на другой вопрос — «почему вставшая заявка снята», — и в тот домен не помещается даже по типу: запись отказа конструктивно требует кода отказа.

Поэтому домены разделены. Значения не пересекаются, и это проверяется машинно, а не соблюдается дисциплиной: expired и balance_settling не могут встать в один ряд, поэтому запросы, группирующие по причине отказа, не начнут молча считать отмены отказами.

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

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

Различаются не два случая, а столько, сколько их есть на деле. Решение стратегии о переоценке входа, упреждение резкого стопа и команда оператора — три РАЗНЫХ кода, а не один «сняли мы»: сложенные вместе, они делают неотличимым «мы передумали» от «человек вмешался», и вопрос «сколько отмен из-за переоценки входа» перестаёт отвечаться группировкой.

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

Текст остаётся там, где несёт СВЕРХ кода: дословные слова оператора, формулировка причины остановки входа, знаковый объём токсичного потока. У решений стратегии свободной формы нет вовсе — там автор текста мы сами, и свобода означала бы, что домен отстаёт от собственного кода.

Пустая причина отмены остаётся законным значением. Часть путей отмены причины не знает по построению — лечение состояния и потерянный ответ при остановке. Требовать код на всех путях означало бы заставить их выдумывать причину ради непустой колонки, то есть заменить честное «не наблюдалось» правдоподобной неправдой.

Граница эры ​

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

Цена решения названа прямо: читатель, выбравший данные за 21 августа 2026, получит меньше наливов, чем их было, и не увидит ни одного признака неполноты. Признанная известная граница эры, а не скрытый дефект.

Потоки наблюдений ​

Решения входа и защиты ​

admission_attempts, stop_frames, selection_verdicts, admission_funnel_* — единица строки названа именем таблицы (_attempts — попытка, _frames — кадр). В этих потоках строка БЕЗ разрешимого события не пишется вовсе: наблюдение решения, которое не с чем сопоставить, бесполезно, а попадая в выборку, разбавляет её.

Счётчики воронки несут ДЕЛЬТУ за минуту, а не уровень. Это главная ловушка яруса: складывающий движок таблицы суммирует повторы ключа, поэтому повторно доставленный пакет не «перезапишет» значение, а УДВОИТ его. Отсюда обязательная дедупликация (ниже).

История отбора пишется на КАЖДОМ решении, а не на одном его классе. До августа 2026 след оставляло ровно одно вето — по рангу; три класса вето и обе непроигрышные ветки не писали ничего, и их существование восстанавливалось только по счётчикам, то есть по числу случаев без личности рынка и без момента. История, собранная из одной ветки из шести, отвечает на вопрос «сколько раз мы отвергли соседа фаворита» и молчит на вопрос «почему этот рынок вообще не дошёл до оценки» — а это и есть главный вопрос разбора смены.

Вердикт и причина — разные домены, а не одна колонка. Вердикт отвечает «что случилось с кандидатом» (прошёл, отвергнут, гейт был прозрачен), причина — «почему именно». Слитые в одну колонку, они лишили бы возможности спросить «сколько всего вето» без перечисления всех причин поимённо, а перечень причин растёт — и каждый существующий запрос пришлось бы править вместе с ним.

probability_rank и store_probability_rank — два источника одного вопроса, не одна величина под двумя именами. Первая колонка — легаси-формула ВЕТО (competition rank с делёными точными ничьими), та же, что фактически решает допуск заявки; менять её значение или источник — значит менять денежное поведение, что этой картой не сделано. Вторая — независимый ранг канонического стора когорты события (уникальный total order, без делёных ничьих), прочитанный в тот же момент решения, но НИКАК не участвующий в вердикте: строка несёт факт для сравнения постфактум, а не второй голос допуска. store_lookup называет исход этого чтения закрытой меткой — available, no_reference (условие сейчас не адресует ровно одну доступную ревизию) либо одна из четырёх причин недоступности стора (never_observed, stale_context, incomplete_cohort, member_integrity_invalid). Обе колонки NULL на вердиктах, случившихся ДО расчёта легаси-ранга (нет контекста, сторож данных, устаревшая котировка сиблинга) — сравнивать ещё не с чем, а не забыли прочитать.

entry_probability_rank — неизменяемый факт ПРИНЯТОГО входа, не третий источник числа. Заполняется только на Pass-строке тем же чтением, что дало store_probability_rank этой же строки, — поэтому на Pass-строке оба поля совпадают по построению, а не по совпадению. NULL на Reject-строках: входа не было, и подставлять сюда ранг вето или ранг стора значило бы приписать позиции факт, которого не существует. Перекос между probability_rank (что решило вето) и store_probability_rank (что видел стор в тот же момент) на ОТКЛОНЁННЫХ кандидатах — то, ради чего колонка вообще заведена: до неё это можно было измерить только на Pass-строках через живой Prometheus-счётчик без истории и без меток.

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

У трёх из них рыночных величин нет вовсе: токсичный поток, рубильник и дрейн процессные, они ОТМЕНЯЮТ заявки, а не продают. Пустые колонки позиции, токена и цены у таких строк — утверждение об их природе, а не пропуск.

Размер, снятый ФАКТИЧЕСКИ, в момент срабатывания неизвестен: он приходит филлом позже. Строка несёт ключ заявки именно затем, чтобы факт можно было соединить с намерением; до филла колонка пуста законно.

Возраст локальной книги на решении: локальный момент, не биржевая метка ​

orderbook-freshness-observability заводит ВОСЕМЬ новых рядов VictoriaMetrics и ОДИН новый атрибут в двух уже существующих таблицах ClickHouse (protection_triggers.book_age_local_ms, selection_verdicts.book_age_local_ms) — по правилу маршрутизации выше: «какое сейчас значение» и «как оно менялось числом» уходят рядами, «что именно случилось с этим решением» — строкой.

Расхождение БАЗИСА и ИМЕНИ — намеренное, не опечатка.admission_attempts.book_age_ms — БИРЖЕВАЯ метка: возраст книги, вычисленный от времени, которое прислала площадка на кадре книги; ни её формулу, ни имя эта карточка не трогает. book_age_local_ms/ pm_engine_orderbook_age_at_decision_seconds — ЛОКАЛЬНЫЙ момент: разница между локальными часами движка на решении и локальными часами момента, когда движок применил последний кадр книги к своей локальной копии. Имя различается НАМЕРЕННО — читатель обязан знать, какую из двух величин видит: биржевая метка зависит от сети и часов площадки (clock skew), локальная не зависит ни от того, ни от другого, но не видит время в пути кадра ДО применения.

Восемь рядов VictoriaMetrics (observability/metrics.rs, describe-блоки — единственный SSOT их точных формулировок):

РядТипМеткиСмысл
pm_engine_orderbook_apply_latency_secondsгистограмма (FRESHNESS_BUCKETS_SECONDS)нетзадержка между биржевой меткой кадра Book/PriceChange и локальным моментом его применения — на КАЖДОМ применённом кадре
pm_engine_orderbook_apply_timestamp_absent_totalсчётчикнетприменённый кадр без биржевой метки времени — именованный исход, не ноль и не огромная задержка
pm_engine_orderbook_clock_skew_secondsgauge, ЗНАКОВЫЙнетрасхождение биржевой метки и локальных часов на каждом применённом кадре, БЕЗ обрезки в ноль
pm_engine_orderbook_max_age_secondsgaugeнетмаксимальный локальный возраст книги среди отслеживаемых токенов, периодический такт 1 Гц — достоверен ТОЛЬКО при available=1 (см. ниже)
pm_engine_orderbook_freshness_availablegauge, 0/1нетаналог up — есть ли хоть один отслеживаемый токен с применённым кадром в этом такте
pm_engine_orderbook_age_at_decision_secondsгистограмма (FRESHNESS_BUCKETS_SECONDS)decision_pathвозраст книги токена намерения в момент денежного решения — на КАЖДОМ обращении к единой точке резервации заявки, независимо от исхода допуска
pm_engine_orderbook_age_observation_skipped_totalсчётчикreason (cancel|incompatible_pair|no_book), path (decision_path на no_book, "n/a" на прочих)именованный пропуск наблюдения — молчание недопустимо так же, как ложный ноль
pm_engine_orderbook_stale_alert_dispatch_dropped_totalсчётчикнетпереход fresh↔stale, для которого доставка тревоги отброшена guard'ом одного одновременного диспетча — рост значит «тревога временно не доставляется», не «такт остановился»

Контракт «доступность сначала». pm_engine_orderbook_max_age_seconds — голый Prometheus gauge, не Option: на такте NoData (ни одного отслеживаемого токена с применённым кадром) он НЕ получает нового значения и продолжает отдавать ПОСЛЕДНЕЕ known-good число. Читать его без сверки с pm_engine_orderbook_freshness_available=1 значит рисковать ложным благополучием на панели — тем самым, ради снятия которого карточка и заведена. NaN в сам gauge не публикуется: поведение экспортёра на NaN никем не проверено, а отдельный ряд доступности — документированный паттерн (аналог up), не требующий этой проверки.

PM_ENGINE_ORDERBOOK_STALE_ALERT_MS — порог тревоги, независимый от торгового допуска. Читается один раз в композиционном корне (config/orderbook_stale_alert.rs), обязателен: отсутствие, пустая строка (после trim), неразборчивое значение И НОЛЬ отказывают старт движка ВСЕ ЧЕТЫРЕ равноправно — умолчания нет. НЕ связана с directional.discovery.orderbook_max_age_ms (config.db) — тот порог видит ТОЛЬКО EntryFreshnessGuard перед BUY-резервацией одной стратегии, эта переменная — диагностическая чувствительность тревоги, наблюдающей ВСЕ денежные решения. Значение вне диапазона FRESHNESS_BUCKETS_SECONDS отказом НЕ является: диапазон корзин касается только гистограмм (значение выше верхней границы уходит в +Inf), а gauge максимума и сама тревога сравнивают точное число оператора с точным порогом, минуя корзины целиком.

Резинка (record_resync_outcome) в объём этой карточки не входит. Честная семантика переподключения WS вынесена отдельной карточкой market-ws-resync-observability; единственное наблюдение реконнекта, которое эта работа не меняет, — уже существующий pm_engine_ws_reconnects_total.

Пакетный REST-обход устаревших книг (stale-orderbook-rest-refresh) ​

Токен, чей локальный возраст книги превышает порог обновления (PM_ENGINE_ORDERBOOK_REFRESH_STALE_MS, см. ниже), самовосстанавливается отдельным 1 Гц периодическим участником (engine::orderbook_refresh_scheduler) без ожидания следующего WS-кадра: непустой список кандидатов даёт ровно один пакетный POST /books за такт. Карточка меняет только то, КАК книга обновляется, — не то, кто и когда её читает, и не торговые пороги допуска (directional.discovery.orderbook_max_age_ms остаётся прежним, см. раздел «Порог обновления книги…» ниже).

Одиннадцать новых рядов VictoriaMetrics (observability/metrics.rs, имена — константы ORDERBOOK_REFRESH_*, единственный SSOT их точных строк) плюс новая метка source на ДВУХ уже существующих рядах предшественника:

РядТипМеткиСмысл
pm_engine_orderbook_refresh_list_sizegauge, штукнетразмер списка обновления такта ПОСЛЕ усечения до потолка пакета — пишется на каждом такте безусловно
pm_engine_orderbook_refresh_requests_totalсчётчик, штукнетчисло отправленных пакетных POST /books; инкремент В МОМЕНТ отправки, до того как известен исход
pm_engine_orderbook_refresh_batch_totalсчётчик, штукresult (ok|timeout|error)исход ПАКЕТА целиком, не отдельного токена; для result="ok" инкремент не раньше, чем каждый токен такта уже получил свой исход в response_outcome_total
pm_engine_orderbook_refresh_response_outcome_totalсчётчик, штукoutcome (applied|rejected_stale|missing|dropped)исход ЗАПРОШЕННОГО токена в такте с успешно классифицированным ответом
pm_engine_orderbook_refresh_applied_totalсчётчик, штукнетчисло применений к MarketStateStore — включает и тот редкий случай, когда книга применена, а снимок стратегии не собран (итоговый исход токена всё равно dropped)
pm_engine_orderbook_refresh_duplicate_totalсчётчик, штукнетпобеждённые дубликаты идентификатора актива ВНУТРИ одного ответа — решается ДО сравнения с уже применённой книгой, это НЕ то же самое, что outcome="rejected_stale"
pm_engine_orderbook_refresh_unrequested_totalсчётчик, штукнетэлементы ответа, чей идентификатор актива не входил в отправленный список
pm_engine_orderbook_refresh_errors_totalсчётчик, штукreason (одиннадцать значений, см. ниже)причина отказа ПАКЕТА целиком — ни один из запрошенных токенов не получает исхода response_outcome_total в таком такте
pm_engine_orderbook_refresh_snapshot_dropped_backpressure_totalсчётчик, штукнетприменённый снимок (outcome="applied"), чья доставка стратегии отброшена переполненным каналом — не меняет исход токена, не откатывает применение
pm_engine_orderbook_refresh_snapshot_dropped_channel_closed_totalсчётчик, штукнетто же, но приёмник канала закрыт — планировщик завершает СВОЮ задачу немедленно после этого
pm_engine_orderbook_refresh_request_latency_secondsгистограмма, секундынетвремя от отправки POST /books до ответа или ошибки
pm_engine_orderbook_apply_latency_seconds / pm_engine_orderbook_clock_skew_secondsряды предшественника, без изменений типа+source (ws|rest_refresh)те же величины, что описаны выше («Возраст локальной книги на решении»), теперь различимые по тому, каким путём применён кадр

Единицы держат Prometheus-конвенцию: секунды для латентности (суффикс _seconds), штуки (целые приращения) для счётчиков и gauge — как и у рядов предшественника.

Закрытые словари меток — ровно то, что перечислено, и ничего сверх.result ряда batch_total — ровно три значения (ok — ответ получен и классифицирован, timeout — истёк именованный таймаут запроса, error — отказ пула клиента CLOB). outcome ряда response_outcome_total — ровно четыре значения (applied, rejected_stale, missing, dropped); unrequested НЕ входит в этот словарь — он относится к элементу ответа, а не к запрошенному токену, и потому считается отдельным рядом (unrequested_total). reason ряда errors_total — ровно одиннадцать значений: timeout (сам именованный таймаут планировщика, пул в этом исходе не участвует) и десять причин отказа пула клиента CLOB — saturated, acquire_timeout, local_operation_timeout, request_timeout, shutting_down, worker_died, external_429, incomplete_trades_tape, invalid_input, request (последнее — названная граница: HTTP-код вне 2xx и ошибка разбора тела 2xx-ответа уже неразличимы типобезопасно к моменту, когда отказ доходит до планировщика, обе причины делят эту одну метку). Свободный текст в значении любой из этих меток — дефект, не допустимая форма.

Ноль от старта процесса — предрегистрация, не первое наблюдение.preregister_orderbook_refresh_counters() (observability/metrics.rs) материализует нулём каждый безметочный счётчик и каждое значение всех трёх закрытых словарей выше, а также handle гистограммы латентности БЕЗ записи наблюдения (тот же приём, что preregister_histogram_series, — не искажает _count/_sum/бакет +Inf). Композиционный корень (app/config.rs) зовёт эту функцию СРАЗУ после установки Prometheus-рекордера, до первого такта планировщика и до любого реального POST /books — без этого холодный старт (планировщик ещё ни разу не сработал) и запуск, у которого сбор этих рядов вообще не подключён, неразличимы на /metrics: оба отдают отсутствие ряда. Тест orderbook_refresh_error_and_duplicate_series_present_at_zero_from_process_start (observability/metrics.rs) — регрессия именно на этот факт: все одиннадцать значений reason, duplicate_total, applied_total и snapshot_dropped_channel_closed_total присутствуют со значением 0 до первого такта.

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

Правило маршрутизации и без меток рынка/токена/условия — тот же принцип, что у рядов предшественника. Все ряды этой карточки уходят в VictoriaMetrics, агрегированные по инстансу целиком: «какое сейчас значение» и «как оно менялось числом» — вопрос рядов, не строк ClickHouse. Ни один ряд не несёт метку рынка, токена или condition — устойчивый рост applied_total/errors_total/requests_total сигнализирует, что хотя бы одна отслеживаемая книга регулярно достигает порога и требует диагностики WS-доставки, реконнектов и естественной активности рынка, но САМ ПО СЕБЕ не указывает конкретную подписку — атрибуция по токену этими рядами структурно невозможна (см. раздел «Порог обновления книги…» в docs/operations/alerts-and-monitoring.md).

Инвариант суммы исходов «между тактами», не «внутри такта». На такте с успешно классифицированным ответом сумма applied+rejected_stale+missing+dropped равна числу токенов отправленного пакета, и batch_total{result="ok"} инкрементируется НЕ РАНЬШЕ, чем последний из этих исходов записан. Между соседними тактами это равенство держится; ВНУТРИ такта — нет: /metrics читает независимые счётчики по отдельности, не атомарным срезом, и опрос, попавший между записью последнего исхода токена и инкрементом batch_total{result="ok"}, может застать частичную сумму — расхождение не больше размера одного пакета и исчезает в первом опросе после завершения такта. Из этого правила ровно два именованных исключения, и в них частичная сумма остаётся в рядах ОКОНЧАТЕЛЬНО: такт, прерванный четвёртым рубежом отмены (сигнал остановки взведён ПОСЕРЕДИНЕ классификации ответа, после того как часть токенов такта уже получила исход), и такт, в котором планировщик вместо продолжения завершает свою задачу из-за закрытого канала доставки снимка стратегии. В обоих случаях batch_total{result="ok"} для этого такта не появляется НИКОГДА, а уже записанные до прерывания исходы response_outcome_total остаются — это не расхождение, ожидающее следующего опроса, а окончательное состояние такта.

Здоровье лент: чем «не мерили» отличается от «не было» ​

feed_health_samples — одна строка на пробу источника: рыночный вебсокет, пользовательский, RTDS, Gamma, CLOB REST, здоровье биржи.

Строка заполняется ЧАСТИЧНО, и это осознанный выбор формы. Ни одна точка наблюдения не знает всех величин сразу: состояние как значение есть только у здоровья биржи, задержка в миллисекундах — только у CLOB REST, у Gamma есть возраст последнего успеха, а не задержка. Форма таблицы описывает то, что осмысленно наблюдать, а не то, что движок умеет наблюдать сегодня: иначе появление задержки у вебсокета завтра потребовало бы менять таблицу вместо того, чтобы просто начать её заполнять.

Условие, без которого выбор превращается в отговорку: колонка причины заполняется всегда — в том числе когда причина состоит в том, что величина этой лентой не измеряется. Пустота обязана быть объяснимой по самой строке, а не догадкой читателя.

Разрыв ленты записан как МОМЕНТ ВОЗОБНОВЛЕНИЯ, а не длительность: момента обрыва не наблюдает никто, и разность была бы правдоподобным неверным числом.

Витрины: «что стало» ​

positions_wide, orders_wide — одна строка на позицию (заявку) целиком. Наполняются периодическим снимком доменных строк, а не проводкой в точках записи: записей заявки и позиции в движке тридцать две в восьми файлах, и проводка в каждой — гарантия пропустить одну и не заметить.

Отбор снимка идёт НЕ по updated_at. Колонка называется «время изменения» и при изменении не меняется: update_position её не трогает вовсе, её ставит только вставка. Обнаружено падающим тестом на настоящей базе, а не разбором. Поэтому выборка перечисляет то, что может измениться: живое, закрытое после границы окна, закрытое без реализованного результата. Повторы отсекает отпечаток строки в памяти снимающего.

Границы, которые по колонкам не прочитать:

  • run_id_opened — запуск, который СНЯЛ строку, а не тот, что открыл позицию: чей запуск открыл, в SQLite не записано, и выдавать снявшего за открывшего значило бы приписывать позицию не тому процессу.
  • Окно наблюдения за завершённым — 24 часа. Изменение позиции, закрытой раньше, витрина не увидит; единственный известный такой случай (позднее книжение резолюции) покрыт отдельным условием «закрыта, результата нет».
  • projected_* уходят NULL. Переоценка по книге и ликвидационная стоимость требуют стакана на момент снятия, терминальный PnL ветви — вектора выплат, которого в рантайме ещё нет.
  • Сторона исхода не подставляется. Движок сегодня NO-only, но записать это безусловно значило бы утверждать ответ вместо наблюдения — и при переходе на обе стороны половина выборки молча оказалась бы ложной.

Зеркала переходов: «как менялось» ​

order_events, position_events — строка на переход, из журнала событий движка (outbox в SQLite). Номер перехода seq — ГЛОБАЛЬНЫЙ номер события журнала, а не порядковый внутри заявки: разность двух seq числом переходов заявки не является, между ними лежат события всех остальных заявок.

★Зеркало ЧАСТИЧНОЕ ​

Журнал покрывает не все переходы: двенадцать боевых точек пишут доменную строку без строки события, и четыре из них ДЕНЕЖНЫЕ (книжение результата резолюции, сверка налива по ответу биржи). Полный перечень, разбор влияния на каждого читателя журнала и варианты закрытия — заявка outbox-coverage-for-money-transitions.

Следствие для читателя выборки: count() по зеркалу — число ЗАПИСАННЫХ переходов, а не случившихся, и денежный итог по нему считать нельзя. Итог берётся из витрин: они читают доменную строку целиком и потому видят и те изменения, о которых журнал промолчал, включая все четыре денежные точки.

Обычные филлы в журнале ЕСТЬ — это стоит сказать прямо, потому что обратное первым приходит в голову: строку Fill пишет реестр позиций, и поздний филл после терминализации её тоже получает. Пропущено там исправление СТАТУСА заявки, а не деньги.

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

Третья граница: новый запуск зеркалирует только события после своей стартовой границы. Строка журнала не хранит run_id/epoch_id, а исторический runtime simulator писал не только узнаваемый SimFill, но и обычные OrderPlaced, PositionOpened, PositionGrew, PositionClosed. Поэтому при старте, сразу после выдачи нового run_id и до orchestrate_bootstrap, восстановления persistent drain и любого боевого пути с append_event, зеркало ставит курсор на max(durable_cursor, max_event_seq). Фоновая задача переноса может стартовать позже: принадлежность события запуску определяет зафиксированная граница, а не время первого такта. Хвост предыдущего процесса остаётся в SQLite и не получает ложную identity нового запуска. При штатной остановке текущий процесс делает финальный перенос; при падении следующий запуск отдельно пишет посмертный разрыв с неизвестным числом потерянных наблюдений. Точно переатрибутировать старый хвост нельзя без identity в самой строке журнала.

Гарантия доставки ​

Заявляется: «попытка внутри запуска плюс ВИДИМЫЙ РАЗРЫВ». Не «не менее одного раза» — такую гарантию слой не даёт и давать не может: у диагностических кадров нет воспроизводимого источника, из которого их можно было бы дослать.

Механизм:

  1. Постановка в очередь НЕ БЛОКИРУЕТ торговый путь. Переполнение отбрасывает строку и увеличивает счётчик потери.
  2. Пакет вставляется до трёх раз с ТЕМ ЖЕ токеном дедупликации (задержки 250 мс и 1 с — сумма вчетверо меньше каденции сброса, очередь не растёт).
  3. Исчерпание попыток даёт СТРОКУ РАЗРЫВА в ingest_gaps. В очередь пакет не возвращается: он ушёл бы следующим сбросом с новым токеном, и дедупликация его не узнала бы — а на суммирующих таблицах это удвоило бы дельты.

Дедупликация ​

run_id называет РЕАЛЬНЫЙ процесс, а не состояние откатываемой базы. Его форма — r{локальный счётчик}-{UUID процесса}: счётчик сохраняет оператору локальный порядок запусков, UUID не даёт повторно использовать identity после coordinated rollback. Это обязательно, потому что rollback восстанавливает более раннюю SQLite, но не откатывает уже записанный внешний ClickHouse. Боевой acceptance 2026-08-23 воспроизвёл коллизию: восстановленный счётчик снова выдал r20, а ClickHouse молча отбросил новые пакеты с уже виденными токенами r20:*.

Токен пакета — {run_id}:{table}:{номер пакета}. Пять фактов замера, каждый получен запуском clickhouse-local 24.8:

  1. Токен БЕЗ non_replicated_deduplication_window не действует вовсе. Окно обязательно у каждой таблицы.
  2. С окном повтор того же токена не удваивает сумму на суммирующей таблице.
  3. Тот же токен с ДРУГИМИ данными отбрасывается МОЛЧА. Отсюда требование: номер пакета монотонен и не переиспользуется.
  4. Окно измеряется в БЛОКАХ, а не во времени: за его пределами повтор создаёт дубликат. 1000 блоков на таблицу при каденции пять секунд — больше часа.
  5. Без токена дедупликация идёт по хешу блока; явный токен её отменяет, поэтому побайтно совпавшие пакеты не теряются.

Уникальная identity ЗАПУСКА в токене, а не только эпоха: эпоха переживает перезапуск, и первый пакет нового процесса столкнулся бы с первым пакетом прошлого. Замер показал, что хранилище отбрасывает такое столкновение МОЛЧА — с успешным ответом и потерянными данными.

Записи разрывов ​

Четыре класса, различимы: переполнение очереди, отказ вставки, не уложившаяся остановка, падение процесса. Первые два прижизненные — число потерянного известно точно. Два последних ПОСМЕРТНЫЕ, и о них надо знать три вещи:

  • число потерянного NULL — считать было некому;
  • таблица (unknown) — какие потоки пострадали, не наблюдалось;
  • run_id — РЕПОРТЁР, а не пострадавший: упавший процесс не сообщал ничего, сообщает следующий.

Окно посмертного разрыва — ВЕРХНЯЯ ОЦЕНКА: от водяного знака последней подтверждённой вставки до старта текущего запуска. Точнее не наблюдалось: между этими моментами процесса не было.

Неразрешимые случаи, названные прямо:

  1. Процесс больше не запустится, локальной записи нет. Посмертный разрыв пишет следующий старт; если старта не будет никогда (узел выведен из эксплуатации), потеря останется незаписанной. Восстановить её нечем — это граница механизма, а не дефект.
  2. Переходное выравнивание отметки завершения. Колонка отметки добавлена к уже работающей базе; в момент добавления она выравнивается на текущий счётчик запусков, потому что до введения механизма закрытость НЕ НАБЛЮДАЛАСЬ. Ценой этого одно настоящее падение, если оно случилось до выката, останется незаписанным.
  3. Курсор зеркала на каждом старте ставится не раньше durable watermark и не раньше текущего конца журнала. История и хвост предыдущего процесса в новый запуск не входят: иначе строки получили бы identity не того процесса.

Наблюдаемость самого слоя ​

pm_engine_analytics_rows_total{outcome, table} — судьба строк. Метки закрытые, outcome пять значений: accepted, dropped, written, failed, retried. retried в баланс accepted = written + failed + очередь НЕ входит: это повторные отправки уже посчитанных строк, и сложение удвоило бы их. Рост retried при нулевом failed означает, что хранилище моргает, а данные доезжают.

pm_engine_analytics_insert_seconds{result} — длительность вставки; pm_engine_analytics_queue_depth — срез глубины очереди на сбросе; pm_engine_analytics_last_write_unix_seconds — МОМЕНТ последней успешной вставки, а не её возраст: возраст пришлось бы обновлять таймером, и остановившийся таймер выглядел бы как свежая запись.

Схема и права ​

Схема применяется ОТДЕЛЬНЫМ ADMIN-шагом внутри coordinated scripts/deploy-vps.sh engine (deploy/server/analytics/), не движком и не ручной командой оператора: у пишущей роли нет права DDL (проверено живым сервером, Code: 497). Релиз staging'ит и хеширует DDL, применяет его до нового writer и принимает релиз только после schema/type/engine/run probes. Схема, применяемая тем же процессом, который пишет данные, означала бы, что ошибка в коде способна изменить форму уже собранного архива.

Изменения ФОРМЫ существующих таблиц идут вторым шагом, из migrations.sql: CREATE TABLE IF NOT EXISTS существующую таблицу не трогает вовсе, поэтому правка определения в схеме действует только на пустой базе, а на накопленной МОЛЧИТ — и разошедшаяся форма не выглядит как ошибка.

Смена ДВИЖКА таблицы возможна только пересозданием, то есть с потерей данных. Скрипт выката проверяет пустоту машиной и ВАЛИТСЯ на непустой таблице: «я утверждаю, что она пустая» проверкой не является.

Граница форвардного измерения ​

Успешный coordinated deploy остаётся в persistent Drain со state ready-to-resume. Нижняя граница анализа — НЕ restart и НЕ момент установки: это durable cutoff_at из .deploy/releases/measurement-cutoffs.jsonl, который scripts/deploy-vps.sh resume --release-id <id> синхронно пишет ДО HTTP resume. Запись связывает release/artifact/config/schema, run_id + epoch_id, VM counter baselines, ClickHouse part watermarks и authored strategy modes.

Только следующая строка status=active с подтверждённым draining=false разрешает считать окно открытым. prepared + failed означает, что торговля не была подтверждённо возобновлена; переносить T0 на повторную попытку нельзя — каждая попытка сохраняется, а успешный старт на release допускается один. Данные до cutoff_at, captures и backup-БД прежнего/багнутого кода пригодны для диагностики, но не для тюнинга и не для утверждения о +EV.

Читатели: где что смотреть ​

ВопросЧемИсточник
«почему стоп сработал или не сработал»forensics.sh stops --engineClickHouse stop_frames (по умолчанию)
то же по кадрам ДО 20.08.2026FORENSICS_FRAMES_SOURCE=sqlite forensics.sh stops --engineengine.db, эра до перехода
«стоп был лишним или спас»forensics.sh stop-counterfactualClickHouse (по умолчанию)
«какая была книга в точке решения»forensics.sh liquidity-profileClickHouse (по умолчанию)
«что стало с позицией/заявкой»запрос к витринамClickHouse

Две ЭРЫ кадров стопа, и смешивать их нельзя. До 20.08.2026 кадры писались в SQLite, после — в ClickHouse. Границы записи у эр разные, и склеенная выборка выглядела бы однородной, не будучи ею — то же правило, которым запрещено сравнение эпох. Переливки не делалось намеренно: накопленное собрано до исправлений, а тюнинг ведётся только на форвард-данных (CLAUDE.md, раздел EV-валидации). Для диагностики старая выборка доступна переключателем.

У исторических строк нет ключа события — когорты по событию по ним несобираемы. Это свойство эры, а не дефект инструмента.

Связанные строки соединяются ВНУТРИ хранилища, и это снимает то, что выглядело препятствием. Первая оценка гласила, что stop-counterfactual и liquidity-profile перевести нельзя — «позиции лежат в другой базе» и «нет когортных колонок». Оценка была неверной: витрины positions_wide и orders_wide живут в том же pm_analytics, а общий ключ у кадра, попытки, заявки и позиции — signal_uid. Недостающие восемь колонок добавлены.

Что важно для читателя выборки: в хранилище связь ТОЧНАЯ. В SQLite решение привязывалось к позиции по совпадению рынка, токена и времени, и оттуда росло однокошельковое допущение — привязка не различала кошелёк и стратегию, и при двух кошельках судьбы перепутались бы. По signal_uid такой ошибки взяться неоткуда, и допущение исчезает вместе с проверкой на него.

Суррогатный id строки SQLite заменяется естественным ключом: у кадра — тройка «сигнал + начало эпизода + момент кадра», у попытки — отметка решения.

Запись stop_dip_contexts и decision_liquidity_profiles в SQLite отрезана 2026-08-20. Порядок был вынужденным и обратному не подлежит: типизация настроек запирает неизвестные ключи, поэтому сперва секция liquidity_profile снималась из боевой конфигурации (версия 47), и только затем удалялся код — бинарь без ключа не поднялся бы, пока ключ оставался в базе.

Накопленные строки НЕ переливались и таблицы НЕ удалялись: это отдельная эра, читаемая переключателем FORENSICS_FRAMES_SOURCE=sqlite и не пополняемая.

Три следствия, которые читатель обязан знать по имени.

Глубина лестницы книги стала константой движка (DECISION_BOOK_LEVELS, 128 уровней на сторону) — ровно то значение, что стояло в боевой конфигурации версий ≤ 46. Настройкой оно быть перестало, потому что управлять стало нечем: горизонт удержания ушёл вместе с таблицей, а форма наблюдения — не торговое решение. В значении config_digest строки глубина ОСТАЛАСЬ (liquidity-profile:v5:levels=128): выборки, снятые при разной глубине, смешивать нельзя.

Включённость слоя определяется приёмником, а не настройкой. Предикат — «сток включён и эпоха разрешима». Пока секция управляла записью, снятие её из конфигурации гасило и поток в хранилище; теперь такой связи нет.

Отметки «связь оборвана сбросом» больше не существует, и она не нужна. Строки лежат вне движковой БД, операторский сброс их не касается, а их связь с signals/orders он всё равно обрывает. Границу показывает сам ключ эпохи: счётчик сбросов входит в её личность (v{версия}-r{сброс}), поэтому строки по разные стороны сброса лежат в разных эпохах.

Проверки ​

Что проверяетЧем
форма схемы, поведение движков, дедупликацияscripts/check-analytics-schema.sh (настоящий ClickHouse в контейнере)
строки движка принимаются настоящей схемой и читаются обратно неизменнымиcrates/pm-engine/tests/it/analytics_end_to_end.rs
поля строк совпадают с колонками поимённотесты *_fields_match_schema_columns рядом с каждой формой строки

Сверка имён полей НЕ ловит тип: строка, ушедшая в целочисленную колонку, и отметка времени, не разобранная как время, проходят её и отказывают вставкой — либо, что хуже, молча искажают значение. Поэтому сквозная проверка сверяет ПРОЧИТАННОЕ с записанным, а не факт успешной вставки.

Причины остановки торгового цикла: что чем наблюдается ​

С unified-trading-gate-and-entry-readiness вход закрывают ТРИ причины, и у каждой своя ступень воронки, свой счётчик и свой код отказа. Четвёртой не существует: авто-риск-стоп по накопленному PnL — это Drain с происхождением Automatic и причиной circuit_breaker, а не отдельная причина.

ПричинаСтупень воронкиКод отказаОбласть
Draindrain (blocked_drain)drain_modeтолько новые входы
LifecycleHaltlifecycle_halt (blocked_lifecycle_halt)lifecycle_haltВСЕ намерения, включая выходы
ExchangeAccessexchange_degraded (blocked_exchange_degraded)exchange_degradedтолько новые входы
прогрев входа (не причина остановки)warmup (blocked_warmup)entry_warmup_pendingтолько новые входы

Прогрев в таблице стоит отдельно намеренно. Он не причина остановки, а ВТОРОЙ последовательный механизм: рубильник отвечает «можно ли торговать», прогрев — «готовы ли данные». Спрашивается он только у намерения, ОТКРЫВАЮЩЕГО позицию, и только после того, как рубильник допустил.

Чего в кадре воронки НЕТ. Полей blocked_lifecycle_halt в BotEvent::EntryFunnelTick нет — кадр собирается поимёнными полями контракта pm-bot-events. Отсутствие поля в кадре не означает нулевого счётчика: величина живёт в pm_engine_entry_funnel_total{stage} и в почасовой сводке.

Полнота стережётся тестом, а не дисциплиной. Связь причины со ступенью идёт по СТРОКЕ, и компилятор её не проверяет. Проверка every_stop_kind_has_a_funnel_row_and_a_stage (strategies/directional/runtime_state.rs) обходит все виды причин и требует имя ступени каждой и в цепочке GATES (приписывание рубежа), и в перечне stages() (операторская сводка). Забытая строка роняет тест, а не теряется молча.