Skip to content

Целевой wire-контракт SSE/REST ​

Уровень: target. Источник правды для контракта между pm-engine backend и фронтом (Vue SPA frontend/pm-engine-dashboard-web/). Противоречащие документы (отдельные описания каналов в ADR-005/012/013/014/027/028 как самостоятельные источники, FSD дашборда) подлежат удалению/redirect во второй итерации.

Контекст и scope ​

Что покрывает. Принцип «один UI-факт = один источник = один канал», REST snapshot + SSE stream consistency через (snapshot, last_seq), список каналов и event-имён, payload schemas (на уровне обязательств, без побайтового DDL — он в pm-bot-events), границы Local-Bot Mode, риск/PnL метрики.

Что не покрывает.

  • Конкретные struct-определения payload'ов — живут в крейте pm-bot-events (Rust → TS типы), wire-контракт сохраняется 1:1.
  • Внутреннюю архитектуру backend — см. engine.md.
  • Схему таблиц events — см. storage.md.

Якорь ​

feedback_dashboard_strategy_data_only, feedback_single_data_pipeline, project_canonical_vision A10. Источник: openspec spec bootstrap-snapshot-stream-consistency + design D6.

Главные инварианты wire-контракта ​

Инвариант 1 — One UI Fact = One Source = One Channel ​

