Skip to content

Alerts and Monitoring — pm-engine operational runbook ​

Внешний vmalert/Alertmanager стек НЕ развёрнут (future). In-repo файлы стека удалены (deploy/{prometheus,alertmanager,alert-rules}.*, deploy/server/shared/observability/) — они обслуживали мёртвый pm-bot scrape (pm-bot:9091) и alert→webhook путь, а сам Prometheus на VPS не поднимался. Что работает сегодня: pm-engine поднимает opt-in Prometheus /metrics endpoint (PM_ENGINE_METRICS_BIND, default 127.0.0.1:31340), VictoriaMetrics сохраняет его ряды с интервалом опроса 1 с, а critical/error события доставляются в Telegram НАПРЯМУЮ через in-process AlertEngine (crates/pm-engine/src/observability/engine_event_notifier.rs) — без Alertmanager. Внешняя оценка alert rules и routing — целевая архитектура, ещё не развёрнутая; дизайн (модель не дублируется здесь) — ../target/engine.md раздел «Observability layer».

Smoke pm-engine / pm-engine-dashboard — runbook.md.

pm-engine observability ​

pm-engine использует двух-канальную observability модель + один direct Telegram channel (архитектурное обоснование разделения — ../target/engine.md раздел «Observability layer»):

  1. Operations metrics → Prometheus /metrics → VictoriaMetrics (опрос раз в секунду; отдельного vmalert/Alertmanager evaluator нет).
  2. Lifecycle events → SSE → дашборд (см. local-dashboard-runbook.md).
  3. In-process AlertEngine → Telegram — critical/error события сразу, без ожидания scrape и без Alertmanager. Это единственный работающий сегодня alert-путь.

Telegram alerts (in-process AlertEngine) ​

pm-engine подписывается на свой EventBus через EngineMetricsTap и маппит выбранные BotEvent в alert dispatch:

  • LifecycleHalted → severity Critical. Subject: pm-engine HALTED: <reason>. Body содержит reason + exit_code + bootstrap_id. Отправка немедленная, дашборд параллельно блокируется state'ом Halted. Возобновление работы исключительно через полный рестарт процесса (якорь A6, см. runbook.md#halt-loop-diagnosis).

  • PositionRedeemFailed { terminal: true } → severity Error. Subject: Redeem FAILED (terminal): <position_uid>. Body содержит reason + retry count. Означает залоченный капитал в un-redeemed position, требуется ручное вмешательство (procedure: Phase J «Redeem failures» раздел в runbook.md).

  • Накопление неразрешённых отправок (rule = unresolved_submits_held) → severity Error, тот же канал приёмников, что у прочих денежных тревог. Сообщение несёт удержанную сумму, количество, возраст самой старой и обе команды разбора (перечень + разрешение). Порог — четверть общего бюджета стратегии: задокументированная константа, не настройка; бюджет не сконфигурирован ⇒ сообщаем при любой записи (молчание хуже лишнего сообщения). Сигнал НИЧЕГО не разрешает сам и ни одной резервации не освобождает — он только доносит факт.

    Граница, которую нельзя опускать: сигнал порождается СЕГОДНЯ только на пути восстановления при старте процесса (reminder-семантика: каждый перезапуск ре-эмитит, пока состояние не разрешено). Удержание, возникшее в уже работающем процессе, Telegram НЕ поднимет до ближайшего перезапуска. Поэтому рантайм-наблюдение идёт через pm_engine_unresolved_submits > 0 на /metrics (или карточку/полосу на дашборде), а молчание Telegram доказательством отсутствия состояния НЕ является.

  • Неопределённая отмена (AMBIGUOUS_CANCEL) — отдельного Telegram-правила нет. Заявка, чью отмену биржа не подтвердила и чью судьбу не удалось установить свежим свидетельством, durable-книжится статусом AMBIGUOUS_CANCEL: её рынок закрыт для новых BUY барьером, повторами занимается периодический sweep, оператору строка видна банером в шапке SPA с ручкой ручного разрешения. Рантайм-наблюдение — счётчики на /metrics: pm_engine_cancel_transport_disposition_total{disposition} (транспортный ответ биржи на саму команду отмены: confirmed | not_confirmed), pm_engine_cancel_ambiguous_total (каждая durable-запись неразрешённого исхода отмены), pm_engine_cancel_verdict_unrecognized_status_total (на пути вердикта биржа вернула статус вне закрытого словаря — строка удержана, «живой» не подменена) и pm_engine_ambiguous_cancel_sweep_stalled_total (строка пережила порог повторов sweep: автоматика сама не сойдётся, нужен оператор). Отдельно от отмены, но из того же механизма честного букинга: pm_engine_fill_fee_rate_unresolved_total — тейкерский филл легаси-строки без персистентной ставки комиссии; исполнение забукировано, комиссия — нулём, и это НЕ подтверждённый ноль. Точная семантика и SSOT имён — describe-блоки observability/metrics.rs.

Дополнительные rate-based правила (IntentSuppressed spike, LDI gate block ratio) добавляются в production tuning после накопления baseline на live-трафике. Пункт «OrderRejected spike» из этого перечня СНЯТ: правила отказов описаны ниже (openspec order-reject-taxonomy-observability).

Тревога «отказ записан БЕЗ кода причины» ​

Правило order_rejected_without_code, severity Warning, доставка — в те же sinks, что алерты здоровья биржи. Триггер — ПЕРВАЯ ЖЕ строка orders.status='REJECTED', у которой reject_reason_code пуст (NULL, пустая строка, пробелы) либо несёт значение вне домена кодов и при этом НЕ является маркером легаси. Порога по доле нет и быть не может: при типизированном отнесении остаточной корзины не существует, поэтому «доля неизвестного» никогда не поднялась бы выше нуля сама.

Точка обнаружения — периодический durable-обход (crates/pm-engine/src/observability/reject_alerts.rs): один проход НЕМЕДЛЕННО на старте процесса и далее по именованной константе UNCODED_REJECT_SCAN_INTERVAL.

Дедупликация — по ИНЦИДЕНТУ, а не по строке и не по проходу. Внутри одного процесса сигнал поднимается ОДИН раз за UNCODED_REJECT_ALERT_DEDUP_TTL_MS, сколько бы строк ни написал сломанный писатель: сигнал на каждую строку похоронил бы под собой и себя, и алерты здоровья биржи, делящие те же sinks. Сигнал называет ПЕРВУЮ строку прохода как представителя (по ней оператор находит запись) и число провинившихся строк этого прохода; каждая не поднявшая своего сигнала строка сосчитана в pm_engine_uncoded_reject_alerts_deduped_total. ПОЛНЫЙ перечень строк остаётся в логе (WARN на каждую) — сворачивается уведомление, а не запись.

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

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

Значения severity и TTL проставлены по рекомендации design.md и ждут подписания оператором (ворота O-2 изменения order-reject-taxonomy-observability).

Каталог кодов причины отказа (метка reason) ​

Значение метки pm_engine_orders_rejected_total{reason} — это reject-код таксономии (crates/pm-engine/src/ports/reject_taxonomy.rs), выведенный match-выражением по типизированному варианту-производителю в месте его построения. Дословный текст ошибки меткой НЕ становится НИКОГДА и живёт в событии, логе и колонке orders.reject_reason.

Отказы отправки и клиента: rate_limit, invalid_tick, crossing, not_found, network, upstream, balance_settling, restart_window, probe_fuse, authorization_mismatch, wire_value_mismatch, invalid_value, not_dispatched, submit_outcome_unknown.

Полученный отказ биржи, по классам ответа: exchange_rate_limited (429), exchange_auth_rejected (401/403), exchange_not_found (404), exchange_unparsed_4xx (400/422, тело не разобрано — сюда приходит рутинный FAK/FOK-kill стоп-капитуляции; код НЕ утверждает делового смысла отказа).

Отказы резервации: risk_limit, lifecycle_halt, drain_mode, position_iteration_capacity, exchange_degraded, entry_warmup_pending, market_closed, stale_data.

С введением единой точки контроля торгового цикла (unified-trading-gate-and-entry-readiness) ТРИ причины остановки ложатся на уже существующие варианты отказа: ReservationDenied::from_stop (crates/pm-engine/src/engine/order_router.rs) отображает Drain → drain_mode, LifecycleHalt → lifecycle_halt, ExchangeAccess → exchange_degraded.

Код kill_switch СНЯТ 2026-08-25. Он объявлял себя наблюдаемой меткой, но производителя не имел: доунификационный путь activate_kill_switch боевого вызывающего не имел, а фактический авто-риск-стоп по накопленному PnL входит в Drain с происхождением Automatic и причиной circuit_breaker (circuit_breaker.rs → enter_persistent_drain; унификация 2026-07-22). Вместе с кодом сняты вариант охраны, вариант контекста, ступень воронки blocked_kill_switch и долговечное значение kill_switch_blocked — оно только писалось и обратно не разбиралось, поэтому исторические строки decision_liquidity_profiles не затронуты. Сам КОД kill_switch остаётся узнаваемым при чтении исторических orders.reject_reason_code как СНЯТЫЙ (RETIRED_CODES в ports/reject_taxonomy.rs): иначе такая строка объявлялась бы отказом без кода и поднимала тревогу при каждом перезапуске. Новое имя этой работы — blocked_lifecycle_halt — это СТУПЕНЬ ВОРОНКИ, а не код отказа (см. раздел про pm_engine_entry_funnel_total{stage} ниже).

entry_warmup_pending — биржа исправна, но охват книг после перерыва ещё не восстановлен: вход закрыл ПРОГРЕВ, а не неисправность биржи. Отдельный код от exchange_degraded намеренно — слияние скрыло бы, что причина в неготовности наших данных. Свой счётчик блокировок: pm_engine_entry_blocked_warmup_pending_total.

position_iteration_capacity — у ограниченной операторской итерации не осталось свободного места под новую начальную позицию. Отказ штатный, а не аварийный: предел выставлен оператором. Как и остальные отказы резервации, идёт общим путём эмиссии, поэтому виден и в этой метке, и в колонке orders.reject_reason_code, и в отчёте форензики.

Вывод сверки: reconciliation_no_order.

Меткой НЕ появляются (объявлено и держится тестом): rate_limit, not_found, network, submit_outcome_unknown — исходы, при которых ордер может существовать на бирже, уходят на путь удержания ВЫШЕ эмиссии отказа и принадлежат ambiguous-shutdown-safety; upstream — его не строит ни одна реализация отправки. Значения other не существует: корзина удалена вместе с разбором подстрок.

Маркер легаси legacy_unclassified — состояние ХРАНИЛИЩА для строк отказа, написанных до вступления таксономии в силу. Он не является кодом, меткой не становится и в форензике показывается отдельной категорией.

Алерты контура здоровья биржи (EngineAlert → sinks) ​

