Тема
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/metricsendpoint (PM_ENGINE_METRICS_BIND, default127.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»):
- Operations metrics → Prometheus
/metrics→ VictoriaMetrics (опрос раз в секунду; отдельного vmalert/Alertmanager evaluator нет). - Lifecycle events → SSE → дашборд (см.
local-dashboard-runbook.md). - 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):
| Env | Default | Скоуп |
|---|---|---|
BOT_TG_EVENTS_ENABLED | false | Master 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_MS | 4000 | Окно батча 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 чтения ниже остаётся полезен для мгновенного среза и для проверки самого эндпоинта, но историю за прошедшее время смотреть надо в хранилище: счётчики эндпоинта обнуляются рестартом процесса, ряды в хранилище — нет.
| Env | Default | Скоуп |
|---|---|---|
PM_ENGINE_METRICS_BIND | 127.0.0.1:31340 | Semantic-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_totalpm_engine_positions_opened_totalpm_engine_positions_closed_totalpm_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 и несёт несколько рынков) и не исход (исходов у рынка два и более). Смешение единиц — реальный риск чтения этих рядов.
Как читать пару величин (ровно ради этого они и введены — на глаз два разных нуля неразличимы):
| до фильтра | после фильтра | что это | реакция |
|---|---|---|---|
| 0 | 0 | обнаружение СЛОМАНО: тег, источник, тип рынка либо массовая отбраковка преобразованием | разбирать; на старте это отказ старта, на ходу вселенная стареет и гейт свежести со временем закроет новые покупки |
| N > 0 | 0 | фильтр не нашёл совпадений — ШТАТНОЕ ожидание листинга нужной даты | ничего; движок работает и ждёт |
| N > 0 | M > 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:0Healthy /1Recovering /2Degraded. Пишется монитором на старте (процесс стартует 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 классифицированного состояния статус-страницы:0Up /1HasIssues /2UnderMaintenance /3Unknown (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-onlymodel, взведённая остановка жизненного цикла снимается рестартом процесса движка (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
- Прочитать summary + description из Telegram-сообщения AlertEngine.
- Известный transient? lifecycle/network/CLOB blip ⇒ resolves в один tick.
- Проверить pm-engine логи ± 5 min вокруг timestamp события.
- 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 не отвечает
docker logs pm-engine-harvester-engine | grep -i 'metrics\|31340'— ищем recorder install / bind error.PM_ENGINE_METRICS_BINDвалиден и не конфликтует с другим bind.- Проверять чтением ЧЕРЕЗ ТУННЕЛЬ (рецепт выше), а не изнутри контейнера.
Прежний шаг «
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 не отправляет
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.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.