Каждая UI-сущность (открытая позиция, активный ордер, unrealized PnL, индикатор стратегии, риск-бейдж, распределение reject'ов, equity curve, дровдаун) формируется из ровно одного backend-источника через ровно один канал:

  • Bootstrap: один REST endpoint.
  • Live updates: одно SSE event-name.

Фронт никогда не объединяет данные одной сущности из нескольких источников. Фронт никогда не вычисляет производные значения локально, если их публикует backend.

Пример: position.unrealized_pnl_micro_usd приходит из GET /api/local/positions (bootstrap) ИЛИ из position_attributes_updated (live). Фронт не пересчитывает unrealized_pnl = (best_bid - avg_entry) × size — он показывает то, что прислал backend.

Источник теперь один (было здесь известным отклонением): probability_rank/ probability_rank_no_probability больше не читаются из двух разных мест. И bootstrap (GET /api/local/seed), и live-обновление (position_attributes_updated) читают ОДИН и тот же канонический CurrentMarketContextStore: bootstrap — напрямую при сборке снимка, live — через фоновый участник PositionRankProjection (crates/pm-engine/src/engine/event_cohort_projection.rs), который просыпается на уведомление store.subscribe() (плюс старт и RecvError::Lagged), а не на каждый market-тик. Strategy-local тик-driven наблюдение, которое раньше писало live-канал отдельно от bootstrap, удалено целиком.

Частота обновления живого значения изменилась, и это осознанно. Раньше тик-driven писатель пересчитывал ранг на каждом market-тике держимого токена по снимку, который сам обновлялся только проходами discovery. Теперь живое значение обновляется ровно тогда, когда стор публикует новую ревизию события держимой позиции: на каждом полном проходе discovery_coordinator (период — directional.discovery.rediscovery_interval_secs; в боевой строке weather-harvester он равен 60 с — чтение config.db 2026-09-03) и на каждом targeted-обходе удерживаемых событий при остановке входов. Значит, ранг в таблице позиций отстаёт от Gamma не больше, чем на период полного прохода, — а не «до следующего тика книги», как раньше. Граница, которую надо знать: при rediscovery_interval_secs = None (режим без периодического полного прохода) стор обновляется только targeted-обходом при остановке входов, и живое значение между такими обходами не меняется — это свойство режима, а не дефект проекции.

Инвариант 2 — Snapshot + Stream Consistency через last_seq ​

Каждый REST snapshot endpoint, бэкующий SSE-канал, возвращает { data, last_seq }, где last_seq — значение events.seq на момент материализации snapshot'а (одна транзакция / consistent read).

Каждый SSE-envelope несёт поле seq. Фронт хранит last_seen_seq per channel, инициализирует его из REST last_seq при подключении, и отбрасывает события с seq <= last_seen_seq. События с seq > last_seen_seq применяются и обновляют last_seen_seq.

Это закрывает окно гонки между snapshot-read и subscribe-start: дубликаты фильтруются клиентом, конечное состояние идентично «подключился сразу и реплеил с нуля».

Инвариант 3 — Wire-контракт сохраняется 1:1 при storage redesign ​

SSE event-names, REST endpoint paths, payload schemas остаются как сейчас определены в pm-bot-events. pm-engine имеет модуль wire/, который мостит внутренние events движка в существующий enum BotEvent. Фронт работает без правок при cutover.

Инвариант 4 — Single Writer Backs Each SSE Channel ​

Каждый SSE-канал бэкается ровно одним writer'ом домейн-таблицы (см. storage.md):

SSE-каналWriterДоменная таблица
signal_*SignalRegistrysignals
order_*OrderRouterorders
position_*, position_attributes_updatedPositionRegistrypositions
quote_tickпубликатор throttled active-set из OrderbookStore(read-only из OrderbookStore)
runtime_* (mode, lifecycle)LifecycleFSMruntime_state + events

Инвариант 5 — Entry funnel is closed and backend-owned ​

entry_funnel_tick переносит 1:1 все терминальные исходы Direct-entry из одного EntryGateCounters::drain(). Для каждого markets_evaluated движок публикует ровно один терминальный исход: конкретный blocking gate, entry_emitted либо blocked_emit_error. entry_attempts и regime observations — сквозные checkpoints и в эту сумму не входят.

Стоповый контур на этом тике НЕ живёт. Девять счётчиков (stop_loss_evals, stop_loss_no_bids_hold, stop_confirm_blocked_complement, stop_confirm_blocked_tape, stop_confirm_witness_window_truncated, stop_loss_fire_attempts, stop_loss_fills, stop_loss_kills, stop_loss_capitulation_partial) сняты с варианта: воронка описывает ДОПУСК ко входу, а не выходной путь. Сбор в движке не тронут — все ЧЕТЫРНАДЦАТЬ полей стопового контура продолжают инкрементироваться и печатаются часовой INFO-сводкой (strategies/directional/strategy.rs), которая после снятия остаётся их единственным потребителем. Четырнадцатое — stop_confirm_incomplete_candidates (lossless-stop-confirm-trade-window 8.3, единица — КАНДИДАТ): оно заведено ПОСЛЕ снятия и на провод воронки НЕ добавлялось намеренно, иначе снятая поверхность вернулась бы, а версия варианта поднялась бы в третий раз.

SPA отображает runtime-порядок гейтов, считает entry_emitted единственным успешным исходом и показывает ненулевой parity gap как ошибку контракта. Она не имеет права называть неполный остаток «прошло все фильтры».

Версионирование — свойство ВАРИАНТА, а не крейта. EVENT_SCHEMA_VERSION остаётся 1 и штампует каждое событие, которое ни разу не расходилось; entry_funnel_tick несёт v = 2 явным литералом, потому что удаление поля не аддитивно. Аддитивные поля версию не поднимают и приезжают с serde(default), поэтому груз старого движка по-прежнему разбирается новой панелью: лишние ключи разбор игнорирует, отсутствующие читаются как честные нули либо как «недоступно». Второй вариант, разошедшийся тем же образом, — metrics_tick: он несёт v = 2 явным литералом по той же причине (снятие поля неаддитивно), см. группу RTDS ниже. Следующая неаддитивная правка ЛЮБОГО из этих двух вариантов поднимает его версию до 3 — общий счётчик версии не делится между вариантами.

Координатор-зависимая часть тика. Пять полей покрытия (coverage_unique_outcomes, coverage_subscribed_outcomes, coverage_mean_revisit_ms, coverage_horizon_ms, coverage_saturated) и объект discovery_cycle (from_ms, to_ms, markets_evaluated, boundary_completed) существуют РОВНО тогда, когда работает координатор discovery. Обязательства:

  • Группа покрытия неделима: числитель без знаменателя не публикуется. Нет координатора либо первый знаменатель ещё не опубликован ⇒ отсутствуют все пять полей. Панель обязана показать «недоступно»; подстановка нуля в знаменатель запрещена (RULE-NEW-01).
  • coverage_mean_revisit_ms может отсутствовать при ПРИСУТСТВУЮЩЕЙ группе — это значит «ни один исход не посещён дважды», а не «покрытие недоступно».
  • coverage_horizon_ms — фактический горизонт движка. Подпись метрики берётся из него, а не из константы окна панели.
  • coverage_subscribed_outcomes считается в ИСХОДАХ (token_id), включает исходы, подписанные принудительно из-за открытой позиции, и потому не равен числу пригодных ко входу.
  • Эта discovery-группа и market_orderbook_coverage ниже отвечают на разные вопросы: первая описывает повторный обход исходов координатором, вторая — прохождение действующего локального набора подписки через transport и hook. Равенство их чисел контрактом не требуется.
  • discovery_cycle приезжает ТОЛЬКО на тике, наблюдающем рубеж прохода; boundary_completed = false означает склейку нескольких циклов после досрочного выхода прохода. Поле не суммируется по окну панели.

Что markets_evaluated означает (и чего НЕ означает). Счётчик инкрементируется в DirectionalStrategy::on_market_data ДО ветвления по entry_flow, поэтому считает ВСЕ доставленные снимки исходов (token_id), включая повторные снимки одного condition_id и исходы с уже открытой позицией — это темп ОЦЕНОК, а не число уникальных рынков. Один активный рынок способен дать сотни оценок за тик, а рынок без обновлений книги — ни одной, поэтому охватом сканирования величина не является ни на проводе (поле strategy_scanned тика метрик), ни на карточке «Strategy scanned». Ограничителя темпа в диспетчере нет, поэтому верхней границы у величины тоже нет — она задаётся производством снимков и стоимостью хука. Тракт доставки и процедура замера — engine.md, раздел «Тракт доставки рыночных данных стратегии».

Группа охвата стаканов ​

MetricsTick и BotSnapshot.metrics несут одно опциональное поле market_orderbook_coverage: MarketOrderbookCoverageSummary. Отсутствие всей группы означает старого производителя и отображается как UNKNOWN; браузер не восстанавливает её из price_events, book_events, strategy_scanned или воронки входа.

Форма группы:

text
observed_at_ms
status: OK | PARTIAL | UNKNOWN | NONE
connection_epoch: u64 | null
dispatch_mode: driver | sharded
configured_shards
activation_rejections_open
totals: { requested, book_seen?, strategy_evaluated? }
shards: [{ shard_index, requested, book_seen?, strategy_evaluated? }]

MetricsTick и MetricsSnapshot также несут опциональное effective_budget_usd. budget_limit_usd остаётся базовым лимитом; effective считается движком в signed micro-USD и является величиной BUY-гейта. Старый производитель без этого поля остаётся совместимым, а клиент использует budget_limit_usd как legacy fallback.

При NONE все три значения равны измеренному нулю, а activation_rejections_open=0. При UNKNOWN поля book_seen/strategy_evaluated отсутствуют: текущей эпохи нет либо хотя бы один принятый discovery-контекст не прошёл атомарную activation, поэтому полный знаменатель не измерен. При доступной эпохе каждое поле присутствует и выполняется strategy_evaluated ≤ book_seen ≤ requested; суммы по всем настроенным шардам равны общим значениям.

Движок вычисляет статус и передаёт его готовым. OK, book_seen и strategy_evaluated означают хотя бы одно успешное прохождение в текущей connection epoch, а не текущую свежесть книги. Фронтенд применяет только проверку свежести по локальному монотонному времени получения последнего /seed или MetricsTick: после истечения окна показывает UNKNOWN и очищает числа, режим и число шардов. observed_at_ms остаётся корреляционной отметкой часов движка и не сравнивается с часами браузера. Точные токены в эту группу и в SSE не входят.

Группа RTDS ​

BotEvent::MetricsTick несёт v = 2 явным литералом (pm_engine::wire::bot_event_map::to_bot_event_metrics_tick, НЕ EVENT_SCHEMA_VERSION) — карточка rtds-transport-remediation сняла с этого варианта два поля неаддитивно:

  • Счётчик размера клиентского пула фильтров-слагов (FilterSet::len()) — снято, без замены. Сам механизм убран из pm_sdk::rtds целиком, а не просто перестал публиковаться, так что у поля нет ни данных, ни смысла ему заменить. Ни один потребитель на проводе его не читал (rg по SPA — пусто); ретировавшееся имя поля — история миграции, а не действующая часть контракта, и здесь намеренно не повторяется (см. ../integrations/rtds.md для точного старого имени).
  • Счётчик кадров, отброшенных клиентским фильтром слагов (DropReason::Filtered) — снято, без замены. Тот же фильтр убран, отбрасывать по нему больше нечего.
  • rtds_tracked_tokens: Option<u64> — добавлено. Gauge, размер множества токенов, чьи кадры движок допускает из полной ленты площадки (TrackedTokens::tracked_token_count через RtdsActorBuffer) — величина ДОПУСКА на шве движка, а не остаток клиентского фильтра, который заменяет по имени: старое поле мерило исходящую подписку, новое — входящий допуск.

Остальные девять полей rtds_* варианта (rtds_enabled, rtds_connection_state, rtds_events_received_trade, rtds_events_received_orders_matched, rtds_events_dropped_dedupe, rtds_events_dropped_parse, rtds_events_dropped_stale, rtds_events_dropped_channel_full, rtds_reconnect_total, rtds_ping_failures_total) заполняются из RtdsTelemetry группой целиком — все поля группы None ровно тогда, когда транспорт не поднят, и присутствуют одновременно, когда поднят (не поодиночке опциональны). Одно имя сохранено, но сменило источник без версии — rtds_events_dropped_dedupe раньше считал потери LRU-дедупа по содержимому; тот механизм снят (см. ../integrations/rtds.md), и под тем же именем теперь читается RtdsMetrics::frame_exact_repeat_total — счётчик точного повтора кадра (протокольная аномалия, не потеря покрытия). Имя оставлено, чтобы не расходовать вторую неаддитивную версию варианта на одно переименование.

Следующая неаддитивная правка этого варианта (снятие или переосмысление ещё одного поля) поднимает v до 3.

Каналы ​

REST snapshots ​

EndpointНазначениеВозвращает
GET /api/local/seedПолный bootstrap при подключении к Local-Bot ModeBotSnapshot { …, position_iteration?, last_seq_per_substream, holdings_reconcile? }; iteration отсутствует в unlimited runtime; holdings_reconcile — необязательная метка текущего one-hot состояния фоновой сверки холдингов кошелька, одно из pending/clean/discrepancies/not_performed (та же метка, что публикует pm_engine_holdings_reconcile_state, alerts-and-monitoring.md §Prometheus)
GET /api/local/market-orderbook-coverageТекущая точная диагностика трёх этапов доставки стаканов; bearer и identity обязательны, dashboard backend проксирует без кэша{ summary: MarketOrderbookCoverageSummary, requested_without_book?: [...], book_without_strategy_evaluation?: [...] }; при измеримой epoch без незакрытых activation rejection списки полные, отсортированы по condition_id, затем token_id, и не пересекаются; при UNKNOWN оба отсутствуют
POST /api/local/restartПодтверждённый restart workflowrequest/status несут nullable положительный position_limit; 0, fraction, negative, overflow и неверная shape → 400 до side effects; идущий /api/local/reset в этом же процессе → 409 {"error":"reset_in_progress",...} БЕЗ поля phase — рабочий процесс перезапуска не создаётся
GET /api/local/restartТекущий progress restart workflowecho нормализованных options, включая принудительный cancel_orders=true при положительном limit
POST /api/local/reset (+ алиас /api/local/sessions/reset)Подтверждённый cancel-all + wipe + self-restartидущий POST /api/local/restart в любой активной фазе этого же процесса → 409 {"error":"restart_in_progress",...} ДО отмены заявок и wipe_runtime_data — обе формы 409 взаимного исключения различимы по error (reset_in_progress не несёт phase, restart_in_progress отличается значением error от собственной защёлки сброса)
GET /api/local/positionsSnapshot открытых позиций{ positions: [...], last_seq }
GET /api/local/ordersSnapshot активных ордеров{ orders: [...], last_seq }
GET /api/local/signalsSnapshot активных сигналов{ signals: [...], last_seq }
GET /api/local/balance_historyЧасовая серия баланса pUSD за 7 суток. Беспараметрический: окно и гранулярность — часть контракта, а не настройка{ points: [{ ts_ms, balance_usdc }] }, РОВНО 168 слотов ASC; balance_usdc: null у часа без успешной выборки (разрыв, а не ноль)
GET /api/local/budget_historyИстория применённой пары базового и effective бюджета активной стратегии за 7 суток{ points: [{ ts_ms, base_budget_usd, effective_budget_usd }] }, РОВНО 168 UTC-слотов ASC; оба значения null у отсутствующего атомарного снимка; запрос не читает Polygon RPC
GET /api/local/realized_pnl_historyНакопительный реализованный PnL за то же 168-слотовое окно{ points: [{ ts_ms, realized_usd }] }, РОВНО 168 слотов ASC; realized_usd НЕ nullable — час без закрытий несёт значение предыдущего; накопление с начала окна, не all-time
GET /trace/:signal_uidПолная трассировка signal → orders → positions → eventsjoin по signal_id (см. storage.md)

Обе часовые серии — самостоятельные виджеты без общей шкалы; исчерпывающий перечень зарегистрированных движком маршрутов и операторские оговорки — operations/local-dashboard-runbook.md §4.

Wallet/strategy конфигурация — отдельные endpoint'ы дашборда, не относятся к bot-event каналу (читают wallets.db и config.db напрямую через pm-cli API).

SSE event-names ​

Сохраняются 1:1 из pm-bot-events (мост wire/ мапит events.event_type → BotEvent variant → SSE event-name):

SSE event-nameТриггер в eventsСемантика
signal_emittedevent_type='SignalEmitted'новый Signal в EMITTED
signal_completedSignalCompletedSignal → COMPLETED
signal_rejectedSignalRejectedSignal → REJECTED
signal_supersededSignalSupersededSignal → SUPERSEDED
order_placedOrderPlacedOrder ack от CLOB/sim
order_filledOrderFilledFill / PartialFill
order_cancelledOrderCancelledOrder → `CANCELLED
order_rejectedOrderRejectedOrder → REJECTED (reason ∈ `RiskLimit
position_openedPositionOpenedPosition → OPEN
position_attributes_updatedPositionChangedsparse-дельта атрибутов позиции и live rank-защиты; rank_protection несёт текущие phase/rank без polling
position_closedPositionClosedPosition → CLOSED
position_redeemedPositionRedeemedPosition → REDEEMED
quote_tick(не из events, прямой publisher)throttled best_bid/best_ask для активного набора рынков
metrics_tick(не из events, прямой heartbeat-publisher, seq=None, никогда не персистируется)периодический снимок метрик; несёт необязательное holdings_reconcile? — та же one-hot метка сверки холдингов кошелька, что и BotSnapshot.holdings_reconcile у /api/local/seed (pending/clean/discrepancies/not_performed)
runtime_mode_changed(на старте)публикация active_mode после bootstrap
position_iteration_changed(прямой runtime publisher)единый snapshot {iteration_id,state,position_limit,opened_positions,reserved_slots,remaining_slots,started_at,finish_reason?} после reserve/release/open/finish; hard reload получает ту же shape из /seed

Каждый envelope: { seq: i64, ts_ms: i64, event_type: string, payload: <variant-specific> }. Поле seq обязательно (для инварианта 2).

У position_closed поле size означает непроданный остаток, записанный той же транзакцией в positions.size_shares; полностью исполненный SELL передаёт "0". Поэтому живой кадр и closed_positions_recent из /seed не меняют смысл позиции после перезагрузки. Полная стоимость входа передаётся отдельным entry_cost_usd; использовать size как исходный объём или как базу процента результата запрещено.

И живой кадр position_closed, и seed-строка закрытой позиции несут redeem_status (общий маппер to_bot_event_position_closed, wire/bot_event_map.rs). Положительный size сам по себе не доказывает погашение: REDEEM в истории позиции допустим только при CONFIRMED либо NO_PAYOUT; отсутствие поля от старого производителя означает «неизвестно» и не разрешает синтетическую строку.

Оба текущих производителя живого кадра (apply_terminal_sell, mark_closed, position_registry.rs) закрывают позицию полностью исполненной продажей, которая колонку positions.redeem_status не трогает, и передают NOT_REQUIRED явно — состояние строки, у которой выкуп никогда не запускался, а не перечитанное из БД значение. Если та же строка в этот момент уже стоит в очереди резолвной сверки (OpenPositionReconcile берёт OPEN/SELLING-строки), продажа закрывает её раньше, и живой кадр может на мгновение разойтись со значением колонки до следующего /seed — известная граница, не устранённая этой правкой. Погашение по резолву (mark_redeemed) position_closed не публикует вовсе — см. PositionRedeemConfirmed ниже.

position_redeem_no_payout (BotEvent::PositionRedeemNoPayout, решение оператора 2026-09-14) — терминальный переход positions.redeem_status: NOT_REQUIRED → NO_PAYOUT БЕЗ попытки выкупа: держимый остаток — остаток проигравшего исхода с нулевой выплатой, ставить его в очередь означало бы только серию заведомо безрезультатных попыток. Эмитент — OpenPositionReconcile::book_and_mark (engine/resolution_sweep.rs), в ТОЙ ЖЕ транзакции, что и CAS. Поля: position_uid, condition_id, token_id, strategy, residual_shares (Decimal-as-string, доли на момент разметки). positions.status этим событием НЕ меняется: сегодня предикат redeem_yields_nothing допускает эту ветку только для уже CLOSED строк с непродаваемым остатком (терминальная продажа уже закрыла позицию раньше) — строго-OPEN проигравшая позиция в этот путь не попадает и продолжает получать PENDING редемп, как и раньше. До этого решения NO_PAYOUT писался только в колонку и на провод не выходил вовсе — SPA узнавала о нём только из следующего /seed; теперь колонка и живой кадр синхронны, как у CONFIRMED/FAILED.

Гранулярные каналы ​

  • quote_tick (per ADR-027) — выделенный display-only SSE event для best_bid/best_ask активных позиций. pm-engine применяет независимое для каждого token_id фиксированное окно 500 мс: первый изменившийся BBO после простоя выходит сразу, burst хранит один latest-wins trailing кадр, неизменившийся (best_bid,best_ask) не публикуется. REST-карта /api/local/position_quotes при этом обновляется на каждом наблюдении. Это не strategy knob и legacy BOT_QUOTE_TICK_* на поведение не влияет. Не путать с position_attributes_updated: quote_tick — рыночная цена, position_attributes_updated — атрибуты позиции.
  • position_attributes_updated (per ADR-028) — sparse-fields payload (только изменившиеся атрибуты), typed reason (fill | quote | drawdown_update | manual). Архитектурный гейт: эти поля изменяются только через PositionRegistry; никаких прямых мутаций из других мест.

Probability-rank protection uses rank-native wire names. Position seed rows carry optional rank_exit_phase, rank_exit_phase_since_ms, rank_exit_threshold_rank, rank_exit_current_rank and rank_exit_reference_entry_price, rank_exit_current_no_probability and rank_exit_protective_price. BotSnapshot.min_size_shares carries the process-constant directional exchange minimum as a decimal string in whole-share units, so the SPA can identify an undersized row without a second configuration source. Live attribute reasons are rank_gradual_exit, rank_maker_reprice, rank_target_restore and rank_protection; the latter carries additive rank_exit_phase, rank_exit_threshold_rank, rank_exit_current_rank, reference entry/current No probability, and the protective-price value/clear signal after each distinct accepted canonical snapshot. rank_exit_current_rank_cleared=true explicitly removes a previously shown protection rank when canonical context is unavailable; omission means «не изменять», как и у остальных sparse-полей. Фаза уже начатой защиты при этом не очищается. New gradual rank orders close as rank_maker_exit. rank_break_even_exit, rank_taker_exit and legacy dfav_* close reasons remain consumer-readable only for historical rows and are never emitted by new rank orders.

UI-сущности и их источник ​

UI-сущностьREST snapshotSSE updatesBackend computes
Открытая позиция/api/local/positionsposition_opened, position_attributes_updated, position_closed, position_redeemedsize, avg_entry, unrealized_pnl, max_drawdown
Активный ордер/api/local/ordersorder_placed, order_filled, order_cancelled, order_rejectedfilled_shares, filled_value, fees
Активный сигнал/api/local/signalssignal_* eventsstatus, связанные orders/positions
Best bid/ask по рынку(в составе positions)quote_tickthrottle window publisher
Risk badge (kill switch активен)/api/local/seed.runtimeruntime_*EmergencyGuard state
Risk metrics (Sharpe/Sortino/max drawdown/equity curve)/api/local/metrics(опционально periodic refresh)aggregations из positions + events
Reject distribution/api/local/metrics/rejectsorder_rejected eventsагрегация по reject_reason
Неразрешённые отправки (агрегат)/api/local/seed.metricsметрический тикcount, inconclusive, held_micro_usd, oldest_age_secs — все четыре из ОДНОГО OrderRouter, того же, что даёт бюджетную тройку

Поштучный перечень неразрешённых отправок — отдельный маршрут ДВИЖКА (GET /api/local/unresolved_submits), дашборд его не проксирует: SPA показывает только агрегат. Возраст самой старой на проводе ОТСУТСТВУЕТ, когда неразрешённых нет (ноль означал бы «есть запись возрастом меньше секунды»); одноимённая метрика Prometheus при этом читается нулём — это разные поверхности с разными правилами, и склеивать их нельзя. Смысл состояния и регламент — operations/runbook.md §«Неразрешённая отправка».

Cancel UI invariants (per ADR-013) ​

  • Soft-delete only: cancel в UI отправляет request, backend помечает orders.deleted_at; терминальная строка не мутирует. Сам факт cancel — отдельная events.OrderCancelled запись.
  • Paranoid bulk gate: массовый cancel (>N в одном вызове) требует подтверждения; N — config knob.
  • Frontend никогда не «оптимистично» убирает order из списка — ждёт order_cancelled SSE event.

Risk metrics (per ADR-014) ​

Sharpe, Sortino, max drawdown, equity curve вычисляются backend'ом (агрегации над positions + events) и публикуются через REST /api/local/metrics. Фронт не считает их локально. Формулы (Sharpe = excess return / std, daily/weekly periodization) — фиксированы в backend implementation; их изменение — backend release, не frontend release.

Local-Bot Mode границы (per ADR-005) ​

  • Local-Bot SSE+REST API биндится на 127.0.0.1:31339 (loopback only) при BOT_LOCAL_API_ENABLED=true.
  • Дашборд (:31337) проксирует к Local-Bot API.
  • Emergency-stop по якорю A6: возобновление исключительно перезапуском pm-engine процесса. Никакого /api/local/clear-emergency endpoint by design.
  • Reset UI (rotate session): atomic cancel-all + rotate capture segment + self-restart pm-engine процесса для подхвата свежей config.db.

Decision rationale (растворённые ADR) ​

  • Per ADR-005 (IMPLEMENTED) — Local-Bot Mode SSE+REST: канал биндится на loopback, опт-ин через env, состояние читается через SSE+REST snapshot. Emergency-stop semantics (restart-only) — закреплена в Decision-секции ADR-005 и в якоре A6.
  • Per ADR-012 (IMPLEMENTED) — axum + Vue 3 SPA: фронт реализован как SPA, backend на axum. Wire-контракт между ними централизован в pm-bot-events (Rust → TS типы).
  • Per ADR-013 (IMPLEMENTED) — Cancel UI invariants: soft-delete + paranoid bulk gate. Эти инварианты — wire-контракт, а не только UI: backend обязан поддерживать soft-delete семантику в таблице orders.
  • Per ADR-014 (IMPLEMENTED) — Risk metrics: Sharpe/Sortino/max drawdown/equity curve вычисляются backend'ом (одно место правды), фронт только отображает. Это применение инварианта 1.
  • Per ADR-027 (ACCEPTED) — Local-Bot quote channel: выделенный quote_tick event и throttled active-set publisher. Deprecation: position_price_updated (раньше совмещал quote + position) — удалён, его роль разделена на quote_tick + position_attributes_updated.
  • Per ADR-028 (ACCEPTED) — position attribute channel: sparse-fields payload + typed reason + архитектурный гейт против прямых мутаций position-полей вне PositionRegistry.
  • Per spec bootstrap-snapshot-stream-consistency ((snapshot, last_seq) пара + клиентский last_seen_seq фильтр): закрывает race-окно snapshot↔subscribe без необходимости в server-side replay-буфере.
  • Per design D6 (unify-orders-positions-signals-events/design.md): wire-контракт сохраняется 1:1 при полном rewrite persistence — фронт не трогаем при cutover, риск UI-регрессии = 0.

Cross-references ​

  • Backend архитектура, EventBus, single-writer гарантии: engine.md.
  • Где живут events.event_type строки: storage.md.
  • Risk-rejection reasons (RiskLimit(SharesPerMarket|...) в order_rejected payload): risk-and-emergency.md.
  • Openspec spec: openspec/changes/unify-orders-positions-signals-events/specs/bootstrap-snapshot-stream-consistency/spec.md.
  • Wire-types crate: crates/pm-bot-events/.
  • Memory: feedback_dashboard_strategy_data_only, feedback_single_data_pipeline, feedback_pnl_display_units.