Контур здоровья CLOB и статус-поллер (openspec exchange-health-resilience; SSOT механики гейта — ../target/risk-and-emergency.md §«Гейт здоровья биржи») шлют алерты в ТЕ ЖЕ sinks, что stop-alarms (LogSink всегда; TelegramSink при BOT_TG_*), но НЕ через маппинг BotEvent — прямыми EngineAlert-диспатчами (crates/pm-engine/src/observability/exchange_alerts.rs, engine/drain.rs, observability/stop_alarms.rs):

  • exchange_degraded_entered → severity Critical. Срабатывает ровно на Healthy/Closed → Degraded/Open. Несёт разбивку классов улик (authenticated_access / definitive_maintenance / suspect / transport) и корроборацию статус-страницы. Провал Recovering/HalfOpen → Degraded/Open остаётся тем же эпизодом и не шлёт повторное сообщение. Degraded → Recovering тоже не алертится.

  • exchange_degraded_cleared → severity Warning. Возврат в Healthy; несёт длительность эпизода (Degraded + Recovering). Троттла нет — одна пара сообщений на эпизод by construction.

  • status_page_transition → severity Warning. Каждый переход классифицированного состояния статус-страницы (включая в/из Unknown) и каждое появление/уход записи работ (activeMaintenances). Стартовая классификация «зелёный» (Unknown → Up при запуске) переходом не считается и не алертится. Троттла нет — дифф-семантика поллера даёт события только на фактических изменениях.

  • persistent_drain_entered → severity Critical. Немедленно при входе в персистентный drain по ЛЮБОЙ автоматической причине (circuit_breaker, accounting_invariant, user_ws_accounting_unavailable, unresolved_submit_without_reservation) — в дополнение к 24-часовому reminder'у. Операторский operator_drain НЕ алертится. Ровно один алерт на переход (проигравший CAS повтора не даёт); доставка fire-and-forget, отказ синка не блокирует drain.

    Закрытие управляющего канала учёта, вызванное ШТАТНЫМ завершением движка, этой тревоги НЕ порождает и drain не вводит — завершение разрушает плоскость подтверждений по построению. Подавленный случай виден в журнале строкой с полями shutdown_in_progress=true suppressed=true и в счётчике pm_engine_user_ws_accounting_shutdown_suppressed_total; сравнение с pm_engine_user_ws_accounting_failure_total{kind="control_channel_closed"} показывает, сколько закрытий всё-таки ввели drain. Терминальный сбой канала и провал reconnect-сверки вводят drain и алертят В ЛЮБОЙ фазе, включая завершение.

  • stop_deferred_restart_prolonged → severity Critical, канал stop-alarms. Срабатывает, когда отложка стоп-капитуляции окном рестарта matching engine переросла порог: ≥5 подряд отложек (STOP_RESTART_DEFER_ESCALATION_COUNT) ИЛИ строго >10 мин суммарного времени эпизода без исполнимого стопа (STOP_RESTART_DEFER_ESCALATION_MS). Троттл — 15 мин (STOP_ALARM_THROTTLE_MS; константа едина для классов stop-alarms, ключ троттла — пара «сигнал + класс»). Ниже порога — намеренная тишина (только троттлённые WARN окна рестарта); успешный филл капитуляции сбрасывает эпизод. Rationale — ../strategies/directional-harvester.md §«Стоп-контур».

PnL formatting в сообщениях: feedback_pnl_display_units (|x| < $1 → ¢dd.dd, |x| ≥ $1 → $d.dd). TG outage: failed sends логируются WARN, tick продолжается; replay buffer нет — историческая база в SQLite.

Тревога свежести книги (orderbook-freshness-observability) ​

Периодический такт 1 Гц (spawn_metrics_pump, crates/pm-engine/src/observability/metrics_pump.rs) сравнивает максимальный локальный возраст книги среди отслеживаемых токенов с порогом PM_ENGINE_ORDERBOOK_STALE_ALERT_MS (обязательная переменная окружения, без умолчания — CLAUDE.md проекта §Environment) и шлёт EngineAlert в ТЕ ЖЕ sinks, что и остальные алерты этого раздела (LogSink всегда; TelegramSink при BOT_TG_*):

  • orderbook_stale_entered → severity Critical. Срабатывает на переходе Fresh → Stale (максимальный возраст пересёк порог снизу вверх). Тело сообщения несёт сам возраст и порог: денежные решения движка сейчас принимаются по устаревшей книге хотя бы одного отслеживаемого токена.

  • orderbook_stale_repeated → severity Critical. Круг III: повтор на затянувшемся Stale — СВОЁ правило, отличимое от входа. Тело несёт длительность УЖЕ ИДУЩЕГО эпизода, измеренный максимальный возраст и порог; до круга III повтор рендерился телом входа (orderbook_stale_entered), и оператор не мог отличить длинный один эпизод от серии новых входов ни фильтром rule, ни по телу сообщения.

  • orderbook_stale_cleared → severity Warning. Возврат Stale → Fresh; несёт длительность эпизода в миллисекундах.

Все три правила заглушены для Telegram-транспорта (решение оператора 2026-09-10, TELEGRAM_SILENCED_RULES в crates/pm-engine/src/observability/sink.rs): боевой факт после выката — 16 входов в orderbook_stale_entered за 28 минут при пороге возраста книги 3 с на ТИХОМ рынке при живом WS-подключении (REST-обход тихого токена отвергается: метка времени ответа /books старше метки последнего WS-кадра). Тревога в этой конфигурации ловит отсутствие сделок по конкретному токену, а не разрыв потока, и немедленного действия оператора не требует. LogSink и ряды pm_engine_orderbook_* на /metrics продолжают получать все три правила без изменений — заглушен только канал оператора.

Троттл повтора, ПОКА состояние остаётся Stale, — 15 мин (ORDERBOOK_STALE_ALERT_REPEAT_MS), отдельная константа от STOP_ALARM_THROTTLE_MS stop-alarms (то же число, разные константы — эта тревога не относится к семейству stop-alarms). Ключ состояния — ПРОЦЕССНЫЙ, не по сигналу: возраст книги наблюдается по всем отслеживаемым токенам разом, а не per-position, поэтому у тревоги нет ключа дедупликации «сигнал + класс», в отличие от stop-alarms.

Такт NoData (ни одного отслеживаемого токена с применённым кадром книги) НЕ меняет состояние тревоги и НЕ алертит. Переход через NoData (Fresh → NoData → Stale или наоборот) не считается прямым переходом друг в друга — состояние, зафиксированное ДО NoData, остаётся действующим, пока NoData длится. Это НЕ дыра в покрытии: NoData означает «свежесть неизвестна, а не плоха» (см. docs/target/observability.md §«Возраст локальной книги на решении»), и тревожить по неизвестности значило бы врать о её причине.

Доставка одного алерта в моменте гарантирована семафором на ОДНО разрешение (Semaphore::new(1)) — такт публикации pm_engine_orderbook_max_age_seconds НЕ ждёт диспетча: если предыдущая доставка ещё не завершилась (например, Telegram завис), текущий алерт отбрасывается и считается pm_engine_orderbook_stale_alert_dispatch_dropped_total. Рост этого счётчика значит «тревога временно не доставляется», не «такт свежести остановился» — pm_engine_orderbook_max_age_seconds/_freshness_available продолжают публиковаться независимо от исхода диспетча.

Порог обновления книги (stale-orderbook-rest-refresh) ​

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

  • Порог обновления (PM_ENGINE_ORDERBOOK_REFRESH_STALE_MS) читает отдельный 1 Гц периодический участник (engine::orderbook_refresh_scheduler, docs/target/observability.md §«Пакетный REST-обход устаревших книг»). Токен, чей локальный возраст книги превысил ЭТОТ порог, попадает в список пакетного POST /books ближайшего такта — движок пытается САМ починить свежесть, не дожидаясь оператора.
  • Порог тревоги (PM_ENGINE_ORDERBOOK_STALE_ALERT_MS, раздел выше) — эскалация ЧЕЛОВЕКУ через EngineAlert: сигнал, что денежные решения движка сейчас принимаются по устаревшей книге хотя бы одного отслеживаемого токена.

Стартовый инвариант: порог обновления обязан быть строго меньше порога тревоги, иначе движок отказывает в старте. Сообщение отказа называет ОБА значения — оператору не нужно искать второе в другом месте лога (crates/pm-engine/src/config/orderbook_refresh_stale.rs). Инвариант гарантирует только ПОРЯДОК пересечения порогов на одной временной оси (кандидат в список обновления пересекает границу раньше, чем кандидат в тревогу) — он НЕ гарантирует, что попытка обновления фактически завершится раньше, чем сработает тревога: оба участника независимы и работают на кадентности 1 с каждый, и при разнице между порогами меньше секунды или задержке одного из тактов возраст книги может пересечь оба порога между соседними замерами. Тревога свежести от исхода попытки обновления не зависит и ею не подавляется ни при каком исходе — ожидающая или уже отправленная REST-попытка не отменяет и не откладывает эскалацию оператору.

Обе переменные обязательны в env инстанса, без умолчания. Отсутствие, пустая строка (после trim), нечисловое значение или ноль отказывают старт движка — тем же приёмом, что и у порога тревоги. Ни то ни другое НЕ связано с торговым порогом допуска (directional.discovery.orderbook_max_age_ms, config.db) — тот порог видит только гейт допуска BUY одной активной стратегии; обе переменные этого раздела — инстанс-уровневая настройка наблюдаемости/самолечения, которая следит за книгой при любой активной стратегии.

Как читать устойчиво ненулевой REST-трафик. Ряды REST-обхода (pm_engine_orderbook_refresh_applied_total, _requests_total, _errors_total и прочие, см. docs/target/observability.md) намеренно НЕ несут метку токена, рынка или condition — тот же принцип, что у рядов предшественника (агрегация по инстансу). Устойчивый рост этих рядов — признак деградации WS-подписок: хотя бы одна отслеживаемая книга регулярно достигает порога обновления чаще, чем ожидалось бы при здоровой WS-доставке, и повод диагностировать WS (реконнекты, здоровье подписки, естественную активность рынка), а НЕ основание приписывать деградацию конкретному токену — по этим рядам такая атрибуция структурно невозможна. Токен, который систематически восстанавливается REST, нужно искать через MarketOrderbookCoverage (охват WS-подписки, docs/target/observability.md) или защищённый диагностический Local API, не через агрегированный счётчик.

Алярмы правила ценовой сетки (order-price-tick-grid-conformance) ​

Четыре класса, все — канал stop-alarms (LogSink всегда; TelegramSink при BOT_TG_*), ключ дедупликации — пара «токен + класс», повтор — не чаще одного раза в 15 минут (STOP_ALARM_THROTTLE_MS), пока стратегия продолжает поднимать тот же алярм. Состав сообщения одинаков у всех четырёх и задан типом TickGridAlarmFacts: рынок, токен, роль величины, наличие и размер позиции, значение шага с ИСТОЧНИКОМ и ПОКОЛЕНИЕМ, признак опровержения, цена-кандидат, подробность отказа.

  • tick_grid_quarantine → Error при открытой позиции, иначе Warning. Биржа отвергла заявку по правилу минимального шага (price … breaks minimum tick size rule …). Значение шага объявляется ОПРОВЕРГНУТЫМ для токена, рынок перестаёт получать любые исходящие цены. Движок НЕ утверждает, что значение неверно: те же симптомы даёт дефект нашего квантования, гонка со сменой шага и неверный токен — вывод остаётся оператору. Снятие: по рынку появилось БОЛЕЕ НОВОЕ допустимое значение шага (рантайм-событие смены либо обнаружение, поднявшее поколение) и оно не из множества опровергнутых. Повторное получение ТОГО ЖЕ значения недействительность НЕ снимает. Признак живёт в памяти процесса: перезапуск его стирает, и цена этого — ОДИН отказ на токен вместо серии.

  • tick_grid_protective_blocked → Error. Защитный выход (лежащая цель, капитуляция стопа, защитная продажа контура ранга) НЕ выпущен, потому что валидной цены на сетке не существует: шаг неизвестен либо опровергнут. Открытая позиция остаётся НЕЗАЩИЩЁННОЙ. Алярм поднимается на ПЕРВОМ таком отказе, а не после серии. Снятие: защитная заявка выпущена (шаг стал известен и не опровергнут) либо позиция закрыта.

  • tick_grid_cancel_unconfirmed → Error. Отмена увеличивающей экспозицию заявки при переходе рынка в карантин не подтверждена: позиция способна вырасти, пока защитная сторона уже заблокирована. Снятие: отмена подтверждена биржей либо заявка исчезла из книги по сверке статусов.

  • tick_grid_construction_defect → Error. Фиксирующая проверка шва выпуска отвергла внесеточную цену при АКТУАЛЬНОМ поколении шага — то есть движок построил цену вне сетки, ЗНАЯ верный шаг. Это дефект ДВИЖКА, а не отказ рынка: заявка не отправлена, бюджет не резервировался. Снятие: пересчёт дал цену на сетке (иной отпечаток намерения), сменилось поколение шага, сменилась ревизия конфигурации либо рынок снят с сопровождения. Разбирать причину обязательно — самоустранение здесь означало бы, что дефект просто перестал попадать в тот же отпечаток.

