Тема
Целевой 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_* | SignalRegistry | signals |
order_* | OrderRouter | orders |
position_*, position_attributes_updated | PositionRegistry | positions |
quote_tick | публикатор throttled active-set из OrderbookStore | (read-only из OrderbookStore) |
runtime_* (mode, lifecycle) | LifecycleFSM | runtime_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 Mode | BotSnapshot { …, 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 workflow | request/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 workflow | echo нормализованных 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/positions | Snapshot открытых позиций | { positions: [...], last_seq } |
GET /api/local/orders | Snapshot активных ордеров | { orders: [...], last_seq } |
GET /api/local/signals | Snapshot активных сигналов | { 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 → events | join по 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_emitted | event_type='SignalEmitted' | новый Signal в EMITTED |
signal_completed | SignalCompleted | Signal → COMPLETED |
signal_rejected | SignalRejected | Signal → REJECTED |
signal_superseded | SignalSuperseded | Signal → SUPERSEDED |
order_placed | OrderPlaced | Order ack от CLOB/sim |
order_filled | OrderFilled | Fill / PartialFill |
order_cancelled | OrderCancelled | Order → `CANCELLED |
order_rejected | OrderRejected | Order → REJECTED (reason ∈ `RiskLimit |
position_opened | PositionOpened | Position → OPEN |
position_attributes_updated | PositionChanged | sparse-дельта атрибутов позиции и live rank-защиты; rank_protection несёт текущие phase/rank без polling |
position_closed | PositionClosed | Position → CLOSED |
position_redeemed | PositionRedeemed | Position → 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 и legacyBOT_QUOTE_TICK_*на поведение не влияет. Не путать сposition_attributes_updated: quote_tick — рыночная цена, position_attributes_updated — атрибуты позиции.position_attributes_updated(per ADR-028) — sparse-fields payload (только изменившиеся атрибуты), typedreason(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 snapshot | SSE updates | Backend computes |
|---|---|---|---|
| Открытая позиция | /api/local/positions | position_opened, position_attributes_updated, position_closed, position_redeemed | size, avg_entry, unrealized_pnl, max_drawdown |
| Активный ордер | /api/local/orders | order_placed, order_filled, order_cancelled, order_rejected | filled_shares, filled_value, fees |
| Активный сигнал | /api/local/signals | signal_* events | status, связанные orders/positions |
| Best bid/ask по рынку | (в составе positions) | quote_tick | throttle window publisher |
| Risk badge (kill switch активен) | /api/local/seed.runtime | runtime_* | EmergencyGuard state |
| Risk metrics (Sharpe/Sortino/max drawdown/equity curve) | /api/local/metrics | (опционально periodic refresh) | aggregations из positions + events |
| Reject distribution | /api/local/metrics/rejects | order_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_cancelledSSE 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-emergencyendpoint 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_tickevent и 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_rejectedpayload): 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.