Гонка «шаг сменился между построением цены и её выпуском» алярма НЕ поднимает вовсе (класс «устаревшее состояние»): смешивать её с дефектом построения запрещено — иначе штатная смена шага рынка обесценивала бы алярм ложными срабатываниями.

Счётчики рядом с алярмами. Метрика pm_engine_order_price_grid_refusal_total{reason,role} считает отказы правила сетки по причинам: tick_unknown, tick_disproven, below_one_tick, empty_clamp_interval, emit_seam_defect, emit_seam_stale, latched_repeat, protective_exit, pinned_dca_off_grid — все действующие для directional, единственной работающей стратегии. paired_merge_bid и paired_merge_imbalance_exit — исторические значения этой же причины, оставшиеся от архивированной стратегии paired_merge: текущий код их не эмитирует, но они могут встретиться в уже накопленных рядах метрики. Отказ БИРЖИ по правилу шага попадает в корзину invalid_tick метрики отказов заявок (RejectCode::InvalidTick) — до разбора формулировки такие отказы уходили в корзину неразобранных, и 652 отказа инцидента 2026-08-10/11 были невидимы.

Почему позиция не докупается. Метрика pm_engine_dca_skip_total{reason} считает оценки добора, закончившиеся без заявки BUY: одно приращение на каждый такт рыночных данных по позиции с активным сигналом, пока причина держится. Это ряд ОЦЕНОК, не событий — его темп равен темпу тиков, читать его надо как «какая причина сейчас держит добор», а не как число отказов. Причины: restore_backoff (отсрочка после отклонённого выпуска), buy_already_resting (BUY уже стоит), target_price_not_pinned (нет приколотой цены входа), pinned_price_off_grid (приколотая цена ушла с сетки после смены шага; тот же случай считает и pm_engine_order_price_grid_refusal_total{reason= "pinned_dca_off_grid"}), remaining_below_min_size (остаток бюджета на рынок не покрывает один лот), no_progress_limit (доборы подряд без единой налитой доли), post_only_would_cross (мейкер-заявка на приколотой цене пересекла бы книгу). target_price_not_pinned — постоянное состояние любой позиции, пережившей перезапуск движка: приколотая цена живёт только в памяти процесса и из базы не восстанавливается, а смена config.db требует перезапуска. Те же исходы пишутся в лог на уровне debug (до 2026-09-13 — trace, в бою невидимы: разбор позиции Мехико 12.09.2026, 1.3 доли ниже лота при бюджете $5, потребовал чтения кода вместо панели). Ступени воронки входа: blocked_no_tick_size (шаг неизвестен/опровергнут) и blocked_no_exit_target (на сетке нет цели выше цены входа-кандидата).

Тревога position_coverage_discrepancy (confirmed-coverage-forbids-unsellable-verdict) ​

Правило position_coverage_discrepancy (StopAlarmClass:: PositionCoverageDiscrepancy) поднимается КАЖДЫМ измерением, чей класс — расхождение (CoverageState::is_discrepancy(): «частично покрыто» или «без покрытия», включая неподтверждённое) — до гейта перед SELL и независимо от него. Учтённый размер, против которого измерение классифицировано, — сумма учтённых размеров ВСЕХ активных позиций кошелька на токене (docs/target/storage.md §«Подтверждённое покрытие»), а не собственный размер одной позиции; при более чем одной активной позиции на токене эти две величины расходятся. Текст тревоги несёт ОБЕ величины раздельно — собственный учтённый размер позиции (position_booked_shares) и сумму по токену (token_booked_shares) — плюс измеренное количество (measured_shares) и недостачу. Недостача считается против СУММЫ, а не против размера одной позиции: класс уже посчитан против суммы, и недостача против размера одной позиции могла бы выйти нулевой при явном классе «частично покрыто» — самопротиворечивое сообщение. У единственной активной позиции на токене обе величины совпадают, и текст не отличается от однопозиционного случая.

Измерение перед классификацией МОЖЕТ быть отвергнуто ещё до записи — счётчик pm_engine_coverage_measurement_refused_total{reason} с закрытым словарём меток: foreign_wallet (позиция принадлежит кошельку, отличному от кошелька верификатора — balanceOf отвечает по одному holder'у, и применять его к чужой позиции значило бы записать чужой баланс как её собственный) и position_gone (позиции уже нет в кэше реестра — закрыта к моменту, когда чтение завершилось). Такой отказ не пишет измерение и не несёт номер — он предшествует классификации, а не является её исходом.

Тревога covered_inventory_rejected_by_exchange (confirmed-coverage-forbids-unsellable-verdict) ​

Правило covered_inventory_rejected_by_exchange (StopAlarmClass:: CoveredInventoryRejected, observability/stop_alarms.rs) поднимается на ТОЙ ЖЕ границе оседания баланса, что и старое balance_settling_bound_breached (StopAlarmClass::BalanceSettlingBoundBreached), но при ДРУГОМ измерении: последнее ГОДНОЕ покрытие называет позицию COVERED (доли на месте на уровне учтённого размера или выше), а биржа всё равно отклоняет каждую попытку выставить выход. Причина заведомо НЕ в недостаче долей — старое правило остаётся действующим только для отказа БЕЗ подтверждённого покрытия (измерение отсутствует, устарело или называет иной класс). Текст тревоги никогда не утверждает нехватку долей на этой границе — он печатает диагностику (см. ниже) и оставляет причину открытой, если диагностика её не назвала.

Стадия воронки (funnel) — exit_blocked_uncovered (entry_gate_counters.exit_blocked_uncovered, strategies/directional/ strategy.rs): счётчик растёт КАЖДЫЙ раз, когда единый гейт покрытия перед автоматическим SELL (docs/target/risk-and-emergency.md §«Гейт подтверждённого покрытия перед автоматическим SELL») отклоняет попытку выставить обычную цель или автоматическую капитуляцию — независимо от того, дошло ли до этой тревоги (тревога — только когда позиция при этом осталась совсем без выхода на книге).

Метка source у pm_engine_position_coverage_checks_total{state,source} — ЗАКРЫТЫЙ словарь вызывающей стороны, независимый от итогового state: episode_bound (граница оседания баланса, страница выше), redeem_zero_balance (терминальный отказ выкупа по нулевому балансу), holdings_reconcile (периодическая сверка холдингов) и confirmation_due (подтверждающий опрос узлов по первому неподтверждённому нулю, D6). Ряд с source="holdings_reconcile" растущий > 0 — признак того, что периодическая сверка ДЕЙСТВИТЕЛЬНО переизмеряет живые позиции, а не только адресные проверки на границах.

Счётчик исходов диагностики противоречия — pm_engine_coverage_contradiction_diagnostic_outcome_total{outcome} (CoverageVerifier::diagnose_contradiction, D9): no_unknown_orders (среди живых заявок биржи по рынку/токену не нашлось ни одной, не опознанной локально — причина осталась неразобранной), unknown_remote_order (нашлась чужая живая заявка на бирже, которая может удерживать инвентарь — диагностика её НЕ отменяет), query_failed (запрос живых заявок не удался). Диагностика — строго read-only: она не отменяет заявки и не переписывает positions, только называет вероятную причину в events (coverage.contradiction.diagnostic) и в теле тревоги. Companion-gauge pm_engine_position_coverage_open_contradictions — сколько сигналов СЕЙЧАС несут открытое противоречие (публикуется каждый такт, включая ноль).

Величина годности измерения — pm_engine_position_coverage_invalid_measurement_open_positions: открытые позиции, чьё измерение НЕ Valid прямо сейчас (не измерялось ни разу, устарело по возрасту реконсиляции, либо разошлось с текущей суммой учтённых размеров активных позиций того же кошелька на том же токене — после частичной продажи/долива любой из них). Публикуется каждый такт, включая ноль — отсутствие образца читалось бы как «нет данных», а ноль — как «все измерения годны», и подменять одно другим прячет денежное состояние. Отдельный от него pm_engine_position_coverage_uncovered_positions считает ТОЛЬКО подтверждённое Uncovered с годным измерением — это порог останова торговли (docs/target/risk-and-emergency.md), а не общая величина негодности.

Telegram configuration ​

Bot token / chat_id — sensitive credentials, хранятся в env-файле instance'а (deploy/server/instances/<name>/.env), НЕ в config.db. Читаются из УНИФИЦИРОВАННОГО BOT_TG_* env (контракт по коду crates/pm-engine/src/config/loader.rs):

EnvDefaultСкоуп
BOT_TG_EVENTS_ENABLEDfalseMaster switch. Если false — Telegram sink = log-only (NullBackend), dispatch'и в WARN-логи.
BOT_TG_BOT_TOKEN—REQUIRED если BOT_TG_EVENTS_ENABLED=true. Format: 123456:ABC-....
BOT_TG_REPORTS_CHAT_ID—REQUIRED если BOT_TG_EVENTS_ENABLED=true. Получить через @userinfobot или GET /getUpdates.
BOT_TG_INSTANCE_LABEL—Опционально: префикс инстанса в сообщениях.
BOT_TG_EVENTS_BATCH_WINDOW_MS4000Окно батча per-event нотификаций.

Создание bot и получение chat_id — runbook.md раздел «Telegram bot setup».

Digest / per-event notifier ​

Периодический hourly/daily TelegramDigestScheduler (K4) в коде pm-engine удалён (0 совпадений) — вместо него per-event notifier с батч-окном BOT_TG_EVENTS_BATCH_WINDOW_MS (crates/pm-engine/src/observability/engine_event_notifier.rs). env/.env.example несёт только BOT_TG_* (устаревших TELEGRAM_* / PM_ENGINE_DIGEST_* там нет). Периодического PnL-summary у движка нет — уведомления идут по событиям.

Prometheus /metrics endpoint ​

pm-engine поднимает отдельный axum HTTP router на default 127.0.0.1:31340 с одним route'ом GET /metrics (Prometheus text exposition 0.0.4). Без auth. Изолирован от dashboard SSE/REST plane'ов (другой bind, другой процесс). Этот эндпоинт опрашивает VictoriaMetrics раз в 15 с — см. раздел «Сбор и хранение рядов» ниже. Рецепт ad-hoc чтения ниже остаётся полезен для мгновенного среза и для проверки самого эндпоинта, но историю за прошедшее время смотреть надо в хранилище: счётчики эндпоинта обнуляются рестартом процесса, ряды в хранилище — нет.

EnvDefaultСкоуп
PM_ENGINE_METRICS_BIND127.0.0.1:31340Semantic-Optional. При unset/invalid — default + WARN. НЕ в fail-fast. На VPS задан 0.0.0.0:31340 в compose (решение оператора 2026-08-10): bind на docker-сети контейнера БЕЗ публикации портов наружу — доступ только из туннельной сети.

Доступ к боевым метрикам (рецепт для агентов) ​

Порты наружу не публикуются; экспортёр слушает docker-сети контейнера движка. Чтение — SSH-туннель к адресу контейнера в туннельной сети:

bash
# адрес контейнера может меняться при пересоздании — сначала уточнить:
ssh deploy@poly.grot.app \
  "docker inspect -f '{{range \$k,\$v := .NetworkSettings.Networks}}{{\$k}}={{\$v.IPAddress}} {{end}}' pm-engine-harvester-engine"
# затем проброс на pm-tunnel-net адрес (пример: 172.18.0.4):
ssh -f -N -o ExitOnForwardFailure=yes -L 31350:172.18.0.4:31340 deploy@poly.grot.app
curl -s http://127.0.0.1:31350/metrics | grep <семейство>

Счётчики обнуляются рестартом/пересозданием контейнера — сравнение «до/после» валидно только внутри одного аптайма (pm_engine_engine_up-семейство и docker ps дают границы). Изнутри контейнера curl/wget НЕТ — не пытаться docker exec.

Воронка входа экспортируется с 2026-08-17 — одним рядом pm_engine_entry_funnel_total{stage}, где stage это имя счётчика из EntryGateCountersSnapshot::stages(). Словарь стадий ЗАМКНУТ компилятором: деструктуризация снимка исчерпывающая, и новый счётчик, не внесённый в перечень, не собирается. Единица ряда — ОЦЕНКА допуска, а не кандидат и не рынок: один кандидат, отклонённый одним гейтом сто раз, даёт сто.

Прежняя редакция раздела утверждала обратное («метрик не имеет вовсе»), и это было верно до 17.08: счётчики жили как AdmissionFunnelCounters → SSE-кадр EntryFunnelTick → кольцо в памяти на 300 тиков, то есть воронку старше пяти минут восстановить было нечем. Кольцо осталось как источник кадра оператору; хранение истории теперь на стороне ряда.

Новая стадия и РАЗРЫВ РЯДА blocked_risk_limit (unified-trading-gate-and-entry-readiness). Метка stage принимает одно новое значение — blocked_lifecycle_halt (EntryGateCounters, crates/pm-engine/src/strategies/directional/runtime_state.rs:686 и :690). До этой работы отказ по остановке жизненного цикла и по рубильнику накопленного PnL не проходил головной проверки вовсе и на отказе маршрутизатора попадал в blocked_risk_limit. Практическое следствие: сравнение blocked_risk_limit через границу выката НЕДЕЙСТВИТЕЛЬНО — падение этого ряда после выката не означает, что лимитов риска стало меньше. Ступень отказа теперь ровно одна на (рынок, такт): EntryGateCounters::note_admission_refusal (…/runtime_state.rs:1093) увеличивает один счётчик по ответу допуска.

Ряд pm_engine_clob_stale_transition_dispatch_total СНЯТ той же работой вместе со своей точкой записи: в crates/pm-engine/src/observability/metrics.rs на месте прежнего describe_counter! (между описаниями pm_engine_entry_blocked_exchange_health_total и pm_engine_entry_blocked_warmup_pending_total) оставлен комментарий с обоснованием. Он считал рассылки перехода здоровья, пришедшие после ухода автомата дальше, и существовал ради отсечки, от которой зависели публикация кэша и перевзведение прогрева; обе зависимости сняты. Отсутствие ряда на эндпоинте — не поломка сбора.

Обе новых стадии на дашборд НЕ доходят — НЕЗАВЕРШЕНО (проверено 2026-08-25). Кадр EntryFunnelTick контракта pm-bot-events собирается поимённо (crates/pm-engine/src/wire/bot_event_map.rs:1009), полей blocked_lifecycle_halt в нём нет. Ряд Prometheus и поминутный ярус ClickHouse их показывают — у них общий источник EntryGateCountersSnapshot::stages() (crates/pm-engine/src/strategies/directional/strategy.rs:15631 и :15652); воронка дашборда и scripts/analysis/funnel-window.jq — нет (jq читает recent_funnel_ticks / ticks того же кадра, funnel-window.jq:11). Молчанием это не остаётся: знаменатель markets_evaluated растёт ВЫШЕ вопроса допуска (…/strategy.rs:14958 против :15102), поэтому такие отказы уходят в неприписанный остаток цепочки и дают ЛОЖНУЮ «Ошибку parity» на исправном движке (frontend/pm-engine-dashboard-web/src/lib/funnelAggregate.ts:344). Остановка жизненного цикла взводится и на СТАРТЕ процесса (TradingGate::at_startup, crates/pm-engine/src/engine/trading_gate.rs:497), и снимается только при переходе цикла в рабочее состояние (EmergencyGuard::republish_lifecycle_halt, crates/pm-engine/src/engine/emergency_guard.rs:217). Сколько оценок попадает в это окно на боевом узле — НЕ ЗАМЕРЕНО; проверяется сверкой pm_engine_entry_funnel_total{stage="blocked_lifecycle_halt"} с остатком цепочки на дашборде за то же окно.

Отдельные pm_engine_entry_* относятся к другим блокировкам входа (entry_blocked_exchange_health, entry_freshness_block, entry_closed_market_block, entry_observation_*, entry_pattern_*, entry_quality_*) и стадиями воронки не являются.

Очистка книги от собственных покоящихся заявок наблюдаема двумя рядами. pm_engine_entry_price_own_book_total{state} считает каждый расчёт в прямом входе, защитном снятии, допуске watch-arm и выпуске watch-arm; state принимает no_own, top_unchanged, top_was_ours или desync. Единица — расчёт в одном месте, не уникальный рынок: один снимок может дать несколько единиц. pm_engine_entry_price_own_book_desync_total{reason,site} отдельно называет причину отказа очистки (own_exceeds_level, level_absent, spread_inverted) и место решения (entry_price либо stale_withdraw). Полная семантика меток и последствий остаётся в describe-блоках observability/metrics.rs.

pm_engine_entry_blocked_risk_limit_total{kind} — намерения входа, отвергнутые одним из трёх канонических лимитов риска на допуске (OrderRouter::allocate), по виду лимита: kind ∈ {shares_per_market, budget_per_market, total_budget} — закрытый набор, у стратегии ровно три лимита. Бьётся стратегией в точке первого отказа — у directional, единственной работающей стратегии, это try_emit_entry. Строки заявки и события OrderRejected такой отказ не порождает (решение оператора 2026-09-06), поэтому это единственный ряд, где видно, КАКОЙ лимит закрыл вход; параметры отвергнутого намерения (размер, цена, стоимость, дословная причина) — в атрибутах кадра воронки допуска admission_funnel_samples.gate_inputs_json (risk_limit_kind, risk_limit_detail, intent_size_shares, intent_price_pip, intent_cost_micro_usd). Отказ лимитом при полностью проинвестированной стратегии — штатное состояние: ряд — разбивка, не тревога.

pm_engine_hydrated_buy_without_entry_role_total — покупка, поднятая из хранилища при старте с пустой ролью входа в строке (orders.entry_role). Считается ЛЮБАЯ такая покупка, без фильтра по стратегии и имени строки: роль пишется из типизированного намерения и читается из колонки, имя источником не является. Пустая роль у покупки возможна только у строки, записанной до появления колонки и не опознанной одноразовым переводом по имени; новая покупка без роли отвергается до записи. Такая покупка поднимается без роли (подстановка запрещена — она может быть добором) и контуром входа не обслуживается, оставаясь живой на книге, — поэтому ненулевой прирост есть повод разобрать строку, а не штатный шум; множество таких строк конечно и не растёт. Ряд бьётся в двух местах: при подъёме открытых заявок на старте (recover_from_db) и при восстановлении снимка уже отпущенной заявки на её терминальном исходе (fetch_released_snapshot) — одна и та же историческая строка может дать по единице на каждом.

С 2026-08-19 воронка пишется в ClickHouse поминутными ДЕЛЬТАМИ (pm_analytics.admission_funnel_counters), и кольцо на 300 тиков перестало быть единственным источником: вопрос «какие ворота срезали корзины вчера» отвечается запросом к архиву. Ряды Prometheus воронки по-прежнему не имеют и иметь не могут — метки стадий низкокардинальны, а идентификаторы рынков нет. Правила маршрутизации и смысл каждого значения — docs/target/observability.md.

Две стадии отказа выхода по инвентарю различаются с 2026-08-19:exit_blocked_balance_settling — все отказы биржи «не хватает баланса»; exit_blocked_balance_settling_size_refined — их подмножество, где биржа НАЗВАЛА доступное количество меньше поданного размера. Первое без второго — задержка расчёта после покупки, она проходит сама; второе — устойчивое расхождение книги движка с биржей, и оно не пройдёт, пока размер не уточнён. Разность двух рядов и есть число случаев чистой задержки.

Семейства (полный и единственный SSOT имён/семантики — describe-блоки в crates/pm-engine/src/observability/metrics.rs): здоровье биржи pm_engine_clob_health_*, pm_engine_clob_error_events_total{class}; статус-страница pm_engine_status_page_* (+schema_anomalies); стоп-контур pm_engine_stop_* (включая restart_defer_*); тень качества входа pm_engine_entry_quality_*; отбор/ось pm_engine_gamma_*; учёт/ордера pm_engine_orders_*, pm_engine_signals_*; идентичность выкаченного кода pm_engine_build_info.

Операционная position-limited iteration публикует gauges без high-cardinality iteration_id label: pm_engine_position_iteration_active, pm_engine_position_iteration_limit, pm_engine_position_iteration_opened, pm_engine_position_iteration_reserved, pm_engine_position_iteration_remaining. Counters: pm_engine_position_iterations_started_total, pm_engine_position_iterations_reached_total, pm_engine_position_iterations_interrupted_total, pm_engine_position_iteration_capacity_denied_total, pm_engine_position_iteration_recovery_failure_total. При достижении лимита используется существующий Critical persistent_drain_entered; тело дополнено iteration id, opened/limit, временем старта и указанием проверить тестовую выборку. Отдельного второго Telegram-сообщения нет.

При активации новой ограниченной итерации одноразовое разрешение System Restart может автоматически снять уже активный Drain. После durable-clear и публикации DrainModeChanged { draining: false } этот источник напрямую отправляет один информационный entry_halt_cleared: в сообщении названы автоматическая активация и wallet, но не POST /api/local/resume. Первый неактивный опрос периодического reminder молчит и не дублирует это сообщение. Ошибка одного alert-sink журналируется, последующие синки всё равно вызываются.

Представительный набор экспортируемых pm_engine_* метрик (полный список — crates/pm-engine/src/observability/metrics.rs):

Counters (BotEvent stream taps):

  • pm_engine_signals_emitted_total{strategy}
  • pm_engine_orders_placed_total{strategy, side}
  • pm_engine_orders_rejected_total{reason} — reason = reject-код таксономии (каталог значений выше); бьётся в ЕДИНСТВЕННОМ помощнике эмиссии, а не на кране доставки событий
  • pm_engine_uncoded_reject_alerts_deduped_total — провинившиеся СТРОКИ, свёрнутые в один сигнал «отказ записан без кода» либо подавленные его TTL внутри одного процесса (сигнал — один на инцидент за TTL)
  • pm_engine_fills_total
  • pm_engine_positions_opened_total
  • pm_engine_positions_closed_total
  • pm_engine_redeem_submitted_total{strategy}
  • pm_engine_redeem_confirmed_total{strategy}
  • pm_engine_redeem_failed_total{strategy, terminal}
  • pm_engine_redeem_no_payout_total{strategy} — терминальный NO_PAYOUT БЕЗ попытки выкупа (держимый остаток — проигравшего исхода, выплата ноль); positions.status не меняется, счётчик не означает закрытие позиции
  • pm_engine_lifecycle_transitions_total{from, to}
  • pm_engine_lifecycle_halt_total{reason}
  • pm_engine_intent_suppressed_total{reason, strategy}
  • pm_engine_quote_ticks_coalesced_total — наблюдения котировки, оставленные как per-token latest-wins pending вместо отдельного SSE-кадра.
  • pm_engine_quote_ticks_unchanged_suppressed_total — повторный BBO, отброшенный до EventBus/SSE.

Gauges:

  • pm_engine_lifecycle_state — текущее состояние FSM (population deferred к K3+ tuning).
  • pm_engine_unresolved_submits — сколько отправок находится в неопределённом исходе (обе стадии, «сверка идёт» и «сверка не дала ответа», считаются вместе).
  • pm_engine_unresolved_submits_held_micro_usd — сколько бюджета удержано этими отправками, микродоллары.
  • pm_engine_unresolved_submits_oldest_age_seconds — возраст САМОЙ СТАРОЙ неразрешённой отправки; 0, когда неразрешённых нет. ФОРМА ЭКСПОРТА РАСПРЕДЕЛЕНИЙ — КОРЗИНЫ, а не сводка с квантилями. Экспортёру заданы адресные схемы корзин по трём семействам (горячий путь, внешние вызовы, счётные величины — pm-common/src/observability/prometheus.rs), поэтому у каждого распределения есть ряды _bucket с меткой le, и перцентиль за произвольное окно считается по ним. Выражение ниже — ОСНОВНАЯ ветвь формулы, а не формула целиком:
histogram_quantile(0.99, sum by (le, instance_label, <смысловые метки>) (rate(S_bucket[5m])))

Панель обязана оборачивать её ДВУМЯ обязательными частями, иначе показывает число там, где показывать нечего:

(
  histogram_quantile(0.99, sum by (le, L) (rate(S_bucket[5m])))
    and on(L) (sum by (L) (increase(S_bucket{le="+Inf"}[5m])) >= 100)
)
or
(
  sum by (L) (S{quantile="0.99"})
    unless on(L) (count by (L) (S_bucket{le="+Inf"}))
)

где L — метка цели instance_label плюс смысловые метки ряда. Первая часть — ДОСТАТОЧНОСТЬ ВЫБОРКИ: перцентиль по десятку наблюдений недостоверен, и панель обязана остаться пустой, а не показать число. Считается она по S_bucket{le="+Inf"}, а НЕ по S_count: последний печатают ОБЕ формы экспорта, и сразу после выката он захватил бы старые summary-наблюдения. Вторая часть — ЗАПАСНАЯ ВЕТВЬ на прежней форме: она действительна до выката движка и гаснет ПОСЕРИЙНО, как только у серии появляется ряд _bucket (гашение по СУЩЕСТВОВАНИЮ ряда через count, а не по числу наблюдений: при нуле наблюдений increase даёт пустой вектор и не погасил бы ничего). Запасная ветвь снимается отдельным шагом после накопления histogram-истории.

Четыре состояния этой формулы проверяются scripts/test-dashboard-compat-queries.sh: он поднимает ВРЕМЕННЫЙ контейнер VictoriaMetrics той же версии, что на сервере, кладёт синтетические ряды по одному на состояние и прогоняет выражение, взятое ИЗ файла панелей. Боевое хранилище при этом не трогается.

Следствие, которое надо знать оператору: между выкатом движка и накоплением выборки панель НАМЕРЕННО ПУСТА. Ряды _bucket существуют с нуля (предрегистрация), поэтому запасная ветвь гаснет сразу после выката, а histogram-ветвь до ста наблюдений за окно не проходит порог достаточности. Пустая панель в этом окне — не поломка: показать старое summary-число значило бы выдать устаревшую величину за текущую, а перцентиль по десятку наблюдений — недостоверную.

Отличить это состояние от сломанной точки записи одним рядом НЕЛЬЗЯ, и pm_engine_build_info для этого не годится. Он несёт только commit, dirty, version и built_at при постоянном значении 1, то есть о проводке наблюдений не говорит ничего; вдобавок built_at меняется при пересборке того же коммита, а при dirty=true метка commit работающий код не идентифицирует. Его доказанная функция — обозначить ГРАНИЦУ выката при сопоставлении с заранее известным отпечатком нужной чистой сборки, и только она. Свидетельство здесь НЕСИММЕТРИЧНО, и это надо держать в голове. РОСТ S_bucket{le="+Inf"} доказывает, что точка записи работает. ОТСУТСТВИЕ роста не доказывает ничего: большинство распределений событийны — pm_engine_redeem_latency_seconds растёт только после погашения, pm_engine_clob_submit_latency_seconds — только при отправке заявки, — и без соответствующих событий исправный ряд тоже стоит на месте. Чтобы объявить точку записи сломанной, нужен НЕЗАВИСИМЫЙ положительный признак: счётчик событий, которые за то же окно обязаны были попасть в этот ряд. Тот же довод, по которому признак применимости проверки задержки user-канала взят из счётчика принятых кадров, а не из _count самой величины: замкнутое доказательство молчит там, где обязано сработать.

Метка цели instance_label обязательна во ВСЕХ группировках запроса: её добавляет сборщик, и агрегация, её не назвавшая, отбрасывает её у одной ветви и оставляет у другой.

Шестнадцать распределений процесса предрегистрируются при старте, поэтому ряд существует со значением ноль ДО первого наблюдения: «наблюдений ещё не было» и «сбор сломан» различимы по СУЩЕСТВОВАНИЮ ряда, а не по значению.

Ряды окон задержки, заведённые 2026-08-27: pm_engine_e2e_seconds, pm_engine_exec_seconds, pm_engine_user_ws_seconds, pm_engine_scanner_ws_event_lag_seconds, pm_engine_scanner_loop_lag_seconds, pm_engine_market_data_queue_dwell_seconds.

Про пребывание в очереди различать СУЩЕСТВОВАНИЕ ряда и ЗАПИСЬ наблюдений. Ряд существует ВСЕГДА и с холодного старта: предрегистрация безусловна и идёт до того, как определён режим доставки. Наблюдения в него пишет ТОЛЬКО диспетчерский путь; в шардированном режиме канал диспетчера в доставке не участвует, и ряд остаётся нулевым. Поэтому ноль сам по себе ничего не означает.

Ряд режима pm_engine_market_data_dispatch_mode{mode="driver"|"sharded"} (оба значения метки присутствуют, у действующего единица) отвечает на ОДИН вопрос — ПРИМЕНИМ ли замер: при mode="sharded" ноль объяснён полностью, канал в доставке не участвует, и разбирать нечего. При mode="driver" он лишь снимает это объяснение, а поломки НЕ доказывает: замер пишется при фактическом приёме снимка из канала, поэтому в тихом окне без снимков исправный ряд тоже нулевой. Для вывода о поломке нужен положительный признак СНИМКОВ, ДОШЕДШИХ ДО ОЧЕРЕДИ, и общий счётчик входных кадров рынка им НЕ является: pm_engine_ws_events_received_total{channel="market"} растёт на каждом разобранном событии ленты, включая те, что вообще не порождают обновления книги (last_trade_price, new_market, market_resolved, tick_size_change), а порождённое обновление ещё может быть отброшено для неизвестного токена или отсутствующей исходной книги. «Кадры шли, пребывание нулевое» поэтому бывает и при полностью исправной записи.

Годный контроль в ДИСПЕТЧЕРСКОМ режиме — распределение длительности хука pm_engine_hook_duration_seconds{hook="on_market_data"}: в этом режиме оно растёт ровно на каждый снимок, ВЫНУТЫЙ из очереди, то есть на каждый, у которого обязано быть наблюдение пребывания. Растёт хук, не растёт пребывание — вот тогда сломана точка записи. НАЗВАННАЯ ГРАНИЦА: в шардированном режиме этот контроль неприменим (хук пишет и шард), но там неприменим и сам замер пребывания, так что вопрос не встаёт.

  • pm_engine_scanner_loop_lag_p99_seconds — ЗАПАСНАЯ ветвь панелей до накопления histogram-истории; снимается отдельным шагом. Действующий источник того же вопроса — pm_engine_scanner_loop_lag_seconds_bucket. Ниже описано поведение снимаемого ряда: p99 интервала между соседними

    итерациями цикла драйвера стратегии, то есть латентность САМОГО event-loop (длительность стратегических хуков меряет другая величина — pm_engine_hook_duration_seconds). Публикуется каждым тиком насоса, включая явный 0 для пустого окна: отсутствие ряда означает, что насос не работает, а не что цикл простаивает. Читать с оговоркой: окно хранит последнюю 1000 выборок, поэтому каждая точка ряда — p99 последней тысячи итераций (при боевом темпе это последние секунды), а НЕ перцентиль за весь прогон. Вывод о продолжительном прогоне обязан назвать, какая величина берётся по самому ряду (максимум, квантиль ряда), — подставлять точку ряда вместо перцентиля за период нельзя. ЗАМЕР НА БОЮ 2026-08-18 закрыл вопрос о применимости: при тике драйвера в 1 с ряд держится у 1.000848, то есть его верхний хвост образуют ТИКОВЫЕ ПРОБУЖДЕНИЯ, а не работа (p50 того же окна — 15–21 мкс, p95 — 6.8–9.9 мс). Сравнивать по нему цену секции наблюдения НЕЛЬЗЯ: величина упирается в период таймера при любой конфигурации. Для этого вопроса служит ряд ниже. p50 и p95 того же окна уходят только на SSE-провод полями scanner_loop_lag_p*_us и истории не имеют.

  • pm_engine_e2e_processing_p99_seconds — ЗАПАСНАЯ ветвь панелей до накопления histogram-истории; снимается отдельным шагом. Второго имени у этой величины НЕТ: её распределение — это pm_engine_hook_duration_seconds{hook="on_market_data"}, куда тот же elapsed пишут оба пути доставки. Ниже описано поведение снимаемого ряда: p99 СТЕНОВОЙ длительности хука on_market_data: обработка одного снимка, без ожидания в очереди и без пауз таймера между итерациями цикла. Это НЕ время процессора (прежняя формулировка «чистое время»/«pure CPU» исправлена 2026-08-26): таймер стоит вокруг всего fut.await в StrategyDriver::invoke_hook, поэтому всё, чего хук дожидается ВНУТРИ себя, входит в величину — чтение базы, транзакция записи заявки с fsync и сетевой POST в CLOB на пути входа. Именно внутри этого хука работает секция наблюдения входа, поэтому сравнение «секция включена против выключенной» ведётся по ЭТОМУ ряду (entry-observation-stage, пункт 2.5) — но при сравнении обязана быть зафиксирована доля проходов с отправкой: она меняет вклад сети, а не кода. Публикуется каждым тиком насоса, включая явный 0. Оговорка про окно та же: точка ряда — p99 последней тысячи обработанных снимков, а не перцентиль за прогон. Чего ряд НЕ покрывает: задача-писатель журнала живёт вне хука (design D6), её вклад виден только косвенно — через соперничество за процессор. То же значение уже уходит на SSE-провод полем e2e_processing_p99_us; ряд добавляет историю.

Идентичность выкаченного кода (openspec deployed-build-provenance) — один ряд, отвечающий на вопрос «какой код сейчас торгует»:

  • pm_engine_build_info{commit, dirty, version, built_at} — значение ВСЕГДА 1; смысл несут МЕТКИ. Ряд регистрируется и публикуется той же единственной точкой, что и описания всех прочих метрик (describe_engine_metrics), поэтому он присутствует на ХОЛОДНОМ скрейпе — сразу после старта, до первого рыночного события, — и уже с зарегистрированными HELP/TYPE.

Как читать метки:

  • commit — ПОЛНЫЙ идентификатор коммита либо явное unknown (git был недоступен на сборке). Подстановки таймстампа или правдоподобной константы вместо коммита не бывает.
  • dirty — true / false / unknown. true означает, что бинарь собран из ИЗМЕНЁННОГО рабочего дерева и метка commit НЕ называет работавший код. Идентичность кода — это пара commit+dirty, по одному commit судить нельзя.
  • version, built_at — СПРАВКА. Момент сборки вкомпилирован в бинарь, поэтому копирование и восстановление из резервной копии его СОХРАНЯЮТ (этим он и отличается от времени ФАЙЛА). Ответом он всё равно не является: он меняется на каждой пересборке той же пары commit+dirty, то есть называет СБОРКУ, а не код. Решать по нему, что выкачено, ЗАПРЕЩЕНО; штатный ответ — scripts/deploy-vps.sh verify (runbook.md#which-code-is-running).

Все четыре метки берутся ТОЛЬКО из отпечатка времени компиляции — ни имени хоста, ни идентификатора процесса, ни значения времени выполнения. Отсюда свойство ряда: перезапуск того же бинаря на том же хосте идентичность НЕ меняет, а выкат другого коммита меняет — то есть в истории метрик выкат виден как смена идентичности ряда, и только он. SSOT текста HELP — describe-блок в crates/pm-engine/src/observability/metrics.rs.

Тот же общий build-time контракт используют остальные выкатываемые Rust-бинари: dashboard публикует build_info в GET /health, а pm-cli и pm-trader-observer — в выводе --version. scripts/deploy-vps.sh verify сравнивает собственный отпечаток работающего dashboard с его manifest-record; для непроцессных артефактов явно сообщает NO RUNTIME COMPARISON.

Граница: dirty — свойство СБОРКИ, а не текущего дерева. Метка обновляется только пересборкой бинаря, поэтому «в репозитории сейчас чисто» и «выкаченный бинарь собран из чистого дерева» — РАЗНЫЕ утверждения. Триггером пересборки объявлен КАЖДЫЙ отслеживаемый файл репозитория (git ls-files) плюс файлы состояния git, поэтому смена отпечатка ВСЕГДА означает настоящую пересборку. Обратное («у пересобранного бинаря отпечаток всегда свежий») держится везде, кроме трёх названных щелей: git add нового файла без правки хотя бы одного уже отслеживаемого; правка НЕотслеживаемого файла внутри пакета; правка, попавшая в промежуток между запуском сборщика и концом компиляции. Индекс git (.git/index) триггером не объявлен намеренно — git status переписывает его после любой правки, и каждый такой вызов стоил бы лишней пересборки.

Что из этих щелей закрыто на ВЫКАТЕ. Первая (git add нового файла) на бой не выходит: замер показал, что крейт в ней не пересобирается вовсе, то есть бинарь остаётся собранным из чистого дерева и его dirty=false о нём самом ИСТИННО, — разойтись могла запись манифеста, и выкат передаёт сборщику отпечатка объявленную пару commit:dirty переменной PM_BUILD_PROVENANCE_STAMP (объявлена триггером cargo::rerun-if-env-changed), поэтому смена признака всегда перезапускает сборщик. Переменную выставляет сам scripts/deploy-vps.sh — это НЕ операторская ручка: заданное руками значение подменило бы объявленную пару, поэтому в env/.env.example её нет и быть не должно.

Третью щель (правка во время сборки) ловит пересвидетельствование состояния источника после сборки и до первой заливки. Вторая (правка неотслеживаемого файла внутри пакета) остаётся и на выкате: такой файл в признак изменённости не входит по решению --untracked-files=no. У локальных сборок разработчика щели остаются все три — метка описывает СБОРКУ, и судить по ней о текущем состоянии рабочей копии нельзя.

Цена триггеров замерена на этой рабочей копии (отладочный профиль, прогретый кэш): правка ЛЮБОГО отслеживаемого файла, включая документ, пересобирает pm-engine — Compiling pm-engine … Finished dev profile … in 8.80s; повторная сборка без правок крейт НЕ пересобирает — Finished dev profile … in 0.21s без строки Compiling. Штамп цену не изменил — перезамер на pm-engine после его введения (те же условия): повтор без правок и БЕЗ штампа Finished … in 0.20s без Compiling; первое появление штампа Compiling … in 7.95s; повтор с ТЕМ ЖЕ штампом Finished … in 0.20s без Compiling; смена штампа …:false → …:true снова Compiling … in 8.15s. То есть выкат без правок источника пересборкой не платит, а смена признака изменённости пересборку вызывает ВСЕГДА. Порядок величины — единицы секунд; конкретное число зависит от загрузки машины, поэтому сравнивать надо наличие/отсутствие строки Compiling, а не секунды.

Слышимость собственных исполнений (openspec unbooked-fill-detection-and-recovery, группы 1a/1b) — три счётчика денежного класса. Событие исполнения адресовано конкретной заявке по биржевому идентификатору; без соответствия «идентификатор → заявка» оно неприменимо, а заявка, пережившая перезапуск процесса, такого соответствия не имела вовсе (прецедент 2026-08-13, позиция 231: потеряно $5.94 выручки, строка списана с нулевым наливом):

  • pm_engine_resting_orders_adopted_total — сколько заявок, переживших перезапуск, получили соответствие при старте. Растёт ТОЛЬКО на заявках, подтверждённых биржей как живые. Ненулевое значение при старте — норма после перезапуска с непустой книгой, а не тревога.
  • pm_engine_resting_order_adoption_failures_total{source} — восстановление соответствий не выполнено: local/remote — источник снимка недоступен, adopt — отказ на конкретной заявке. Любое ненулевое значение означает, что исполнения по части заявок движок не услышит до следующего старта.
  • pm_engine_pending_frame_evicted_total{kind} — кадр, которому не нашлось адресата, вытеснен из буфера ожидания по TTL (30 с). kind="trade" — денежный класс: это доли, которых движок больше ниоткуда не узнает (пользовательский канал историю не переигрывает). kind="order" — служебный. Рост trade — прямая улика незабукированного исполнения.
  • pm_engine_reconcile_absent_inconclusive_total{reason} — заявка исчезла из списка живых, а её судьбу выяснить не удалось, поэтому вердикт отложен до следующего цикла. Отсрочка намеренная: объявить заявку снятой необратимо (это обнуляет налив), подождать — нет. reason="source_error" — обращение к бирже не удалось; reason="degraded_verdict" — биржа ответила, но ответ был безопасной заглушкой «снята, налив ноль», выданной из-за недоступной ленты сделок, а не выводом о судьбе заявки. Устойчивый рост означает, что заявки залипают в живом состоянии и нужен разбор доступности CLOB.

Фильтр обнаружения по подстроке названия (openspec discovery-market-slug-filter) — четыре величины, публикуемые адаптером Gamma на КАЖДОМ проходе обнаружения (стартовом и на каждом такте):

  • pm_engine_discovery_markets_pre_filter — сколько рынков дал проход ДО применения фильтра. Точка измерения закреплена требованием: непосредственно перед предикатом, то есть ПОСЛЕ отсева по признакам жизненного цикла, ПОСЛЕ отбора по типу рынка и ПОСЛЕ преобразования ответа во внутренние метаданные.
  • pm_engine_discovery_markets_admitted — сколько осталось ПОСЛЕ фильтра. При отсутствующем ключе равно предыдущей величине.
  • pm_engine_discovery_markets_conversion_rejected — сколько рынков отброшено преобразованием метаданных (неполный ответ источника).
  • pm_engine_discovery_slug_filter_active{value="<подстрока>"} — 1 с подстрокой в метке, если ЗАПУЩЕННЫЙ процесс сужает вселенную; 0 с пустой меткой, если не сужает.

Единица счёта у первых трёх одна — РЫНОК после устранения повторов по идентификатору рынка. Не событие (событие — единица пагинации Gamma и несёт несколько рынков) и не исход (исходов у рынка два и более). Смешение единиц — реальный риск чтения этих рядов.

Как читать пару величин (ровно ради этого они и введены — на глаз два разных нуля неразличимы):

до фильтрапосле фильтрачто этореакция
00обнаружение СЛОМАНО: тег, источник, тип рынка либо массовая отбраковка преобразованиемразбирать; на старте это отказ старта, на ходу вселенная стареет и гейт свежести со временем закроет новые покупки
N > 00фильтр не нашёл совпадений — ШТАТНОЕ ожидание листинга нужной датыничего; движок работает и ждёт
N > 0M > 0рабочее состояниеничего

Третья величина отвечает на вопрос «почему до фильтра ноль»: если она равна числу рынков от источника, причина не в теге, а в изменившейся форме ответа Gamma.

Четвёртая величина отвечает на операторский вопрос, который иначе неразрешим: горячей перечитки config.db НЕТ, поэтому «сохранено» и «применено» — РАЗНЫЕ состояния. Сохранённое из конфигуратора значение доходит до движка ТОЛЬКО перезапуском процесса. Для СУЖАЮЩЕЙ ручки цена смешения — продолжение торговли всем тегом при уверенности, что вселенная сужена. Сверять надо метку value работающего процесса, а не строку config.db.

Журнальная запись о фильтре пишется ТОЛЬКО на смене состояния (0 ↔ N) плюс одна строка при старте с действующим значением — при такте 60 с одинаковая строка на каждом проходе дала бы десятки записей в час, и журнал перестал бы быть поверхностью наблюдения.

Быстрый путь листинга (listing_watcher) — пять счётчиков. Механизм ловит кадр new_market из вебсокета, склеивает кадры окном в 1 с, делает ОДИН пачечный запрос /markets?condition_ids=…&include_tag=true и отдаёт найденное координатору обнаружения, который заводит рынок СВОИМ обычным путём. Ни один фильтр не ослаблен, поэтому набор рынков он не расширяет — только ускоряет допуск с «до такта обнаружения» до «около секунды».

  • pm_engine_listing_frames_total — принятые кадры new_market. Трансляция ГЛОБАЛЬНАЯ: сюда попадают кадры о ЧУЖИХ рынках (живой замер 2026-08-12: 88 кадров за 10 минут вне всякой погодной волны), поэтому величина НЕ равна числу интересных нам листингов.
  • pm_engine_listing_lookup_requests_total{result="ok"|"error"} — пачечные запросы к Gamma. Один запрос на чанк окна, а не на кадр.
  • pm_engine_listing_admitted_total — рынки, заведённые быстрым путём, то есть те, чей допуск опередил очередной проход.
  • pm_engine_listing_rejected_total{stage=…} — отсев. Метки замкнутые: duplicate (повтор кадра о том же рынке), entries_halted (входы остановлены — запрос не выпускался), handoff_full (исполнитель пачечных запросов не успевает — партия отброшена, читатель трансляции при этом НЕ ждал), lookup_failed, absent (рынок не вернулся незакрытым: либо справочник ещё не проиндексировал, либо рынок уже закрыт), incomplete_row (строка вернулась недозаполненной: нет перечня категорий, нет событийной привязки либо метаданные не преобразовались — вердикт НЕ окончательный), discovery_screen (полная строка, рынок не наш: жизненный цикл / чужая категория / тип рынка / фильтр названия), candidate_predicate (вероятность и окно входа).
  • pm_engine_listing_ws_lagged_total — кадры, потерянные отставанием читателя.

Известное ограничение учёта. Сумма меток вместе с admitted_total МЕНЬШЕ размера партии: рынок, оказавшийся уже отслеживаемым, не попадает ни в одну величину (он получает обновление метаданных, а не заведение), как и рынок, чьё заведение не состоялось из-за неудавшейся подписки. Разница — это НЕ потеря данных и не повод искать дефект.

Как читать: frames_total растёт, а admitted_total стоит — это ШТАТНОЕ состояние (кадры про чужие рынки), и разбирать надо метку stage, а не сам факт нуля. absent заметно больше нуля означает ОДНО ИЗ ДВУХ: вебсокет систематически опережает индексацию в справочнике либо рынки к моменту запроса уже закрыты — запрос идёт без признака закрытия и закрытых строк не возвращает, поэтому по одной этой метке причины не разделить; рынок при этом не теряется, его подхватит штатный проход. incomplete_row заметно больше нуля — это сигнал о ФОРМЕ ответа справочника (перестала приходить категория или событие), а не о рынках; при устойчивом росте способность вырождается в ноль допусков, и разбирать надо ответ источника. entries_halted больше нуля при снятой остановке входов — противоречие, требующее разбора. handoff_full больше нуля означает, что справочник отвечает дольше восьми окон подряд.

Три величины неразрешённых отправок заполняются КАЖДЫЙ тик metrics-pump'а из того же OrderRouter, что и бюджетная тройка, и зарегистрированы ЗАРАНЕЕ: экспортёр, опрошенный сразу после старта, обязан показать нули, а не пустоту («метрики нет» оператор читает как поломку сбора, и это другое утверждение, чем «метрика равна нулю»). Смысл состояния, регламент разбора и поверхность разрешения — runbook.md §«Неразрешённая отправка».

Контур здоровья биржи и статус-поллер (openspec exchange-health-resilience; имена и семантика — describe-блоки в crates/pm-engine/src/observability/metrics.rs):

  • pm_engine_clob_health_state — gauge состояния FSM здоровья CLOB: 0 Healthy / 1 Recovering / 2 Degraded. Пишется монитором на старте (процесс стартует Healthy) и на каждом переходе.
  • pm_engine_clob_error_events_total{class} — классифицированные улики деградации (class = authenticated_access | definitive_maintenance | suspect | transport). По спеке исключены: 429, бизнес-отказы и локальные до-сетевые ошибки.
  • pm_engine_clob_probe_attempts_total — число допущенных read-only authenticated проб; пока circuit Open, не чаще одной в 60 с.
  • pm_engine_clob_probe_results_total{result} — исходы допущенных проб (result = success | failure). Только success закрывает circuit.
  • pm_engine_clob_circuit_transitions_total{from,to} — переходы circuit breaker; домен обеих меток ограничен состояниями healthy, recovering, degraded.
  • pm_engine_clob_degraded_seconds_total — накопленные секунды эпизода деградации (Degraded + Recovering); доучитываются на каждом переходе FSM.
  • pm_engine_entry_blocked_exchange_health_total — входы, отклонённые гейтом здоровья (голова воронки + второй рубеж OrderRouter::allocate; слагаемые взаимоисключающи — одно на попытку).
  • pm_engine_status_page_state — gauge классифицированного состояния статус-страницы: 0 Up / 1 HasIssues / 2 UnderMaintenance / 3 Unknown (fail-soft после 5 подряд неудач опроса; 2 маркирует блок входов на плановые работы). До первого опроса — 3.
  • pm_engine_status_poll_failures_total — неудачи опроса summary.json (timeout / connect / non-2xx / неразбираемое тело). Только fail-soft: НИКОГДА не трактуются как улика деградации биржи. Неудачи ВТОРОГО запроса (/v2/components.json) сюда НЕ входят — он опрашивается лениво, считается отдельным счётчиком, и его недоступность лишь оставляет блок работ по прежнему правилу.
  • pm_engine_status_page_component_probe_failures_total — ленивые запросы /v2/components.json, не давшие улики (transport / non-2xx / неразбираемое тело) во время идущих работ. Блок при этом остаётся. Счётчик существует, чтобы отличать «входы закрыты, потому что задета наша поверхность» от «входы закрыты, потому что зонд молчал»: во втором случае в логе стоит rule=status_page_component_probe_unavailable и искать аварию CLOB не нужно.
  • pm_engine_status_page_foreign_maintenance_suppressions_total — сколько раз движок видел идущие работы Polymarket и НЕ закрывал входы, потому что /v2/components.json доказал: неисправны только чужие компоненты (Perpetuals — их API движок не использует). Единственное боевое доказательство, что отбор по компонентам сработал; растёт раз на эпизод, не на каждый опрос. Счётчик остаётся нулевым, пока Polymarket не пометит затронутый компонент неисправным — это НЕ поломка отбора, а отсутствие доказательства (тогда движок осторожно держит блок, как раньше).
  • pm_engine_stop_restart_defer_active — gauge: позиции, чья стоп-капитуляция сейчас отложена post-only-окном рестарта matching engine (эпизод: первая отложка → успешный филл капитуляции или терминал сигнала).
  • pm_engine_stop_restart_defer_seconds_total — накопленные секунды отложек (ленивый учёт на точках наблюдения отложки, перенос неполной секунды).
  • pm_engine_stop_exit_plan_total{kind} — исход плана защитной продажи по видимой книге (plan_protective_sale, немедленные политики стопа fak/ fok; см. ../strategies/directional-harvester.md §«Стоп-контур pm-engine»). Метка kind — один из семи литералов SalePlanKind::as_str: top_absorbs, remainder_sellable, named_cap, deepened, shrunk, tail_unavoidable, book_unknown. Растёт СТРОГО в ветке успешной отправки (Ok(coid) от emit_order) и только когда план вообще строился — покоящаяся политика (limit_gtc/limit_gtd), force-exit и адресная продажа плана не строят, ряд по ним не растёт; синхронный отказ отправки построенного плана тоже не считается — заявки на бирже нет. Восьмой возможный исход плана, отказ FloorUnreachable (пол цены исполнения недостижим), сюда не попадает вовсе — он не строит заявку, поэтому у него нет kind; наблюдается отдельным счётчиком pm_engine_stop_execution_floor_held_total. Запрос increase(pm_engine_stop_exit_plan_total{kind="shrunk"}[24h]) считает попытки, где заявка сознательно вышла меньше учтённого остатка.
  • pm_engine_exit_residual_stranded_total{origin} — эпизоды, когда позиция осталась НИЖЕ биржевого минимума размера заявки и поэтому непродаваема ни при каком размере (ExitSizeDecision::DustStranded). Метка origin: partial_sell — позицию уже частично продавали, и хвост остался ПОСЛЕ продажи; entry_shortfall — вход никогда не дорастал до продаваемого размера, продаж по нему не было вовсе. Единица счётчика — ЭПИЗОД, не оценка: защёлка mark_and_observe_residual_stranded держится В ПАМЯТИ ПРОЦЕССА по идентификатору сигнала (strategy.rs, runtime.mark_dust_ stranded) и инкрементирует ряд ровно один раз на сигнал, пока движок не перезапустится или остаток не изменится (поздний fill, закрытие позиции) и защёлка не снимется явно — повторные такты того же непродаваемого остатка счётчик не двигают. Рестарт процесса ОБНУЛЯЕТ защёлку в памяти, а не сам счётчик Prometheus: если позиция останется непродаваемой и после рестарта, следующая оценка посчитает её как НОВЫЙ эпизод — сравнение increase(pm_engine_exit_residual_stranded_total[24h]) через рестарт может показать больше эпизодов, чем реально новых непродаваемых позиций появилось. Запрос increase(pm_engine_exit_residual_stranded_total{origin="entry_shortfall"}[24h]) считает входы, так и не дошедшие до продаваемого размера за сутки.

Сверка холдингов кошелька (openspec holdings-bootstrap-off-critical-path; имена и семантика — describe-блоки в crates/pm-engine/src/observability/metrics.rs, рядом с pm_engine_holdings_below_floor_shares_micro):

  • pm_engine_holdings_reconcile_state{state} — одноразрядный (one-hot) gauge текущего состояния фоновой сверки; ровно один ряд равен 1 после инициализации и после каждой фиксации исхода. Четыре значения state: pending — сверщик собран, ни один прогон в процессе ещё не завершился (обычное состояние в первые секунды после старта; НЕ признак отказа сборки и не читается допуском торговли); clean — последний завершённый прогон расхождений не нашёл; discrepancies — последний завершённый прогон нашёл расхождение; not_performed — последняя попытка не выполнена (детали — в reason счётчика ниже).

    Состояние discrepancies/not_performed фиксируется в этом ряду СИНХРОННО, ДО рассылки тревоги: доставка в приёмники — отдельный асинхронный шаг ПОСЛЕ фиксации (HoldingsReconcileState::record внутри run_bounded, затем HoldingsReconciler::report отдельным .await). Между «ряд уже показывает состояние» и «тревога доставлена» есть окно ожидания; при отказе самого приёмника (сеть до Telegram, недоступный лог) ряд уже верно называет состояние независимо от исхода доставки — оператор, сверяющий ЭТОТ ряд, не ждёт тревогу как подтверждение.

  • pm_engine_holdings_reconcile_not_performed_total{reason} — счётчик невыполненных прогонов. Домен reason ЗАКРЫТ четырьмя значениями: holdings_source (перечень холдингов не получен — Data API/RPC недоступны или ответили ошибкой), registry (реестр локальных позиций не прочитан), deadline (прогон не уложился в предел HOLDINGS_RECONCILE_DEADLINE = 300 с), construction (сверщик НЕ собран при старте процесса — фоновая задача сверки вообще не заводится, а не просто отложена). Существует отдельно от счётчика найденных расхождений намеренно: невыполненная проверка не есть подтверждение их отсутствия.

  • pm_engine_holdings_reconcile_last_completed_timestamp_seconds — Unix-момент последнего УСПЕШНО завершённого прогона (clean или discrepancies); not_performed этот момент не двигает. Ноль — ни один прогон в процессе ещё не завершился успешно.

  • pm_engine_holdings_reconcile_last_pass_seconds — длительность последней ПОПЫТКИ, любого исхода, включая not_performed. Ноль — ни одна попытка ещё не завершилась.

Прогон стартует фоновой задачей уже после запуска драйвера стратегии и защитных подметал, а не прямым ожиданием на старте, — порядок и мотив см. ../target/engine.md §«Lifecycle + bootstrap». Ни одно из четырёх значений state не читается допуском входа/выхода и не меняет торговый цикл: реакция на discrepancies — только alert в приёмники (решение оператора 2026-08-19), а not_performed любой причины не есть отказ движка — граница держится храповиком scripts/arch-gate.sh R11 (TradingGate/EntryHaltState/LifecycleFsm про состояние сверки не знают).

Histograms — точки записи ЕСТЬ (дописаны 2026-08-17; до этого они были только описаны, и экспортёр отдавал метаданные рядов, которых нет):

  • pm_engine_hook_duration_seconds{hook} — длительность хука стратегии. Меток пять, а не четыре: on_market_data, on_tick, on_order_event, on_closure, on_position_change — все идут через общий StrategyDriver::invoke_hook, и замер переиспользует уже снятый там elapsed, второго таймера нет.
  • pm_engine_clob_submit_latency_seconds{result} — длительность отправки заявки на биржу, result ∈ {ok, err}.
  • pm_engine_redeem_latency_seconds{result} — длительность погашения от отправки до подтверждения, result ∈ {ok, err}.

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

Расхождение «описано ⇄ эмитится» теперь ловится автоматически.crates/pm-engine/tests/metric_registry_gate.rs сверяет по дереву исходников множество эмитируемых имён с множеством описанных и падает при расхождении в ЛЮБУЮ сторону. Практическое следствие для правок движка: новая метрика без describe_*! роняет ворота, как и описание без точек записи. Гейт даёт СТРУКТУРНУЮ согласованность кода и не отвечает за достижимость точки: зелёный гейт совместим с тем, что на живом эндпоинте видно 34 имени из 180 — именно это наблюдается при движке в дрейне.

Что НЕ настраивать ​

  • Не публиковать :31340 на хост — при развёртывании scrape только internal docker network.
  • Не добавлять auth на /metrics — internal network достаточен.
  • Не wire-ить /metrics через SSE-plane / dashboard proxy — экспозиция метрик и trading control plane разнесены (../target/engine.md раздел «Observability layer»).

Smoke /metrics ​

Боевой endpoint проверяется через SSH-проброс по рецепту «Доступ к боевым метрикам» выше. Отдельный локальный торговый процесс ради smoke не запускается.

Операторские процедуры (engine-valid) ​

Не зависят от внешнего мониторинг-стека — применимы к живому pm-engine.

Послерелизный гейт — что он проверяет и чего НЕ проверяет ​

scripts/vps-health.sh вызывается режимами engine, engine-bot и engine-dashboard автоматически, и его отказ блокирует выкат (с 2026-08-27, openspec postdeploy-health-gate-truthfulness).

Состав проверок — только из --list-checks. Перечислять его здесь значило бы завести второе место жительства факта: инвентарь живёт в scripts/lib/vps-health-inventory.jq и является тем же источником, из которого проверки исполняются. Команда печатает id, состояние (active/retired), уровень, источник величины, условие применимости, порог и — для снятых — причину снятия.

Границы, которые важно знать при разборе тревог.

  • Гейт наблюдает окно в 25 секунд и не заменяет ряды VictoriaMetrics: вопрос «какой была величина сорок минут назад» решается запросом к хранилищу.

  • После engine инстанс остаётся дренированным, поэтому торговые проверки попадают в раздел «не применимо». Гейт в этот момент подтверждает ЖИВОСТЬ ПОТОКА, а не торговое поведение: момент возобновления торговли наступает в режиме resume, и проверка после снятия drain — отдельная работа.

  • Десять записей из пятнадцати сняты по ДВУМ разным причинам: у восьми боевой производитель не заполняет источник, а две были уровня INFO и ничего не проверяли — их сведения переехали в снимок. Восстановление первых привязано к карточкам wire-latency-fields-removal (поля-заглушки контракта) и latency-metrics-export-form (замеры задержек).

  • Исход 2 — «проверка не состоялась» — означает, что данных для вывода не было. Трактовать его как «нарушений нет» нельзя: именно эта подмена и была дефектом, ради которого гейт переделан.

  • Остановка жизненного цикла (operator-only): per канонический emergency = restart-only model, взведённая остановка жизненного цикла снимается рестартом процесса движка (scripts/deploy-vps.sh engine-bot, либо docker restart pm-engine-harvester-engine), который переоценивает state на boot. Never: auto-reset, silence-bypass, direct DB edit. Примечание: отдельной CLI-команды сброса не существует — рабочий способ снятия только рестарт. Прежняя ссылка на pm-cli risk reset-kill-switch снята вместе с самим kill switch 2026-08-25.

  • Orphan orders на shutdown: перед restart выполнить pm-cli cancel-all.

  • Unknown WS event type: обновить WS event fixture в тестах pm-engine; добавить parser arm; ship fix; re-deploy. Прецедент — RTDS (feedback_silent_feature_broken).

  • No placement 15min: investigate grep -i auth|unauthorized, cross-ref halt-state; idle windows — OK.

Halt-loop / critical alert triage ​

  1. Прочитать summary + description из Telegram-сообщения AlertEngine.
  2. Известный transient? lifecycle/network/CLOB blip ⇒ resolves в один tick.
  3. Проверить pm-engine логи ± 5 min вокруг timestamp события.
  4. Decide: auto-resolves → wait+confirm; operator action → выполнить процедуру выше; spurious → зафиксировать в tech-debt-backlog-2026-08-31.md.

Halt-loop диагностика по exit-code — runbook.md#halt-loop-diagnosis.

Troubleshooting ​

pm-engine /metrics не отвечает ​

  1. docker logs pm-engine-harvester-engine | grep -i 'metrics\|31340' — ищем recorder install / bind error.
  2. PM_ENGINE_METRICS_BIND валиден и не конфликтует с другим bind.
  3. Проверять чтением ЧЕРЕЗ ТУННЕЛЬ (рецепт выше), а не изнутри контейнера.

Прежний шаг «docker exec … curl … — локально работает?» УДАЛЁН как невыполнимый: образ движка debian:13-slim не содержит HTTP-клиента. Замер 2026-08-17: docker exec pm-engine-harvester-engine sh -c "command -v curl wget" → NO_HTTP_CLIENT. Тот же запрет уже стоял выше в этом документе и в CLAUDE.md, то есть раздел противоречил сам себе.

Telegram не отправляет ​

  1. docker logs pm-engine-harvester-engine | grep -i 'telegram\|alert' — in-process AlertEngine → TG требует BOT_TG_EVENTS_ENABLED, BOT_TG_REPORTS_CHAT_ID, BOT_TG_BOT_TOKEN в instance .env.
  2. BOT_TG_EVENTS_ENABLED=false ⇒ sink = log-only, dispatch'и уходят в WARN-логи, а не в чат.

Сбор и хранение рядов — РАЗВЁРНУТО ​

Что изменилось. До 2026-08-17 ряды жили только внутри процесса движка и обнулялись рестартом; в разобранной сессии 12–13.08 было 20 стартов за 17 часов торговли, поэтому сравнение «до и после» упиралось в границу аптайма. Теперь их собирает VictoriaMetrics single-node, и обнуление стало наблюдаемым СОБЫТИЕМ, а не потерей истории.

Раскладка — deploy/server/observability/ (compose, цель опроса, панели интерфейса). Заявка — openspec/changes/metrics-series-store.

Движок о сборщике не знает: не отправляет ряды, не ждёт подтверждения, не меняет поведение при его отсутствии. Опрос идёт по ИМЕНИ контейнера движка в общей сети — пересоздание контейнера меняет адрес, но не имя.

Куда смотреть ​

ЧтоКак
интерфейс запросов + панели по движкуhttps://harvester-engine.grot.app/vmui/ (за Cloudflare Access)
читающий APIтот же хост, /api/v1/query?query=… и /prometheus/api/v1/…
для агентаread-only MCP-сервер, /mcp на том же хосте — нужен служебный токен Access
изменить панелиscripts/deploy-vps.sh observability-dashboards — источник истины deploy/server/observability/dashboards/, копировать файлы руками ЗАПРЕЩЕНО

Запись и удаление через этот хост закрыты (403): у VictoriaMetrics нет собственной аутентификации, а её API умеет не только читать.

Панели правятся только через режим выката. Он сверяет отпечаток установленного каталога с записью манифеста и при расхождении ОСТАНАВЛИВАЕТСЯ (код 4), потому что ручная копия однажды уже затёрла содержимое без следа (2026-08-18). Подробности и порядок — deploy/server/observability/README.md §«Обновить дашборд». Ни сборки, ни перезапуска не требуется: vmui перечитывает каталог на каждый запрос.

Проверить, что сбор идёт ​

Признак — не «контейнер запущен», а «образцы реально сняты»:

bash
ssh deploy@poly.grot.app \
  "docker exec pm-victoriametrics wget -qO- http://127.0.0.1:8428/api/v1/targets" \
  | head -c 400        # ожидается "health":"up" и ненулевой lastSamplesScraped

Отличать разрыв от нуля. Провалившийся опрос даёт образец up = 0 — это событие «цель не ответила». Ряды самого движка при этом образца не получают вовсе — это разрыв, то есть «данных нет», а НЕ «величина была нулевой». Прочитать разрыв как ноль — самая дорогая ошибка при разборе.

Обнуление счётчика рестартом видно как resets(<ряд>[<окно>]) и не ломает rate(): замер 2026-08-17 через границу выката — sum(resets(...[3h])) = 6 при непрерывном rate.

Чего здесь НЕТ ​

Правил оповещения — решение оператора: пока только хранение. Цена названа прямо: сегодня оповещает сам движок через in-process AlertEngine → Telegram, то есть о собственных отказах сообщает тот, кто отказал. Мёртвый или зависший процесс не сообщит ничего — этот класс отказа остаётся невидимым, пока оператор не посмотрит сам. Правило «опрос перестал отвечать» закрывает его одной строкой и заводится отдельно.

Что НЕ восстанавливать: удалённые pm-bot имена метрик и конфиги shared/observability/ — они legacy. Цель опроса написана заново под действующие pm_engine_*, состав которых удерживает гейт реестра.

Историческая целевая модель (Канал 1, alert-правила PmEngine*, cross-channel coupling с in-process AlertEngine) описана в ../target/engine.md, раздел «Observability layer».

Cross-references ​

  • runbook.md — deploy + smoke pm-engine/pm-engine-dashboard, Telegram bot setup.
  • local-dashboard-runbook.md — Local-Bot Mode, SSE lifecycle события.
  • ../target/engine.md — pm-engine observability layer (2-channel split metrics vs SSE + целевой внешний стек).
  • tech-debt-backlog-2026-08-31.md — long-term follow-ups (снимок на момент архивации).
  • project_ws_self_healing_decision, feedback_silent_feature_broken, feedback_pnl_display_units.