Skip to content

Целевая архитектура pm-engine ​

Уровень: target. Источник правды для архитектуры торгового движка (новый крейт crates/pm-engine). Все противоречащие документы (старые ADR-007/010/022/029, отдельные дизайны слоёв) подлежат удалению или ссылке на этот файл во второй итерации SSOT-конвергенции.

Контекст и scope ​

Что покрывает. Целевую внутреннюю структуру pm-engine: слои, порты, адаптеры, EventBus + outbox, отношения между Engine и Strategy SDK, распределение ответственностей по записи доменных таблиц.

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

Якорь ​

Документ обязан соответствовать project_canonical_vision A1–A10, project_canonical_risk_limits, feedback_no_hidden_logic, feedback_single_data_pipeline, feedback_dashboard_strategy_data_only. Источник change'а: openspec unify-orders-positions-signals-events.

Размещение кода ​

Новый workspace member:

crates/pm-engine/
  src/
    main.rs                — binary entrypoint
    lifecycle/             — FSM, runtime_mode lock, BootstrapReconciler
    engine/                — OrderRouter, TradingGate + EntryReadiness + AdmissionControl,
                             SignalRegistry, PositionRegistry, EventBus, EmergencyGuard
    strategies/
      sdk/                 — Strategy trait, StrategyContext, helpers, transition! macro
      directional/         — Harvester (бизнес-логика переписана с нуля по PRD/FSD)
    ports/                 — OrderClient, OrderbookFeed, MarketDataPort, PersistencePort, ClockPort
    adapters/
      clob/
        live/              — LiveOrderClient (REST/WS Polymarket через ClobClientPool из pm-sdk)
      orderbook/           — реализация OrderbookFeed (унифицированный merge WS+REST)
      storage/             — реализация PersistencePort над SQLite
    wire/                  — мост в pm-bot-events (BotEvent ↔ events.event_type)

Крейт pm-bot удалён 2026-07-22 после завершения перехода на pm-engine. Никакой in-tree миграции данных — pm-engine стартует с чистой engine-state.db.

Архитектурные слои ​

┌──────────────────────────────────────────────────────────────────┐
│ Strategy Layer (SDK)                                             │
│   directional/Harvester (single active per ADR-024)              │
│   trait Strategy + StrategyContext (helpers/hooks)               │
└─────────────────────┬────────────────────────────────────────────┘
                      │ emit Signal / OrderIntent (декларативно)
┌─────────────────────▼────────────────────────────────────────────┐
│ Engine (domain core, hexagonal, не знает про SQLite/Polymarket)  │
│   TradingGate         ── ЕДИНАЯ точка допуска: 4 причины         │
│                          остановки, область — значение причины   │
│   EntryReadiness      ── прогрев входа: ВТОРОЙ вопрос, только    │
│                          для намерений, открывающих позицию      │
│   EmergencyGuard      ── владелец причин LifecycleHalt /         │
│                          PnlKillSwitch (не гейт)                 │
│   OrderRouter         ── RISK-LIMITS layer (ровно 3) + writer    │
│                          таблицы orders                          │
│   SignalRegistry      ── writer таблицы signals                  │
│   PositionRegistry    ── writer таблицы positions                │
│   LifecycleFSM · BootstrapReconciler · EventBus + Outbox         │
└──┬──────────────┬──────────────────┬──────────────────┬──────────┘
   │ ports        │                  │                  │
   ▼              ▼                  ▼                  ▼
┌────────┐ ┌─────────────┐ ┌─────────────────┐ ┌──────────────────┐
│Order   │ │Orderbook    │ │MarketData /     │ │Persistence       │
│Client  │ │Feed         │ │ClosureWatcher   │ │Port              │
└──┬─────┘ └─────┬───────┘ └────────┬────────┘ └────────┬─────────┘
   │             │                  │                   │
┌──▼────┐  ┌─────▼────┐         ┌───▼────┐         ┌────▼──────────┐
│Live   │  │WS+REST   │         │Gamma + │         │SQLite         │
│CLOB   │  │merge     │         │Closure │         │engine-        │
│adapter│  │store     │         │poll+WS │         │state.db       │
├───────┤  └──────────┘         └────────┘         └───────────────┘
│Sim    │
│CLOB   │
│adapter│
└───────┘

Hexagonal pattern. Engine + SDK — pure domain; зависят только от port-трейтов. Адаптеры реализуют порты и инкапсулируют все внешние зависимости (Polymarket SDK, sqlite, WS-клиенты). Замена адаптера (live↔sim, sqlite↔inmemory для тестов) не требует правок в Engine.

Threading model. adapters/clob/live/ использует готовый ClobClientPool из pm-sdk — единый dedicated REST worker pool + глобальная rate-limit семафора согласно Polymarket-лимитам. Hot path (scanner, strategy hooks, OrderRouter, EventBus publish) — async-only, никаких inline blocking REST вызовов. Любой outbound CLOB-запрос идёт через bounded MPSC channel в pool. См. unify-orders-positions-signals-events/design.md D11.

Ограниченные тестовые итерации. Подтверждённый System Restart может передать положительный position_limit. Restart workflow сохраняет pending handoff, bootstrap после reconciliation активирует общий PositionIterationLimiter, а OrderRouter атомарно резервирует Entry-слоты в своей существующей allocation critical section. PositionRegistry превращает слот в committed вместе с PositionOpened. N-я уникальная позиция переводит движок в shared persistent Drain; DCA/SELL/close/redeem счётчик не меняют. Storage и wire контракты: storage.md и wire-contract.md; safety-семантика: risk-and-emergency.md.

Background tasks orchestration. Gamma poll, ClosureWatcher, redeem backstop, metrics-pump — отдельные tokio tasks, запускаемые LifecycleFSM в фазе Ready и останавливаемые в Draining. Каждый использует тот же ClobClientPool (rate-budget sharing). Никакая background-задача не создаёт собственный HTTP-клиент Polymarket вне pool.

Strategy registration. Strategy trait + static registry в crates/pm-engine/src/strategies/registry.rs. Добавление новой стратегии = новый файл + одна строка регистрации; core не правится. Альтернатива enum StrategyMode с разветвлением внутри core отвергнута — нарушает feedback_strategy_isolation и блокирует A2. См. design D12.

Контракты портов ​

OrderClient — центральный исходящий порт ​

  • submit(AuthorizedOrderIntent) -> Result<OrderAck, OrderError> — синхронный ack; envelope содержит immutable SubmitAuthorization, созданный router'ом из прошедшей allocation.
  • cancel(OrderUid) -> Result<CancelAck, OrderError> — синхронный ack.
  • events() -> Stream<OrderEvent> — асинхронный поток Placed | PartialFill | Fill | Cancelled | Rejected | Expired.

Инварианты live-контракта:

  • Синхронная ошибка submit НЕ дублируется в events(). Стратегия получает ровно один сигнал на одну попытку.
  • События с одинаковым client_order_id строго упорядочены.
  • События разных ордеров могут переплетаться.

Production-реализация одна: LiveOrderClient — REST + WS Polymarket CLOB. Детерминированные двойники существуют только в тестовых модулях и не сопоставляют книги, не создают синтетические филлы и не выбираются runtime-флагом.

Три исхода отправки ​

У submit три исхода, не два. Разграничитель между вторым и третьим — один вопрос: пришёл ли ответ, который говорит, что ордера НЕТ?

ИсходЧто известноЧто делает движок
Успехбиржа вернула ключстрока PLACED, дальше ведут события клиента
Окончательный отказ (OrderError::ExchangeRefused и разобранные формулировки BalanceSettling / PostOnlyCrossing / MatchingEngineRestart)ответ ПОЛУЧЕН и отрицателенрезервация освобождается, строка REJECTED, событие OrderRejected
Неопределённость (OrderError::SubmitOutcomeUnknown)отправка состоялась, ответа, доказывающего отсутствие ордера, нетрезервация УДЕРЖАНА, снимок ордера НЕ удалён, строка AMBIGUOUS_SUBMIT, повторный выпуск намерения заблокирован

Правила, без которых третий исход не работает:

  • Отнесение — по МЕСТУ ПОСТРОЕНИЯ ошибки, а не по её имени. Один и тот же PoolError строится и до POST'а (бюджет частоты), и после его начала (тайм-аут оборота); различает их обёртка SendPreparedError::{NotDispatched, Dispatched}, которую ставит сам пул. Отказ, случившийся до POST'а (NotDispatched), — окончательный: ордера нет.
  • Ответ 200 разбирается ПО ТЕЛУ. Признак отказа — непустой errorMsg, а не HTTP-статус и не поле success (оно приходит true на документированном отказе). Разбор ОБОРОНИТЕЛЬНЫЙ, и граница улик названа: форма «200 плюс errorMsg» задокументирована у ПАКЕТНОГО /orders, которым движок не пользуется, а одиночный POST /order по измерению 2026-07-29 отказывает не-2xx с телом {"error": …} (поле error, не errorMsg; на успехе errorMsg присутствует и пуст). Схема тела у обоих эндпойнтов одна, поэтому защита сохранена; утверждать, что эта форма встречается на одиночном эндпойнте, нельзя. Контракт контрагента — ../architecture/polymarket-clob-order-api.md.
  • Блокировка ключуется на тройке «кошелёк, исход (token_id), сторона», durable-таблица unresolved_submits. Не на личности сигнала: на неопределённом исходе сигнал сознательно НЕ закрывается, но даже если бы закрылся — следующий такт завёл бы новый signal_uid с новым client_order_id, и барьер по ордерам сигнала оказался бы пуст по построению.
  • Неопределённая ПОКУПКА никогда не повторяется. В подписанной V2-структуре нет поля клиентского идентификатора, а salt и timestamp меняются на каждом построении: «повтор» физически создаёт ВТОРОЙ ордер, а покупку биржа ограничивает залогом — второй ордер будет ПРИНЯТ и позиция удвоится. Для ЗАЩИТНОЙ ПРОДАЖИ действует отдельное правило (ниже): её ограничивают запасы долей, и вторая продажа того же инвентаря биржей отвергается.
  • Резерв не освобождается по времени ни при каких условиях. Выход из удержания даёт только факт: пришедшее событие, вердикт сверки или явное решение оператора.

Признак «ордер занимает место» выражен ОДНОЙ функцией OrderStatus::occupies_slot (NEW, PLACED, PARTIAL, AMBIGUOUS_SUBMIT, AMBIGUOUS_CANCEL — последний зеркалит неопределённость на пути ОТМЕНЫ, тем же принципом, что и AMBIGUOUS_SUBMIT на пути отправки, см. ниже §«Сверка неопределённой отмены»); её же зовут барьеры стратегий, симулятор и слой хранения (перечень литералов SQL выводится из неё, оба частичных индекса пересобираются переводом схемы). Инлайновых наборов статусов в Rust-коде нет — это держит тест-оракул по дереву исходников.

Защитная продажа: одна повторная отправка не позднее секунды ​

Позиция, чей защитный выход отправлен и не подтверждён, стоит на рынке без защиты, пока сверка не вынесет вердикт, — а это 30–60 секунд. Для продажи это дорого («20 секунд для продажи критично для изменения стакана»), поэтому защитная продажа выпускается ПОВТОРНО не позднее PROTECTIVE_RESUBMIT_DEADLINE_MS = 1000 мс от момента неопределённого исхода. Срок — константа контура, не настройка; возраст считается через внедрённые часы.

Асимметрия по стороне держится на ограничителе биржи, а не на предпочтении: покупку ограничивает ЗАЛОГ (повтор будет принят), продажу — ЗАПАСЫ ДОЛЕЙ по инварианту maxOrderSize = balance − Σ(openOrderSize − filledAmount), поэтому вторая продажа того же инвентаря физически невозможна и худший исход повтора — отвергнутая заявка. Кого повторять, решает один предикат submit_may_be_protectively_repeated(side, exit_role): сторона Sell И непустая роль выхода.

  • Повтор ровно один. Заявка изымается из карты ожидающих ПЕРЕД выпуском; карта живёт в процессе, поэтому перезапуск повтор отменяет — стакан к тому времени другой, и защитную цену заново считает стратегия.
  • Повтор проходит ОБА барьера движка. Барьер неразрешённой отправки — одноразовым пропуском на конкретный order_uid повтора. Durable-уникальный индекс uq_orders_live_immediate_exit — вырезом в предикате по метке -protective-resubmit в order_uid: инвариант читается «не более ОДНОГО живого немедленного выхода на исход, НЕ являющегося защитным повтором другого», и слот остаётся за первой, неопределённой строкой.
  • Своя резервация alloc-resubmit-{target}. Разделяемая alloc-exit-{signal} перезаписала бы привязку первой строки, и разрешение удержания сняло бы снимок ЖИВОГО повтора.
  • Учёт первой отправки повтор НЕ закрывает. Ответ на повтор читается (read_resubmit_probe), но ни одна ветка не освобождает резервацию, не снимает блокировку и не книжит филл: три числа в отказе биржи (balance / sum of active orders / sum of matched orders) — агрегаты по кошельку и исходу, отнести их к нашей заявке нечем. Для заявки FAK «занято активными» вообще не может относиться к ней: FAK не покоится и долей не связывает. Судьбу первой отправки устанавливает только сверка.
  • Филл первой продажи, пришедший ПОСЛЕ повтора, книжится обычным путём — отброшенный, он стал бы незабукированным исполнением.

Сверка неопределённой отправки: три рубежа в обязательном порядке ​

Работник сверки — единственный, кто зовёт рубежи; заявок он не размещает и не отменяет (OrderClient ему не передан вовсе). Порядок и ранний выход:

  1. Открытые ордера по рынку И исходу — соединение по отпечатку заявки, записанному в строку ордера ДО отправки (orders.signing_hash; на Polymarket идентификатор ордера численно равен подписному хешу).
  2. Лента сделок по рынку и исходу за окно submitted_at − 600 с. Запись с нашим отпечатком в статусе RETRYING/FAILED доказывает ПРИЁМ заявки, хотя исполнения не доказывает. Свидетелем ОТСУТСТВИЯ сделки этот рубеж выступает лишь на ПОЛНОЙ странице: лента отдаётся кусками по 300 записей с курсором следующего смещения, и «в прочитанном куске нашей сделки нет» — не то же самое, что «сделки не было». Полнота берётся из next_cursor (терминальное значение "LTE="), а не выводится из count == limit: курсор — собственное утверждение сервера, равенство счётчика лимиту — лишь похожий на него признак. Ответ без страничной обёртки даёт «не установлено», а не молчаливое «полная».
  3. Доли на цепочке — крайний рубеж, запрашивается только когда первые два молчат. Свидетелем отсутствия он выступает лишь при ТОЧНОМ равенстве «холдинг = учтённое движком»: любое неравенство означает, что учёт и цепочка разошлись, и такой свидетель ничего не подтверждает. Холдинг читается на кошельке-ВЛАДЕЛЬЦЕ (poly_funder_address, иначе адрес подписанта) — единым резолвом WalletCredentials::polymarket_owner_address(), тем же, которым пользуются выкуп позиций и признание своей сделки в потоке RTDS. Правило существует потому, что чужой адрес вернул бы ноль, а ноль на свежем входе СОВПАДАЕТ с нулём учтённого объёма — то есть выдал бы ЛОЖНОГО свидетеля отсутствия, и притом только там, где доли у нас отсутствуют: по наблюдаемым признакам такая ошибка не проявлялась бы вовсе. (В текущей боевой конфигурации адрес подписанта и адрес владельца совпадают, поэтому правило снимает латентный риск, а не исправляет наблюдаемое поведение.)

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

Правило задержки рынка (без него единогласие трёх рубежей лжёт). Ответ POST /order имеет состояние delayed: заявка ПРИНЯТА, но ещё не выпущена в книгу и в окне задержки не отменяется. Пока она delayed, все три рубежа честно отвечают «нашего ордера нет». Поэтому отрицательный вывод «ордера не было» не выносится раньше тихого периода ЭТОГО рынка, считая от собственного времени отправки:

text
тихий период = Market.seconds_delay этого рынка + NO_ORDER_QUIET_PERIOD_MARGIN_SECS (30 с)

Задержка рынка читается публично (GET /markets/{condition_id}, pm_sdk::clob::public_market::fetch_market_seconds_delay; подписи не требует, торгового бюджета частоты не расходует, значение кэшируется по рынку — кэшируются только успехи). Спрашивается она ТОЛЬКО на пути к отрицательному выводу: положительные выводы тихим периодом не гейтятся и задержку не запрашивают вовсе.

Запас в 30 с покрывает то, чего в свойстве рынка нет: окно тейкерской задержки (itode, 250 мс), задержку появления факта в REST-поверхностях книги и ленты, расхождение часов. Величина равна прежней ОБЩЕЙ константе, поэтому на рынках с seconds_delay = 0 (13 761 из 15 344 принимавших заявки на момент замера — подавляющее большинство, включая домен активной стратегии) поведение не изменилось; рынок с задержкой 33 с получает 63 с вместо 30 (измерение).

Задержка рынка НЕ УСТАНОВЛЕНА ⇒ отрицательный вывод по этому рынку недостижим вовсе. Ноль, умолчание либо «разумная» константа не подставляются нигде (RULE-NEW-01): подставленное число — догадка о чужом свойстве, а цена заниженной догадки — деньги, разжатые под ПРИНЯТОЙ заявкой. Исход «задержка не установлена» РАСХОДУЕТ бюджет попыток сверки — в отличие от тихого периода, который его не расходует. Разница денежная: окно задержки кончится само, а сломанный источник сам не починится, и запись, вечно стоящая в стадии «сверка идёт», не подняла бы ни сигнала оператору, ни стадии «сверка не дала ответа». Расход бюджета доводит такую запись до оператора за пять проходов; попытки при этом не прекращаются, поэтому разовая недоступность заживает сама.

Пришедшее событие сильнее поздней сверки. Любое событие жизненного цикла по неопределённой строке доказывает, что биржа заявку приняла: удержание снимается, филл книжится ОБЫЧНЫМ путём (PositionRegistry), сверка по этому ордеру закрывается, и отрицательный вывод после этого недостижим. Молчание событийного канала доказательством отсутствия ордера не является никогда.

Вердикты и что они делают:

ВердиктДеньги и строка
ордер в книге, налито нольстрока → размещена с биржевым идентификатором, резервация остаётся под ордером, блокировка снята
ордера не былострока → отказ, резервация освобождена, сигнал закрыт, намерение снова доступно
ордер найден И УЖЕ НАЛИЛНЕ разрешается автоматикой: деньги удержаны, намерение заблокировано, стадия → «сверка не дала ответа», разбирает оператор (второго писателя позиций в движке нет и заводить его здесь нельзя)
не установленопопытка засчитана; по исчерпании бюджета попыток — та же стадия, попытки продолжаются

Оператор — штатный, а не аварийный разрешитель: строгое равенство на третьем рубеже и тихий период делают отрицательный вывод заметно менее достижимым, и это выбрано сознательно (цена ошибки в одну сторону — удержанные секунды, в другую — второй ордер поверх живого первого). Операторская поверхность и регламент — ../operations/runbook.md §«Неразрешённая отправка»; метрики и сигнал — ../operations/alerts-and-monitoring.md.

Сверка неопределённой отмены: два вопроса вместо одного ​

Зеркальная секция для ДРУГОГО перекрёстка жизни заявки: транспортная команда отмены ушла биржа, а исход этой КОНКРЕТНОЙ отмены не установлен фактом — ответ биржи не пришёл, пришёл деградированным, либо противоречит записанному наливу. Строка получает статус AMBIGUOUS_CANCEL (третий вид причины, наравне с AMBIGUOUS_SUBMIT, но на ПРОТИВОПОЛОЖНОМ конце жизни заявки — заявка уже была подтверждена биржей, вопрос не «была ли она вообще», а «жива ли ещё и сколько исполнила перед отменой»).

Вопросов здесь ДВА, а не один, и они намеренно ортогональны:

  • Жива ли заявка (RemoteOrderState) — StillLive (биржа авторитетно подтвердила: заявка всё ещё в книге, целиком или частично), ConfirmedTerminal (биржа подтвердила терминальный статус), Unrecognized (свидетельство недоступно, деградировано, либо биржевой статус не входит в закрытый словарь filled/cancelled/expired/ open/partial).
  • Сколько исполнено (FillTruthBeforeCancel) — Agrees (биржа не знает о наливе больше, чем уже записано), Diverged (биржа исполнила БОЛЬШЕ записанного — строка приводится к факту биржи ДО терминализации, авария cancel_fill_divergence уходит в тревожные приёмники), Undetermined (ответ недоступен либо свидетельством не является).

Единая точка вынесения обоих вердиктов — не переизобретается на каждом пути терминализации отдельно: обычная отмена, зависшая заявка, живое WS-уведомление, периодическая сверка, лечение при запуске, остановка процесса, разрушительный операторский сброс — все проходят через ОДНУ и ту же классификацию, иначе два пути с одинаковым свидетельством могли бы разойтись во мнении.

Строгий разбор статуса — путь вердикта, а не путь отправки. Снисходительный разбор биржевого статуса (используемый листингом открытых ордеров и bootstrap-сверкой) подставляет Live на любой нераспознанный статус — приемлемо там, где неверный вывод не денежный. На пути вердикта отмены такая подмена стёрла бы различие «биржа подтвердила: жива» от «биржа прислала что-то незнакомое». Отдельная строгая функция протокольного клиента (pm-sdk) сохраняет сырую строку биржи без потерь (типизированное биржевое перечисление разбирается напрямую, минуя лоссовый to_string()) и не подставляет ничего вместо неё; путь вердикта — ЕДИНСТВЕННЫЙ потребитель строгого разбора, снисходительный разбор и его прочие вызывающие не меняются. Unrecognized наблюдаем счётчиком и сырой строкой в журнале, а не молчаливым «open».

Сторона заявки не заменяет доказательство терминальности. StillLive означает, что BUY или SELL продолжает владеть своим слотом и резервацией; локальная запись не терминализуется, пока CLOB авторитетно видит заявку. Unrecognized также оставляет обе стороны в AMBIGUOUS_CANCEL, потому что деградированное свидетельство не отличает исчезнувшую заявку от живой. Даже после ConfirmedTerminal неизвестный FillTruthBeforeCancel не даёт права освобождать резервацию: поздний fill должен быть восстановлен или авторитетно исключён. Структурный запрет овер-продажи не решает эту задачу: потерянный локальный SELL-владелец оставляет удалённый ордер держать остаток и блокирует следующий защитный выход.

Периодический работник (engine::ambiguous_cancel_sweep) обходит открытые AMBIGUOUS_CANCEL-строки и разрешает КАЖДУЮ той же единой классификацией. При StillLive работник (не сама классификация) решает, слать ли один повтор транспортной отмены, и отслеживает частоту повтора процесс-локально независимо от стороны. Unrecognized — строка ждёт следующего тика без побочного эффекта.

Ручное разрешение — операторская страховка на случай, когда автоматика устойчиво не сходится. POST /api/local/ambiguous_cancels/{order_uid}/resolve (зеркальная поверхность выхода POST /api/local/unresolved_submits/{uid}/resolve, двойной подтверждающий токен, явный исход без умолчания) СНАЧАЛА выполняет ТУ ЖЕ синхронную проверку, что и периодический работник, — заявленный оператором исход применяется ТОЛЬКО когда эта проверка сама показывает устойчиво деградированное свидетельство; свежий факт всегда перевешивает заявленное намерение. Три заявленных исхода:

ИсходЧто известноЧто делает движок
«подтверждённый ноль»оператор утверждает: исполнения не былострока → CANCELLED тем же CAS-примитивом, что автоматический путь; append-only аудит с источником Operator (отличимым от Reconciliation, которым book'ит автоматика) — в ОДНОЙ транзакции с переходом
«жива»оператор подтверждает наблюдаемую живостьстрока ОСТАЁТСЯ AMBIGUOUS_CANCEL, факт уходит в аудит (multiple записи легальны — каждая проверка самостоятельна), немедленный повтор отмены запрашивается тем же каналом, что у периодического работника
«был налив»оператор утверждает: исполнение былоОТКАЗЫВАЕТ ВСЕГДА в текущей реализации — агрегатная величина денежным оракулом не является, требуется отдельный идемпотентный букинг по конкретным подтверждённым сделкам, которого пока нет; тело запроса структурно не несёт поля для суммы налива

Условное обновление «только из AMBIGUOUS_CANCEL» даёт естественную ровно-однократность: опоздавший (ручка против sweep, второй одновременный ручной запрос) видит ноль изменённых строк в CAS и отказывает без побочных действий; повтор после успеха читается как «уже разрешено». Durable-переход и append-only аудит атомарны одной транзакцией — снятие резервации в памяти НЕ является источником истины и происходит ПОСЛЕ commit'а, поэтому рестарт между этими двумя шагами восстанавливает согласованное состояние из ОДНОЙ durable-записи. Схема данных — storage.md §«ambiguous_cancel_resolutions».

Прочие порты ​

  • OrderbookFeed — подписка на book/price_change поверх unified OrderbookStore (source-tracked merge WS + REST snapshot). Производитель MarketDataSnapshot выводит probability_milli через midpoint собственной книги токена: (best_bid + best_ask) / 2. Формула универсальна для binary (YES/NO, CTF-арб enforce'ит YES_mid + NO_mid = 1) и neg-risk outcome'ов (локально binary, sum YES_mid ≈ 1 across event). NO-инверсия implicit через собственный orderbook. Защита: best_bid > best_ask → warn + None. Источники verified: docs.polymarket.com/{concepts/prices-orderbook, advanced/neg-risk} + pm-bot precedent.
  • MarketDataPort — Gamma snapshot + ClosureWatcher (per-outcome поллинг + user-WS).
  • PersistencePort — CRUD по 4 доменным сущностям + транзакции + outbox для EventBus.
  • ClockPort — боевые часы WallClock в проде, управляемые часы в тестах: домен не читает системное время напрямую.

Жизненный цикл Signal → Orders → Position ​

Нормативный сквозной контракт данных определён в event-context.md. Стратегия получает собственную ветвь неизменяемого событийного контекста, добавляет Signal и связанный OrderIntent, а OrderRouter обогащает эту ветвь наблюдаемым lifecycle заявки. Диаграмма ниже описывает денежные операции и хранение, но не заменяет контракт контекста.

Перед построением OrderIntent directional-стратегия разрешает обязательную policy своей роли (initial_entry, dca, target_exit, stop_exit, rank_exit) одним pure resolver'ом в {order_type, time_in_force, gtd_seconds, post_only}. FAK/FOK — значения типа ордера, а не стратегии и не независимые флаги. Решение Direct/WatchArm/stop/rank определяет момент, цену и размер, но не подменяет execution policy. BUY fee cap хранится отдельно в своей BUY-роли; фактическая форма всё равно повторно валидируется адаптером.

Strategy.on_market_data / on_tick
   │
   ├─ ctx.emit_signal(Signal { … })
   │     └─► SignalRegistry.insert (txn: signals + events.SignalEmitted)
   │
   ├─ ctx.emit_order(OrderIntent { signal_id, side, price, size, tif })
   │     ├─► OrderRouter.allocate
   │     │     ├─ идемпотентный возврат по той же роли (без пере-проверок)
   │     │     ├─ ЕДИНСТВЕННЫЙ вопрос допуска (AdmissionControl::admit):
   │     │     │    причина остановки → и только для BUY — готовность входа
   │     │     ├─ SELL: резервация без RISK-LIMITS (выход не лимитируется)
   │     │     ├─ enforce ровно 3 canonical RISK-LIMITS
   │     │     └─ bind allocation + exact intent → SubmitAuthorization
   │     └─► OrderRouter.submit_allocated
   │           ├─ persist orders(status=NEW) + events.OrderPlanned (one txn)
   │           ├─ persist orders.signing_hash (EIP-712 отпечаток) ДО POST'а
   │           ├─ OrderClient.submit(AuthorizedOrderIntent)
   │           │    └─ live: exact canonical↔wire integrity check before POST
   │           ├─ on ack:              status=PLACED  + events.OrderPlaced   (one txn)
   │           ├─ on definitive refusal: status=REJECTED + events.OrderRejected (one txn)
   │           └─ on unknown outcome:  status=AMBIGUOUS_SUBMIT + unresolved_submits
   │                                   (резервация УДЕРЖАНА, события отказа НЕТ)
   │
OrderClient.events stream
   │
   ├─ PartialFill / Fill
   │     ├─ persist Fill + events.OrderFilled                     (one txn)
   │     ├─ PositionRegistry.apply_fill → upsert positions + events.PositionChanged (one txn)
   │     └─ Strategy.on_order_event + Strategy.on_position_change
   │
   └─ Cancelled / Expired / Rejected
         └─ Order.status update + events.OrderCancelled            (one txn)

EventBus и outbox ​

In-process MPSC канал распространяет BotEvent после атомарной записи в БД. Outbox-паттерн: writer в одной SQLite-транзакции пишет domain-row + соответствующую events-row; после COMMIT публикует event на bus. Если COMMIT упал — ни domain-row, ни event не появятся; bus тоже не получит фантомный сигнал.

Гарантия: доменный факт никогда не теряется — он в таблице events. Bus — оптимизация для live-потребителей. При рестарте подписчик «нагоняет» от последнего seen seq через SELECT … WHERE seq > ?.

Подписчики:

  • SSE-публикатор → дашборд (мост wire/ → BotEvent → SSE event-name 1:1).
  • Metrics-pump → Prometheus.

Single-writer discipline ​

Каждая доменная таблица имеет ровно одного writer'а:

ТаблицаЕдинственный writerСобытия в outbox
signalsSignalRegistrySignalEmitted/Completed/Rejected/Superseded
ordersOrderRouterOrderPlaced/Filled/Cancelled/Rejected
positionsPositionRegistryPositionOpened/Changed/Closed/Redeemed
eventsappend-only любым writer'ом — но только в той же транзакции, что domain-mutation—

Архитектурный гейт: ast-grep сканер в CI блокирует любой INSERT|UPDATE|DELETE к этим таблицам вне соответствующего writer'а. Это расширение правил ADR-029 (Unified PositionRegistry) на ордера и сигналы.

Выбор рабочей стратегии и отказы старта ​

Рабочую строку выбирает база, а не окружение. Предикат отбора — пара «файл × кошелёк»: enabled = 1 AND wallet_label = <кошелёк инстанса>. BOT_STRATEGY_LABEL и --strategy-label удалены целиком; аргумент не игнорируется, а отсутствует — разбор отвечает «unexpected argument», и старый способ запуска ломается громко.

Отбор выполняется ЗАПРОСОМ, возвращающим ВСЕ подходящие строки; решение принимается по их фактическому числу. LIMIT 1 отсутствует намеренно: это был бы молчаливый выбор «первой» с шансом один к двум запустить не ту стратегию.

Двухключевая проверка INV-3 не исчезла, а СТАЛА самим предикатом: строка, переставленная на другой кошелёк, просто не попадает в выборку. Прежние ветки Inv3WalletMismatch / Inv3StrategyWalletMissing / StrategyDisabled стали недостижимы и удалены — недостижимая проверка создаёт впечатление защиты, которой в этом месте уже нет.

Отказы старта, относящиеся к выбору строки:

СостояниеЧто говорит движок
активной строки на кошельке нетназывает кошелёк и путь config.db, указывает назначить активную конфигуратором или pm-cli
активных две и болееперечисляет все найденные метки; выбор наугад не делается
метка прошлого запуска ≠ активной И есть открытые позицииназывает обе метки и число позиций: чужой инвентарь не обслуживается чужими порогами

Ветка «две и более» реализована и покрыта тестом, несмотря на то что структурное ограничение схемы v4 делает такое состояние недостижимым при штатной записи: config.db — plaintext-файл, ограничение добавляется миграцией, и база, созданная до неё или отредактированная в обход, обязана получить отказ, а не выбор наугад. При штатном открытии первым срабатывает миграционный барьер, поэтому ветка загрузчика остаётся второй линией.

Смена активной строки при открытых позициях запрещена, и проверка стоит в ДВУХ местах — у писателя активации и на старте, — потому что config.db и торговое состояние живут в разных файлах. Обе читают одно определение «плоского состояния» (engine::flat_state): два определения означали бы запрет, обходимый сменой точки входа.

Lifecycle + bootstrap ​

  • LifecycleFSM — состояния процесса (Starting → Bootstrapping → Running → Draining → Stopped).
  • BootstrapReconciler:
    • Live режим: открытые ордера (признак занятости OrderStatus::occupies_slot) ↔ REST snapshot CLOB. Snapshot считается авторитетным только после успешного чтения всех страниц до терминального cursor; ошибка последующей страницы отбрасывает весь уже собранный префикс. Расхождения → план reconciliation; превышение bootstrap_auto_heal_action_cap → fail-fast.
    • Authenticated GET /data/orders сохраняет только ограниченные безопасные признаки ответа: HTTP status, media type, Retry-After и CF-Ray. JSON 401/403 и HTML/WAF block немедленно открывают общий CLOB circuit breaker; процесс остаётся живым в Reconciling, а следующая полная read-only authenticated проба допускается не раньше чем через 60 с. 429, 5xx и transport failure остаются слабыми признаками и повторяются только как полное новое чтение с первой страницы под единым monotonic deadline BOOTSTRAP_RECONCILE_TIMEOUT_SECONDS; параметр обязателен, положителен и не имеет default. Только успешная терминальная пагинация закрывает circuit до перехода lifecycle в Ready; тишина и статус-страница восстановление не доказывают. Тот же монитор во время активной торговли ВЗВОДИТ причину ExchangeAccess с областью «только новые входы», сохраняя SELL-выходы, стопы, redeem и сверку; подробный контракт — в risk-and-emergency.md. Закрытие circuit успешной bootstrap-сверкой — такое же открытие входов, как и любое другое: если оно снимает ПОСЛЕДНЮЮ причину, закрывающую новые входы, поколение открытия растёт и прогрев входа начинается заново.
    • Строка AMBIGUOUS_SUBMIT входит в признак занятости, поэтому переживает перезапуск вместе со своей резервацией и своей блокировкой; восстановление такой строки поднимает сигнал операторского класса.
    • Усыновление заявок, переживших перезапуск (lifecycle::resting_order_adoption, после лечения строк сверкой и до первого такта стратегии). Сверка приводит в порядок ЗАПИСИ о заявках, но не заводит соответствия «биржевой идентификатор → заявка» в реестре корреляции боевого клиента: его заводит ТОЛЬКО успешная отправка. Поэтому заявка прошлого процесса, оставшаяся на книге, была для нового процесса чужой — её исполнение приходило кадром с неизвестным именем, ложилось в буфер «пришло раньше подтверждения» и вытеснялось по TTL (30 с) молча. Прецедент 2026-08-13, позиция 231: продажа 6 долей по 0.99 исполнилась через 61 минуту после старта, $5.94 выручки не попали в учёт, а строка позже была списана как истёкшая с нулевым наливом. Теперь по каждой заявке, которую биржа подтверждает живой, соответствие заводится задним числом через тот же порт, что чинит потерянное подтверждение отправки (LostAckRecovery::adopt_proven_order, политика KeepCorrelation); заявок на биржу при этом не подаётся и деньги не трогаются. Водяной знак — забукированный движком налив, знаменатель инварианта «исполнено ≤ размещено» — размер заявки на бирже. Заявка, живой не подтверждённая, НЕ усыновляется: её судьбу решает absent-путь. Наблюдаемость — pm_engine_resting_orders_adopted_total, pm_engine_resting_order_adoption_failures_total{source}, pm_engine_pending_frame_evicted_total{kind}.
    • Соответствие local ↔ remote строится по ДВУМ ключам. Основной — polymarket_order_id (подтверждённое размещение). Второй — записанный до отправки отпечаток orders.signing_hash: у неопределённой строки подтверждения нет по определению, а заявка при этом может СТОЯТЬ в книге. По одному ключу она признавалась чужим сиротой и снималась (BOOTSTRAP_CANCEL_UNKNOWN_ORDERS в боевой конфигурации включён) — то есть движок отменял собственную заявку, по которой держал удержание бюджета и блокировку намерения. Соединение законно потому, что идентификатор заявки численно равен подписному хешу; сравнение нормализует регистр hex и префикс 0x. Опознание НЕ зависит от порядка старта (отпечаток берётся из тех же локальных строк, которые сверка уже читает, а не из реестра удержаний, восстанавливаемого позже), НЕ пишет polymarket_order_id и НЕ меняет состояние строки, но наблюдаемо правилом журнала bootstrap_recognised_own_unacked_order. Строку закрывает работник сверки либо оператор.
    • Сверка холдингов кошелька — вне критического пути старта (lifecycle::holdings_reconcile, openspec holdings-bootstrap-off-critical-path). PositionRegistry::hydrate() поднимает записанные позиции из engine.db заметно раньше — ещё внутри app::execution::build, до попытки собрать сверщик; порядок этого шага сверка не меняет и от неё не зависит. Раньше сборка сверщика заканчивалась прямым .await его первого прогона: неограниченное ожидание Data API держало старт целиком — рехидрацию OrderRouter, спавн драйвера стратегии и защитных подметал ждало внешнего ответа без собственного таймаута. Теперь app::execution::build только СОБИРАЕТ сверщик и возвращает управление немедленно; при ошибке сборки состояние фиксируется как not_performed{reason="construction"} одной записью (HoldingsReconcileState::record_construction_failure), а не остаётся в вечном pending. Сам прогон стартует первым тактом фоновой задачи app::protection::spawn_periodic_sweeps — уже ПОСЛЕ того, как драйвер стратегии и защитные подметалы запущены. Прогон ограничен пределом HOLDINGS_RECONCILE_DEADLINE = 300 с (tokio::select! с приоритетом отмены); по истечении предела фиксируется not_performed{reason="deadline"}, а не зависшая задача. Ни один исход сверки (pending/clean/discrepancies/not_performed) не влияет на торговый цикл и не читается допуском: TradingGate/EntryHaltState/LifecycleFsm о состоянии сверки не знают — границу держит храповиком scripts/arch-gate.sh R11. Реакция на найденное расхождение — только alert в приёмники (решение оператора 2026-08-19), не остановка торговли ни при каком исходе, включая недоступность источника и превышение предела.
  • Подъём открытых заявок в память (OrderRouter::recover_from_db, после лечения строк сверкой и до первого такта стратегии) читает роль заявки на стороне входа — первичный вход или добор — ИЗ КОЛОНКИ orders.entry_role, а не из имени строки: роль туда кладёт тот же маршрутизатор из типизированного намерения при записи строки, поэтому после перезапуска снимок несёт ту же роль, что нёс до него. По этой роли обслуживание стоящей заявки входа (снятие устаревшей, перестановка) отличает первичную заявку от приколотой заявки добора, а обработчики исходов заявки опознают добор уже после того, как снимок выброшен (fetch_released_snapshot читает ту же колонку). Покупка с пустой ролью в строке — только историческая, старше колонки и не опознанная одноразовым переводом по имени — поднимается без роли (подстановка запрещена: неопознанная покупка может быть добором), контуром входа не обслуживается, старт не прерывает и считается рядом pm_engine_hydrated_buy_without_entry_role_total — любая покупка, без фильтра по стратегии и имени. Недопустимый литерал в колонке останавливает подъём отказом чтения (PersistenceError::Constraint): это признак несовместимого писателя, а не «роли нет». Схема колонки и одноразовый перевод строк — storage.md §orders.

Emergency-stop (по якорю A6) ​

Аварийный stop снимается ИСКЛЮЧИТЕЛЬНО полным перезапуском процесса pm-engine. Никакого soft-resume и /api/local/clear-emergency endpoint нет. EmergencyGuard причину только взводит; стоит она в TradingGate, состояние которого живёт только в памяти процесса: рестарт очищает его, а engine.db это состояние не восстанавливает. Метода снятия у PnlKillSwitch нет ни у владельца, ни у оператора. Персистентный kill-switch остаётся отдельной незавершённой способностью (PM-ENGINE-2 в tech-debt-backlog-2026-08-31.md). Отдельно и прямо: БОЕВОГО взводящего у причины PnlKillSwitch нет и никогда не было — фактическая защита по накопленному убытку это CircuitBreaker, и она взводит Drain. Подробности — risk-and-emergency.md §Слой 2.

Обнаружение событий и рынков ​

Нормативный целевой контракт вынесен в discovery.md: единый GammaClient, одна lifecycle-подписка WS внутри discovery, полный набор фильтров /events/keyset, каноническая EventRevision, отдельные представления стратегий и шардирование только по event_id.

Текст ниже до раздела «Тракт доставки рыночных данных стратегии» описывает текущее состояние реализации до этой переработки и не является целевым контрактом. При реализации он подлежит удалению либо переносу в историю; противоречие разрешается всегда в пользу discovery.md.

Текущее состояние до целевой переработки ​

Набор рынков рождается в ОДНОМ месте — discover_markets_pass() адаптера adapters::market_data::gamma_market_data::GammaMarketDataAdapter (НЕ нормативный discovery/ из discovery.md — это отдельная, ещё не начатая работа; текущий адаптер, наоборот, применяет продуктовые фильтры и строит MarketMetadata прямо в проходе, то есть именно то, что нормативный discovery обязан не делать). Порядок внутри прохода: keyset-проход событий по каждому тегу из tag_ids (СТАБИЛЬНЫЙ ЧИСЛОВОЙ порядок — отсортированная копия, а не порядок, в котором теги перечислены в конфигурации) → дедупликация → отсев по признакам жизненного цикла → отбор по типу рынка → преобразование ответа во внутренние метаданные → фильтр по подстроке названия. Стартовый проход и каждый такт повторного обнаружения наследуют всю цепочку от одной реализации.

Транспорт обхода — keyset-курсор целиком, offset не используется нигде (openspec gamma-keyset-pagination, раздел 10 карточки, решение оператора 2026-09). И списки рынков (list_closed_by_condition_ids, list_by_condition_ids_with_tags), и обход событий обнаружения идут через /events/keyset//markets/keyset. Прежний офсетный обход событий, включая его потолок глубины (GET /events принимал offset не выше 2000, замер 2026-08-24) и локальный предохранитель в 50 страниц, — удалён из кода полностью вместе с типом GammaSource::list_events_by_tag: причина перехода (риск отказа ЦЕЛОГО тега при превышении глубины) с переходом на курсор исчезла как класс, поскольку полноту обхода теперь обеспечивает исключительно курсор источника, а не число страниц.

Продолжение задаётся непрозрачным after_cursor из предыдущего ответа. Конец обхода определяется ТОЛЬКО отсутствием курсора: полная последняя страница без курсора существует, и признак «страница короче лимита» на keyset неверен. Локального потолка страниц НЕТ — конечный законный набор может занять сколько угодно страниц (scripts/bench/gamma-pagination-cost.sh --tag <id> печатает запросы, страницы, строки, байты и время повторяемо; выводов о скорости по нему не делается). Проход публикуется целиком или не публикуется вовсе — отказ любой страницы (транспорт, HTTP-статус, malformed page/root JSON — все три разбираются как KeysetError::Source, отдельной ветки целостности для битого тела нет), пустой курсор и повтор курсора отменяют весь проход, а накопленные строки отбрасываются. Повтор идентификатора решается заменой ПОСЛЕДНЕЙ целой строкой, а не отказом прохода (design D4/D6): конфликтующий повтор — сперва по Event.id внутри одного тега (транспортный слой pm-sdk), затем по Event.id между разными тегами и по condition_id между разными событиями (адаптер) — заменяется последней по порядку traversal целой строкой БЕЗ слияния отдельных полей; прежний отказ прохода на конфликте (KeysetError::IdentityConflict) снят вместе с production duplicate census (типа DuplicateCount в коде больше нет — повтор замещается, а не считается). Дедупликация в адаптере идёт СТРОГО ДО отсева по жизненному циклу, а не после: событие, отсеянное раньше, никогда бы не попало в карту по Event.id и не заменило бы устаревшую строку того же идентификатора из другого тега — устаревшая строка тихо осталась бы победителем.

Три параметра, которые keyset не поддерживает так, как прежний путь: offset запрещён контрактом, active и archived источник МОЛЧА игнорирует (замер 2026-08-24). Поэтому отбор по признакам жизненного цикла события выполняется после ответа, а не в запросе, и параметр live не подставляется вместо active: равенство множеств не доказано. Фактический потолок limit — 100, хотя документация объявляет 500; полноту это не меняет, её обеспечивает курсор.

Стоимость обхода наблюдаема тремя интервалами (pm_engine_gamma_keyset_{request,page,pass}_duration_seconds) с меткой исхода из ТРЁХ значений (complete/source_failed/integrity_failed — KeysetOutcome::from(&KeysetError)) и без меток неограниченной мощности. Метка scope различает три вызывающих (discovery/closures/listings); discovery наблюдается фактически с момента переключения discover_markets_pass() на keyset (прежде код существовал, но production-вызывающего у него не было). Все страницы идут через тот же ограничитель скорости, что и остальные вызовы Gamma; собственного механизма повторов у сборщика нет.

Ключ discovery.slug_filter (openspec discovery-market-slug-filter) — необязательная строка в секции обнаружения конфигурации СТРАТЕГИИ (config.db, ADR-015). Отсутствие ключа — прежнее поведение во всём. Источник значения только один; ни переменная окружения, ни аргумент командной строки этой ручкой не являются. Пробный --probe-city остаётся отдельной ручкой пробного режима и целит в ТОТ ЖЕ слот, поэтому одновременно заданные ключ и пробный аргумент — отказ старта (ни пересечения, ни приоритета).

Предикат один на все поверхности и сравнивает ПОБАЙТОВО: рынок подходит, если его погодный city-slug ТОЧНО равен значению ЛИБО его название (slug внутреннего рынка, не события) СОДЕРЖИТ значение как подстроку. Нормализации регистра, сворачивания разделителей, шаблонов и регулярных выражений нет. Три следствия нормативны:

  • значение, равное голому имени города, совпадает по первой ветви и допускает ВСЕ даты города — то есть РАСШИРЯЕТ вместо сужения;
  • подстрока может задеть соседнее название, содержащее её целиком;
  • различие регистра означает несовпадение.

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

Правила загрузки (обе поверхности — старт движка и сохранение из дашборда): пустое и целиком пробельное значение отвергается (пустая подстрока содержится в ЛЮБОМ названии, то есть объявила бы фильтр применённым и не сузила бы ничего); краевой пробел отвергается, а не обрезается молча; заданный фильтр ТРЕБУЕТ присутствия rediscovery_interval_secs — без координатора обнаружение одноразовое, рынок, листингованный после старта, не найдётся никогда, и одновременно исчезает довзятие рынков с деньгами.

Два разных нуля различаются на шве. Проход обнаружения отдаёт не голый вектор, а счёт «до фильтра», счёт «после» и счёт отбраковки преобразованием. Стартовое обнаружение с нулём ДО фильтра — отказ старта (сломанный набор тегов, недоступный источник, неверный тип рынка либо массовая отбраковка преобразованием; последнее названо в сообщении отдельно). Ненулевой набор, суженный фильтром до нуля, — ШТАТНОЕ рабочее состояние: движок стартует ПОЛНОСТЬЮ, без подписок, и ждёт следующего такта. Довод денежный: отказ старта происходит ДО гидратации реестра позиций и до повторного взятия удерживаемых рынков сторожем закрытия, то есть заморозил бы открытые позиции без сопровождения, а резолвнутые — без выкупа.

Свежесть вселенной обновляет только проход с ненулевым счётом ДО фильтра. Пустота ПОСЛЕ фильтра свежесть обновляет — «совпадений нет» это ответ. Сырой ноль свежесть НЕ обновляет: вселенная стареет и гейт universe_max_age_secs со временем закрывает новые покупки. Это отказ в сторону безопасности, а не дефект — сломанный НА ХОДУ набор тегов иначе держал бы вселенную «свежей» вечно. Сырой ноль при этом НЕ считается отказом источника: счётчик подряд идущих неудач говорит о недоступности Gamma, а здесь Gamma ответила.

Сужение не роняет рынок с деньгами. Отсутствие рынка в результате обнаружения — включая отсутствие, сделанное фильтром, — основанием для отписки не является; выселение остаётся строго событийным (закрытие рынка). После ПЕРЕЗАПУСКА отслеживаемое множество стартует пустым, а начальный проход допуска идёт по уже отфильтрованному набору, поэтому «не ронять» само по себе ничего не гарантирует: защищаемые рынки довзимаются в подписку независимо от фильтра, прямым запросом по идентификатору (этот путь фильтру не подчинён). Защищаемое множество — позиции Open/Selling ∪ рынки с ЖИВОЙ заявкой (перечень статусов выводится хранилищем из OrderStatus::occupies_slot(), включая неопределённую отправку). Неналитый BUY позиции не создаёт, а его снятие, перевыставление и учёт налива живут в обработчике рыночных данных, который без подписки не срабатывает. Довзятие выполняется и на старте, и на КАЖДОМ успешном такте; на такте с ОШИБКОЙ обнаружения оно намеренно пропускается (уже подписанные защищаемые рынки при этом остаются подписанными), и следующий успешный такт его повторяет.

Метаданные довзятого рынка беднее пришедших из обнаружения РОВНО на два поля — погодный city-slug и slug события. Поля даты окончания и времени начала наблюдения ПРИСУТСТВУЮТ, поэтому расчёт «вход на довзятом рынке отвалится сам по неразрешимому анкеру» ВЕРЕН лишь для одного источника анкера из трёх и денежной гарантией быть не может.

Быстрый путь листинга ​

Штатный проход находит рынок в среднем через полтакта после появления, то есть в бою — до минуты. Кадр new_market приходит по вебсокету немедленно, на ту же глобальную трансляцию, которую уже слушает сторож закрытия, и содержит идентификатор рынка. Сторож листинга (adapters/market_data/listing_watcher.rs) превращает эти кадры в допуск за время порядка секунды.

Порядок: отбор кадров new_market → накопление идентификаторов окном в одну секунду (гроздь листинга события — 11 внутренних рынков — склеивается в один запрос) → передача закрытого окна ОТДЕЛЬНОЙ задаче-исполнителю → ОДИН пачечный запрос /markets?condition_ids=…&include_tag=true нарезкой по тому же потолку, что у пачечного опроса закрытий → ТЕ ЖЕ экраны обнаружения, что и у штатного прохода (жизненный цикл, категория, тип рынка, преобразование, фильтр названия) → передача найденного координатору обнаружения.

Читатель трансляции никогда не ждёт справочник. Трансляция рыночных кадров глобальна, ограничена 256 слотами и несёт каждый разобранный кадр, включая обновления стакана; пачечный запрос занимает порядка полусекунды (живой замер 2026-08-12: 0.56–0.68 с). Ожидание ответа внутри цикла чтения переполняло бы трансляцию на потоке порядка 400 кадров/с — ровно в волну листинга, когда путь и нужен. Поэтому цикл чтения отдаёт закрытое окно исполнителю через очередь на восемь окон НЕБЛОКИРУЮЩЕЙ передачей: очередь полна ⇒ партия отбрасывается со счётчиком stage="handoff_full", а чтение продолжается. Отброшенную партию подхватит штатный проход.

Второго механизма допуска нет. Координатор проводит партию через ТОТ ЖЕ process_discovered, что и штатный проход, поэтому предикат допуска (вероятность + окно входа) применяется тот же, а уже отслеживаемый рынок получает обновление метаданных вместо второй подписки. Расширить набор рынков быстрый путь не может по построению — он только ускоряет допуск того, что штатный проход допустил бы сам.

Отличие от штатного прохода ровно одно и вынужденное: принадлежность к категории штатный проход получает от сервера (/events?tag_id=<id>), а здесь её сообщает сам ответ (include_tag=true → поле tags, идентификаторы приходят СТРОКАМИ). Рынок без перечня категорий отклоняется: отсутствие доказательства принадлежности — не принадлежность.

Совпадение двух перечней экранов держится не дисциплиной, а проверкой: один корпус рынков прогоняется через ОБА пути, вердикты обязаны совпасть, а перечень допустимых расхождений задан данными и обязан быть исчерпывающим (screen_parity_oracle::both_screen_paths_return_the_same_verdict_on_the_same_corpus). Допустимых расхождений ТРИ, и все — в сторону строгости быстрого пути: строка без перечня категорий, строка без событийной привязки, жизненный цикл по ПЕРВОМУ событию строки.

Отказ экрана расщеплён надвое. «Строка полная, рынок не наш» (stage="discovery_screen") и «строка вернулась недозаполненной» (stage="incomplete_row": нет категорий, нет события, метаданные не преобразовались) — разные причины. Недозаполненная строка НЕ запоминается как искавшаяся: кадр new_market приходит один раз, и запомнить её значило бы закрыть для рынка быстрый путь навсегда по причине, к самому рынку отношения не имеющей. Без этого расщепления вырождение способности (источник перестал проставлять категорию) неотличимо от нормальной работы.

Снимок слоя отбора партия не строит и не затирает. Снимок собирается из ПОЛНОГО набора сиблингов события, а партия — заведомо подмножество, поэтому обогащать её нечем: частичный снимок дал бы неверный ранг, то есть неверное торговое решение. Но и оставить пустым нельзя — продюсер подменяет метаданные целиком, и одно обновление стёрло бы событийный контекст удерживаемой позиции. Правило: обновление с быстрого пути переносит снимок из уже известных метаданных и НЕ выдумывает его там, где прежнего снимка нет. Следствие названо прямо: при ВКЛЮЧЁННОМ слое отбора быстрый путь защищает удерживаемые позиции, но вход не ускоряет — новый рынок без событийного контекста гейт входа отвергает до ближайшего полного прохода.

Партия НЕ считается проходом обнаружения: свежесть вселенной она не обновляет (запрос по перечню идентификаторов вселенную не обходил), границу цикла воронки не ставит и поколение знаменателя покрытия не двигает — прогрев входа обязан дождаться именно полного прохода. Требование живёт в EntryReadiness::fresh_denominator (engine/entry_readiness.rs, переехало без изменений из удалённого entry_warmup_state): знаменатель годится для ветви охвата, только если поколение знаменателя выросло ПОСЛЕ взведения прогрева. Счёт подписанных исходов при этом обновляется: набор действительно вырос.

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

Остановка входов подчиняет и этот путь. Пока входы остановлены, полный проход разрежен в десять раз; быстрый путь в этом состоянии пачечный запрос не выпускает вовсе (счётчик stage="entries_halted"), иначе он молча вернул бы секундный допуск там, где ни одна покупка не пройдёт.

Признак здесь — ИМЕННО операторская остановка (EntryHaltState::entries_blocked, то есть персистентный drain либо эфемерный барьер рестарта), а НЕ вердикт единой точки допуска. Различие намеренное и снятию не подлежит: деградация доступа к бирже и прогрев входа тоже закрывают покупки, но замедлять при них обнаружение нельзя — оно как раз должно оставаться живым, чтобы к моменту открытия входов подписанный набор был свежим. Оба неденежных читателя этого признака — быстрый путь листинга и разрежение переоткрытия — меняют РАСХОД РАБОТЫ, а не решение о заявке.

Наблюдаемость — пять счётчиков pm_engine_listing_*, разобранных в alerts-and-monitoring.md.

Канонический стор когорты события (event-cohort-rank-freshness) ​

CurrentMarketContextStore (engine/event_cohort_store.rs) — единый in-memory стор per-event ревизий полной сырой когорты Gamma-события (EventCohortRevision) и атомарных single-parent ссылок рынка на своего родителя (MarketEventContextRef). Наполняется координатором обнаружения ДО существующего допуска на каждом полном проходе покрытия (все настроенные теги одним вызовом) и на targeted-обходах (гидрация родителя листингового окна, поддержание риска удерживаемых позиций/ордеров во время остановки входов) — единственный писатель. Ранг внутри когорты — уникальный total order (no_price_pip по возрастанию, точная ничья решается позицией в сыром Event.markets[]), в отличие от прежней strategy-local competition-rank формулы с делёными ties.

Стор — единственная власть ранга для directional. Вето входа, resting_entry_rank_wall и rank_deterioration_exit выполняют одно синхронное typed-чтение полной immutable ревизии на каждую логическую оценку. Это чтение фиксирует монотонное время до обращения к стору и возвращает полный RevisionIdentity, канонический total-order rank, цену собственного No и закрытую причину недоступности. Legacy competition-rank и MarketMetadata.selection не управляют ни одним из этих трёх денежных решений; недоступность не подменяется старым снимком. Вход использует то же значение для вето и долговечного entry_probability_rank в аналитике.

PositionSnapshot.probability_rank/probability_rank_no_probability читаются ИСКЛЮЧИТЕЛЬНО из стора и на /seed (bootstrap), и на живом SSE-push — оба канала сошлись на одном источнике: bootstrap читает напрямую, живой push публикует фоновый участник engine::event_cohort_projection::PositionRankProjection (подписчик store.subscribe(), полная перепроекция held-набора при старте и на RecvError::Lagged, публикация только при изменении).

Защитная FSM дополнительно читает стор для каждой открытой позиции на сериализованном секундном Strategy::on_tick, поэтому собственный market-data кадр не является условием живости выхода. rank_exit_current_rank обновляется отдельным sparse-событием той же канонической величиной; явные clear-флаги убирают устаревшее значение из обеих колонок при недоступности. Уже защёлкнутая защитная фаза и её единственный SELL-владелец при этом сохраняются.

Стор освобождает записи: release_unreferenced(coverage, money_refs) вызывается координатором после каждого Complete-коммита полного прохода покрытия, освобождая событие только когда оно отсутствует и в подтверждённом покрытии, и в денежных ссылках (удерживаемые позиции/открытые заявки) — held-набор остаётся union'ом обоих множеств, а не одним покрытием.

Drain разрежает только полный discovery. Targeted refresh денежных ссылок идёт на каждом базовом такте координатора; при штатных 60 секундах это сохраняет 60-секундную свежесть защищаемого события даже при 600-секундном полном проходе. Неизвестный condition_id → Event.id восстанавливается ограниченным пакетным запросом open markets и затем closed markets; неоднозначный, пустой или неполный ответ не создаёт фиктивную связь и повторяется на следующем такте.

Тракт доставки рыночных данных стратегии ​

Раздел нормирован capability market-data-ingestion-capacity (openspec/specs/market-data-ingestion-capacity/spec.md). Он описывает, сколько рыночных событий доходит до стратегии, чем это ограничено и как проверить насыщение. Описание самодостаточно: значения констант приведены здесь, читать исходный код для выполнения процедуры не требуется.

Диспетчер и приоритет ветвей ​

StrategyDriver::run (crates/pm-engine/src/engine/strategy_driver.rs) — один tokio-task на экземпляр стратегии. Внутри biased tokio::select! с ШЕСТЬЮ ветвями; фактический порядок приоритета:

#ВетвьРоль
1shutdown_token.cancelled()выход из цикла
2position_change_rx.recv()учёт: изменение позиции
3order_events_rx.next()учёт: событие ордера
4closure_rx.recv()закрытие рынка
5tick.tick()периодические денежные механизмы
6market_data_rx.recv()снимок рыночных данных

Ограничителя темпа в диспетчере НЕТ. Разделение управления между ветвями обеспечено ТОЛЬКО их порядком: biased опрашивает список строго сверху вниз на КАЖДЫЙ poll и возвращает управление немедленно, как только сработала ветвь, — поэтому непрерывно готовая ветвь рыночных данных не способна отобрать управление ни у одной ветви выше себя. Ни счётчика, ни предиката, ни дополнительного таймера для этого не требуется.

Цикл последователен по построению: следующая итерация не начинается, пока текущий хук не вернул управление (invoke_hook дожидается future хука целиком).

Обратная сторона порядка, принятая осознанно: голодает сама ветвь market_data, пока непрерывно готова любая ветвь выше неё. Направление выбрано намеренно — сначала учёт уже совершённого, потом новые решения (по закрытому рынку решение принимать уже нельзя), — и оно ограничено тем, что ни один вышестоящий источник не является непрерывным потоком.

Канал и возраст снимка ​

Ёмкость канала — MARKET_DATA_CHANNEL_BUFFER = 256 (crates/pm-engine/src/main.rs). Продюсер ставит снимок БЛОКИРУЮЩЕЙ отправкой market_data_tx.send(snap).await: при полной очереди он ЖДЁТ освобождения места, а не отбрасывает снимок.

Существенно, ЧТО именно ждёт в очереди. Продюсер материализует MarketDataSnapshot ДО постановки: перечитывает свежую книгу из MarketStateStore (через OrderbookFeed::snapshot, который проксирует в store), берёт оттуда же окно недавних SELL-принтов, клонирует Arc метаданных, ставит ts по часам. Поэтому содержимое снимка стареет ровно на время ожидания в очереди, и стратегия видит книгу возрастом, равным длине очереди, делённой на темп её опустошения.

Темп опустошения диспетчером БОЛЬШЕ НЕ ОГРАНИЧЕН — он определяется стоимостью хука on_market_data и темпом производства снимков. Измеренные ранее на боевом инстансе e2e_p50 ≈ 3.0 с и e2e_p95 ≈ 4.0 с были прямым следствием снятого ограничителя (256 / 64 = 4.0 с) и после его снятия НЕДЕЙСТВИТЕЛЬНЫ. Новое значение возраста снимка форвард-замером ПОКА НЕ ПОДТВЕРЖДЕНО.

Таймер ветви tick: готовность зависит от уступки планировщику ​

Интервал создан с MissedTickBehavior::Skip. Пропущенные тики НЕ накапливаются и НЕ выдаются пачкой: следующий тик планируется на очередной кратный момент, поэтому деградация частоты ветви tick не оставляет видимого следа отставания — нет ни очереди тиков, ни счётчика пропусков. ГОТОВОГО ПОЛЯ с числом фактических тиков за окно ни одна наблюдаемая поверхность не отдаёт, но сама частота выводится косвенно и без нового кода: on_tick публикует EntryFunnelTick БЕЗУСЛОВНО, по одному кадру на тик (даже когда счётчики пусты), поэтому темп кадров этого типа на потоке SSE и есть темп тиков. Ожидание для directional — около 60 кадров в минуту.

Ветвь tick стоит ВЫШЕ рыночных данных и потому обслуживается сразу, как только стала ГОТОВА. Но, в отличие от канальных ветвей, её готовность появляется не сама по себе: сработавший таймер виден задаче только после того, как рантайм обработает колесо таймеров, а это происходит, когда задача уступает планировщику. Уступка наступает не позже границы кооперативного бюджета задачи (tokio 1.52: Budget::initial() = 128 операций на один poll, src/task/coop/mod.rs) либо раньше — на любом ожидании внутри хука.

Сколько бюджетных окон проходит до того, как рантайм обработает колесо таймеров, решает планировщик, и это НЕ одно окно. Замер (25 прогонов одного и того же кода, предзаполнение 192 / 512 / 1024 снимка): тик срабатывает после 128-го снимка в большинстве прогонов, но наблюдались также 192-й, 256-й, 384-й и 512-й. Поэтому позицию тика в последовательности закреплять нельзя — тест давал бы нестабильные падения; прежняя формулировка «не более ОДНОГО бюджетного окна» этим замером ОПРОВЕРГНУТА.

Закреплено СЛЕДСТВИЕ, которое от планировщика не зависит и имеет денежный смысл: под непрерывно пополняемой очередью снимков ветвь tick держит свой период. Проверено интеграционным тестом strategy_driver_arm_priority::tick_keeps_its_cadence_under_a_continuously_refilled_snapshot_queue: при интервале 50 мс пять тиков укладываются в 200 мс, пока диспетчер обрабатывает 21 000 – 53 000 снимков. Контрфакт того же теста (ветвь tick перенесена НИЖЕ рыночных данных) даёт 9.4 / 23.6 / 26.9 с на те же пять тиков — деградация периода в 47-134 раза. Три теста ПОРЯДКА этот контрфакт НЕ ловят: они предзаполняют конечную порцию снимков, а конечная порция кончается, после чего тик срабатывает независимо от своего места в списке.

Инвариант приоритета — рыночные данные обслуживаются последними ​

Инвариант: при непрерывно готовой ветви рыночных данных каждая ветвь, объявленная выше неё, получает управление в пределах ОДНОГО хука on_market_data, и для продолжения доставки снимков не требуется ни одно постороннее событие.

Для канальных ветвей (position_change, order_events, closure) граница точна: положенное в канал сообщение видно уже на следующем опросе ветви. Для ветви tick границы в снимках НЕТ — её задаёт планировщик (предыдущий раздел); закреплён вместо неё период тика.

Формулировки «не более N снимков в секунду» и «не более N снимков на срабатывание сбрасывающей ветви» БОЛЬШЕ НЕ ПРИМЕНИМЫ: механизм, который их порождал (счётчик market_data_streak, предикат allow_market_data, константа MAX_MARKET_DATA_STREAK = 64), удалён. Верхней границы темпа доставки со стороны диспетчера нет — темп задают производство снимков и стоимость хука.

Что живёт в ветви tick ​

Ветвь tick не «служебная», а денежная. В ней: дренаж счётчиков воронки, часовая INFO-сводка, публикация EntryFunnelTick, process_due_redeems, sweep watchlist WAF, аварийная ликвидация портфеля run_force_exit, операторская продажа run_manual_exit, safety-net стоп-лосса и таймеры перевыставления exit-целей.

Поэтому её приоритет выше рыночных данных — не удобство, а требование: задержка этих механизмов молчалива (раздел про MissedTickBehavior::Skip), и заметить её было бы нечем.

Стоимость одного вызова хука on_market_data под нагрузкой с реальными отправками ордеров измерением ПО-ПРЕЖНЕМУ не охвачена. Перцентили, снятые в окне с нулевым числом отправок (окно exec пусто), характеризуют холостой ход: invoke_hook дожидается хук целиком, а хук на пути входа может выполнить чтение из БД, полную транзакцию записи ордера с fsync и сетевой POST в CLOB; порог SLOW_HOOK_THRESHOLD (1 с) только логирует и ничего не ограничивает. После снятия ограничителя эта величина стала определяющей: верхнюю границу темпа доставки задаёт теперь она, а не константа диспетчера.

Приоритизации рынков нет ​

Механизма приоритизации рынков не существует: StreamMap в продюсере выбирает готовый поток со случайного стартового индекса, сделать один рынок «важнее» другого нечем. Порядок выдачи внутри одной подписки задаёт CoalescingQueue (crates/pm-engine/src/adapters/orderbook/coalesce.rs): слот токена встаёт в хвост очереди при ПЕРВОМ появлении токена среди ожидающих, последующие обновления того же токена перезаписывают значение, не меняя позицию (FIFO по первому появлению — дисциплина, близкая к круговому обходу активных токенов).

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

markets_evaluated — темп оценок, а не охват сканирования ​

Счётчик markets_evaluated инкрементируется в DirectionalStrategy::on_market_data ДО ветвления по entry_flow. Значит он считает ВСЕ доставленные снимки исходов (token_id) — включая повторные снимки одного и того же condition_id и исходы с уже открытой позицией.

Отсюда правило подписи: величина, считающая повторные снимки, НЕ подаётся оператору как охват сканирования. Свойство не зависит от снятого ограничителя: даже без всякой верхней границы темпа один активный рынок способен дать сотни оценок за тик, а рынок без обновлений книги — ни одной, поэтому число оценок и число просмотренных рынков остаются несвязанными величинами. markets_evaluated уходит на провод полем strategy_scanned тика метрик и показывается карточкой «Strategy scanned» — подпись и пояснение обязаны называть её темпом ОЦЕНОК за тик. Величина проходит через разделяемый last-value-gauge (пишет ветвь tick стратегии, читает независимый по частоте насос метрик), поэтому может отставать на один тик: подпись не вправе утверждать, что показано значение именно последнего тика.

Любая метрика уникальных обработанных исходов публикуется вместе со знаменателем — числом исходов, отслеживаемых подпиской на момент измерения. Без знаменателя доля неинтерпретируема: числитель зависит от того, по каким исходам пришли обновления книги, а не от размера подписки, поэтому рост числа отслеживаемых рынков может уронить долю, не отражая никакого ухудшения обработки. Знаменатель нормирован смежным изменением entry-funnel-domain-rollup (его задача 6.4: при отсутствии источника знаменателя поля покрытия не публикуются вовсе — ни нуля, ни выдуманного знаменателя).

Ревизия операторских поверхностей на формулировки «охват сканирования»: часовая INFO-сводка (hourly funnel summary) выводит величину под её собственным именем markets_evaluated и охвата не заявляет; панель воронки уже подписана «повторные тики одного condition_id являются отдельными оценками, не уникальными рынками». Не приведены в соответствие две подписи карточек на главной странице дашборда — пояснение карточки «Strategy scanned» и подпись карточки времени решения; их правка идёт отдельной задачей после применения смежного изменения entry-funnel-domain-rollup, которое переписывает тот же файл.

Процедура воспроизводимого замера насыщения ​

Каждый шаг называет поле, поверхность и критерий прохождения. Обращение к исходному коду не требуется.

  1. Возраст снимка.

    ПРИМЕНИМОСТЬ ШАГА — проверяется ДО того, как взято первое число. Трактов доставки два, и этот шаг описывает ОДНОШАРДОВЫЙ, где снимки идут через канал диспетчера. В шардированном режиме (PM_ENGINE_MARKET_DATA_SHARDS) канала нет вовсе: снимок оценивается на месте (ShardEvaluator::evaluate), очередь не участвует, и приведённая ниже арифметика «ёмкость ÷ темп доставки» к величине НЕ применима — как и сам вывод о насыщении очереди. Режим фиксируется до замера, а не после; на шардированном тракте шаг пропускается, а не пересчитывается.

    Величина: поле e2e_p95_us груза GET /api/local/seed либо того же поля живого кадра metrics_tick в потоке /api/local/stream. Карточек задержек на главной странице БОЛЬШЕ НЕТ — секция снята (observability-surface-split), поэтому поверхностью служит провод, а не экран. Критерий насыщения (одношардовый тракт): значение около MARKET_DATA_CHANNEL_BUFFER ÷ темп доставки, то есть около 256 ÷ (значение шага 2 × частота тика) секунд. При свободной очереди то же поле близко к нулю. Ограничителя в диспетчере нет, поэтому непустая очередь означает, что не успевает ПОТРЕБИТЕЛЬ (стоимость хука), а не что сработала константа.

    Оговорки величины, обязательные к упоминанию рядом с любым числом. Дискретность миллисекундная: отметка снимка — стенные миллисекунды, и при свободной очереди величина близка к нулю именно из-за неё, а не потому, что ожидания нет. Сегмент «WS-сокет → продюсер» в неё НЕ входит, поэтому она систематически занижена относительно измерения, привязанного к сокету. Отметка ставится ДО отправки в канал, поэтому в величину входит и ожидание продюсера на отправке; отдельного замера ПРЕБЫВАНИЯ в очереди сегодня нет — его заводит карточка latency-metrics-export-form. Режим инстанса на момент замера (в частности, состояние drain) обязан быть зафиксирован: нижняя часть воронки в drain нерепрезентативна. Числа возраста снимка, снятые при действовавшем ограничителе диспетчера, НЕДЕЙСТВИТЕЛЬНЫ — они описывают механизм, которого в коде нет.

  2. Темп доставки за тик. Карточка «Strategy scanned» на главной странице pm-engine-dashboard: её значение — это markets_evaluated последнего опубликованного тика воронки (на провод уходит полем strategy_scanned), sparkline — история значений. Критерий: устойчиво ОДИНАКОВОЕ значение на подряд идущих точках больше не ожидается и, если наблюдается, означает появление нового ограничителя, а не норму. Панель «Воронка оценок входа» для этого шага НЕ годится: она агрегирует окно 60 с, и в строке «из N оценок» показана СУММА по всем тикам окна, а не значение одного тика; сверять её можно только с произведением значения этого шага на число тиков окна (панель выводит его меткой «N тиков»).

  3. Темп доставки за час. Часовая INFO-сводка hourly funnel summary в логах движка (интервал FUNNEL_SUMMARY_LOG_INTERVAL_MS = 3 600 000 мс), поле markets_evaluated. Критерий: величина сверяется с произведением значения шага 2 на число тиков за час и расходится с ним ровно настолько, насколько неравномерен поток обновлений книги. Верхней границы, задаваемой диспетчером, у величины нет; при этом фактическое число тиков может быть меньше номинального из-за MissedTickBehavior::Skip и сегодня не наблюдаемо, поэтому произведение — ориентир, а не порог.

  4. Входящий поток. Карточки «WS Market — цены» (поле price_events) и «WS Market — стакан» (поле book_events) на главной странице pm-engine-dashboard: значение — за последнюю минуту, sparkline — в секунду. Кадры считаются ДО коалесцирования, поэтому это именно входящий поток, а не доставленный. Критерий: отношение суммы этих двух величин к темпу снимков из шага 2 показывает величину прореживания — но НЕ его причину (см. следующий подраздел). Перед делением обе величины ОБЯЗАНЫ быть приведены к одной базе времени — к секунде: значение карточки делится на 60 (оно за минуту), а значение шага 2 умножается на частоту тика стратегии (при секундном тике — на единицу, то есть остаётся как есть). Деление минутной величины на потиковую даёт бессмысленное число. Единицы тоже разные и смешивать их нельзя: карточки считают кадры по всем подпискам, markets_evaluated — снимки, доставленные одному хуку.

  5. Режим инстанса. Обязательная часть публикации любых чисел: drain / live / probe, плюс entry_halt_reason, если движок в операторском drain-режиме. Критерий: без этой пометки числа не публикуются. В drain-режиме верхняя часть воронки (темп доставки, задержки) от режима не зависит и репрезентативна, а нижняя (эмиссия входов, окно exec, число отправок) — НЕТ, и окно exec пусто именно поэтому.

    Сюда же входит режим прогрева (warmup_active на кадре воронки, ступень warmup, счётчик blocked_warmup). Прогрев — ВТОРОЙ последовательный механизм после единой точки допуска, и его готовность НЕ сообщается оповещением, а ВЫВОДИТСЯ ИЗ ПОКОЛЕНИЯ ОТКРЫТИЯ рубильника: готов он только тогда, когда взведён под ТЕКУЩЕЕ поколение И охват книг набран. Взведение ленивое — при первом вопросе о готовности под новым поколением. Поэтому у прогрева нет отдельного перечня триггеров: он начинается заново на КАЖДОМ переходе «есть причины, закрывающие новые входы → нет», чем бы этот переход ни был вызван (достижение рабочего состояния на запуске, resume оператора, снятие причины ExchangeAccess при возврате доступа к бирже). Ни монитор здоровья, ни кто-либо ещё прогрев не перевзводит: перевзведение снаружи вызвать нечем.

    Прогрев подавляет ТОЛЬКО генерацию сигналов на вход, пока не выполнено условие готовности по данным; вопрос о готовности задаётся ИСКЛЮЧИТЕЛЬНО намерениям, открывающим позицию. Следствие для измерений ровно то же, что у drain: верхняя часть воронки репрезентативна, нижняя — нет. Оговорка обязательна, потому что «эмиссия входов равна нулю» в прогреве означает штатное ожидание данных, а не отсутствие кандидатов. Защитные пути — стоп-лосс, выход по цели, переоценка цели, аварийная ликвидация, адресная продажа, реакция на закрытие рынка, погашение — в прогреве работают полностью, поэтому их числа репрезентативны и в нём.

    Практическое следствие для чтения чисел ПОСЛЕ снятия остановки: вход открывается не на первом снимке после resume, а по охвату книг либо по запасному пределу. Механизм и его пороги — risk-and-emergency.md §«Прогрев входа».

    Третья часть того же режима — разрежение обнаружения (discovery_sparse_interval_secs на кадре воронки). Пока входы остановлены, полный проход обнаружения выполняется в десять раз реже настроенной каденции (HALTED_DISCOVERY_SLOWDOWN, задокументированная константа, не настройка). Что это меняет для чтения чисел: знаменатель охвата продолжает жить, но новые рынки попадают в подписанный набор позже — до одного разреженного интервала. Что НЕ меняет и на что нельзя ссылаться как на следствие разрежения: предикат допуска остался прежним, поэтому разность «вселенная обнаружения минус подписанные» разрежением НЕ объясняется вовсе — её величину задаёт окно входа (entry_window_seconds, в боевой строке на 31.07.2026 — 45 часов, то есть разность мала). Подписки удерживаемых позиций разрежение не затрагивает вовсе: их довзятие идёт в обычном темпе, а снятия подписки не происходит ни при каких условиях — единственный путь отписки остаётся закрытие рынка.

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

    Срез по циклу обнаружения (discovery_cycle) в разреженном режиме покрывает соответственно более длинный интервал; boundary_completed остаётся true, потому что рубеж по-прежнему один проход, а не склейка. Панель подписывает срез ИЗМЕРЕННОЙ длительностью, поэтому подпись остаётся верной без правок.

Оговорки полей, которые приводятся ВМЕСТЕ с числами:

  • e2e имеет миллисекундную дискретность: MarketDataSnapshot::ts — стенные миллисекунды, проставленные в момент эмиссии продюсером, поэтому выборка ступенчата по 1000 мкс. При лёгкой нагрузке ожидание в очереди субмиллисекундное и поле читается как ноль.
  • e2e НЕ включает сегмент «WS-сокет → продюсер»: ts ставится уже после обработки фида. Величина систематически занижена относительно измерений, привязанных к сокету, и с ними НЕ сопоставима.
  • e2e_processing — чистое время хука on_market_data без ожидания в очереди; decision — длительность периодического хука on_tick. Это разные хуки, сравнивать их как «почти один и тот же промежуток» нельзя.
  • Пустое окно exec означает, что отправок не было; при пустом exec верхняя оценка стоимости одного вызова хука измерением не подтверждена.
  • ★Окно перцентилей задано числом ОБРАЗЦОВ, а не временем: DEFAULT_MAX_SAMPLES = 1000 (crates/pm-engine/src/observability/latency.rs, проводка — main.rs). Поэтому при росте темпа доставки то же окно охватывает во столько же раз более короткий срез настенного времени, а хвост опрашивается во столько же раз чаще: p99 может ВЫРАСТИ как артефакт выборки, без всякого ухудшения обработки. Сравнивать p99 до и после изменения темпа НЕЛЬЗЯ. Честная замена — доля обработок дороже фиксированного порога (например, дороже 1 мс) либо окно, зафиксированное по времени, а не по числу образцов. Оговорка критична именно для форвард-замера после снятия ограничителя (строка TD-MD-CEILING-3): темп доставки там меняется кратно.

Чего сегодня измерением не разделить ​

До стратегии доходит малая доля входящих кадров, и у этого прореживания ДВА источника: latest-wins коалесцирование в CoalescingQueue и пропускная способность потребителя, то есть стоимость хука on_market_data (константы диспетчера среди источников больше нет). Источники НЕ независимы, они связаны противодавлением: полная очередь market_data заставляет продюсера ждать на отправке → заполняется его входной канал (DOWNSTREAM_BUFFER в clob_ws.rs) → выдающая ветвь forwarder'а голодает под biased → слоты живут в очереди дольше → больше обновлений одного токена схлопывается в один слот. Интенсивность коалесцирования — функция скорости потребителя, а не фоновая константа тракта.

Практическое следствие: атрибуция вычитанием НЕГОДНА в принципе — нельзя измерить одну величину и объявить остаток вкладом второй. Нужны два независимых измерения: глубина очереди market_data и доля схлопнутых обновлений. Оба ЕСТЬ на ряде pm_engine_* с коммита 60ed6e00: pm_engine_market_data_queue_depth / _capacity (MarketDataQueueProbe::publish_gauges) и pm_engine_market_data_coalesced_dropped_total (clob_ws.rs, инкремент ровно в той единственной точке, где кадр исчезает). Счётчики кадров forwarder'а таким свидетельством по-прежнему не являются — они считают кадры ДО коалесцирования. Что осталось незакрытым: замер глубины МГНОВЕННЫЙ и снимается раз в секунду (DEFAULT_METRICS_TICK_INTERVAL_MS = 1000), а после снятия ограничителя очередь нормально пуста и наполняется всплесками, поэтому секундная выборка почти всегда покажет ноль; нужен high-water-mark рядом с мгновенным значением. На SSE и дашборде обеих величин нет вовсе — панель «поток данных» показывает по ним null и это честно. Закрытие этого пробела — предмет отдельного изменения, зафиксировано в tech-debt-backlog (TD-MD-CEILING-1..3).

Важная асимметрия: MarketStateStore видит ВСЁ — apply_event вызывается на каждом событии в forwarder'е, до коалесцирования. Потеря схлопнутых обновлений касается ТОЛЬКО потребителя снимков, а не состояния книги. Поэтому safety-net стоп-лосса в on_tick, читающий свежий стакан через ctx.book_snapshot, от темпа доставки снимков не зависит вовсе; темп доставки влияет только на ПЕРВОЕ обнаружение пробоя порога в on_market_data, но не на путь капитуляции.

Кадр стоп-подтверждения: свидетельства, покрытие, FSM ​

Правила самого стопа — в strategies/directional-harvester.md; здесь только то, что принадлежит ДВИЖКУ, а не стратегии.

Выделенный индекс торговых свидетельств. Свидетельства стопа лежат НЕ в кольце событий токена (оно count-ограничено и делится с book/price-трафиком), а в отдельном упорядоченном индексе MarketState.trade_evidence с ключом (event_ts_ms, ingest_sequence). Потолка по числу записей у него нет вовсе: книжный шум не имеет права вытеснить свидетеля стопа. Освобождение ровно одно — untrack токена. Причинное окно стоит O(log n + k) от числа возвращаемых записей и не требует пост-сортировки, а событие с непорядковым ts_ms встаёт на своё место по ключу, а не в хвост. Наблюдаемость удержания — перепись структур (trade_evidence_total плюс оценка байт, считаемая на чтении из этой же величины) и метрика возраста самого старого требуемого интервала (pm_engine_stop_evidence_oldest_required_age_seconds).

ОДИН запрос окна на кадр. MarketStateStore::stop_trade_window(token, prints_since_ms, flow_since_ms, threshold) отдаёт принты причинного интервала, покрытие КАЖДОГО источника и итоги потока из ОДНОГО снимка состояния под одним read-lock'ом. Границы ДВЕ и это часть контракта: свидетели корроборации читаются с arm_start − lookback (они опровергают ОДИН принт и могут предшествовать взводу), а доля потока считается ровно с arm_start (она характеризует сам провал). flow_since_ms = None означает «поток не читался вовсе» и отличается от «измерен и нулевой». Полной копии MarketState стоп-кадр не делает: копия тащила бы с собой и кольцо событий, и карту price-level lifetimes, и весь неограниченный индекс свидетельств — на каждом тике взведённой позиции, ровно когда они самые большие.

Покрытие принадлежит ПОДПИСКЕ, а не потоку данных. Контракт источника — { epoch, continuous_since, opened_at, last_gap_reason }. Эпоху открывает ПЕРВЫЙ ДОСТАВЛЕННЫЙ КАДР подписки (confirm_subscription), а не Ok подписного вызова: подтверждение из ветки backoff не доказывает, что сокет существует, и покрытие тёмного интервала читалось бы как «полное и пустое» ⇒ согласие ⇒ подтверждение стопа по данным, которых никто не наблюдал. Разрывы пишет боевой код там, где меняется жизненный цикл подписки: переподключение рыночного WS, переполнение локального канала forwarder'а, тишина источника. Отсутствие объекта покрытия — НЕПОЛНОТА, а не тривиально покрытое пустое окно. У прямого и синтетического источников эпохи НЕЗАВИСИМЫ (при включённом RTDS прямой — это соединение RTDS, комплемент остаётся анонимным), поэтому и логи, и диагностическая строка называют виновника поимённо. Каждый разрыв виден на /metrics счётчиком pm_engine_stop_trade_source_gaps_total с ограниченным набором меток (источник × причина; token_id меткой не является — это бомба кардинальности).

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

Идентичность свидетельства — явная, ненулевая и совместимая с проводом.TradeIngestId — перечисление из двух вариантов, Legacy и Ingested(NonZeroU64); номера выдаёт ОДИН общий счётчик, который держит MarketStateStore и отдаёт actor-буферу RTDS, поэтому идентичности двух швов сравнимы без спец-случая по источнику. Порядка на типе НЕТ намеренно: у записи без идентичности нет позиции в порядке ввода, и выдумывать её значило бы повторить ту же ложь под новым именем. На проводе Ingested(n) — голое число под прежним именем поля, Legacy — ОТСУТСТВИЕ поля; чтение терпимо (нет поля, null и 0 читаются как Legacy).

Гейт перекоса потока — fail-OPEN, и это НЕ симметрия с корроборацией. Корроборация АВТОРИЗУЕТ стоп, поэтому недоказанный интервал не имеет права его авторизовать (fail-closed). Гейт потока только ОТМЕНЯЕТ уже подтверждённый стоп, поэтому недоказанный интервал не имеет права его отменять (fail-open): иначе «не смогли измерить покупательский поток» читалось бы как «значит он покупательский» — то же использование отсутствия данных как доказательства, только направленное в другую сторону. Вето ровно одно — ИЗМЕРЕННЫЙ полный покупательский поток.

Observability layer ​

Реализован в Phase K (5 atomic sub-phases). Разделён на два независимых канала + один direct Telegram channel — структурное разделение чтобы operations metrics никогда не разделяли transport с lifecycle events для дашборда.

Канал 1 — operations metrics → Prometheus → Alertmanager → Telegram ​

  • Источник: EventBus (counter taps в EngineMetricsTap) + lifecycle FSM gauges.
  • Transport: Prometheus text exposition на /metrics HTTP endpoint (default 127.0.0.1:31340, override PM_ENGINE_METRICS_BIND).
  • Consumer: внешний Prometheus scrape + Alertmanager rules → Telegram bot для critical alerts.
  • Use-case: oncall response, infrastructure monitoring, rate-based alerts (OrderRejected spike, IntentSuppressed spike, terminal redeem failures, scrape-down).

Канал 2 — lifecycle events → SSE → дашборд ​

  • Источник: EventBus (BotEvent поток) через wire/ мост к pm-bot-events::EventEnvelope (см. wire-contract.md).
  • Transport: SSE. Движок хостит собственный SSE-сервер (crates/pm-engine/src/sse): публикует envelope'ы, отдаёт их подписчикам мультиплексным /api/local/stream + seed /api/local/seed; pm-engine-dashboard проксирует поток на SPA. Opt-in PM_ENGINE_SSE_ENABLED (default OFF), bind PM_ENGINE_SSE_BIND (default 127.0.0.1:31342; вне loopback обязателен PM_ENGINE_SSE_TOKEN, иначе fail-fast).
  • Consumer: pm-engine-dashboard SPA — compact 2-3 widget system status display + lifecycle event timeline.
  • Use-case: operator situational awareness — что bot делает прямо сейчас, без задержки 15s scrape.

Display-only исключение в этом канале — quote_tick. Отдельная задача QuoteTickPublisher читает общий market-event tap, но не стоит в strategy hot path. Для каждого удерживаемого токена она публикует не чаще одного кадра за фиксированные 500 мс (immediate sparse + latest-wins trailing), подавляет неизменный BBO и удаляет REST/cadence state при terminal position. Свежая книга стратегии не коалесцируется. Наблюдаемость ограничения: pm_engine_quote_ticks_coalesced_total и pm_engine_quote_ticks_unchanged_suppressed_total.

Direct Telegram channel — per-event notifications ​

  • Модель: per-event уведомления через engine_event_notifier (crates/pm-engine/src/observability/engine_event_notifier.rs). Периодический hourly/daily digest-scheduler (K4) УДАЛЁН в пользу событийных уведомлений (см. observability/mod.rs) — PM_ENGINE_DIGEST_* env больше не читается.
  • Transport: TelegramSender направляет HTTP POST на api.telegram.org/bot<token>/sendMessage напрямую.
  • Конфигурация: BOT_TG_* (токен/chat/включение) — единственный живой Telegram-env.
  • Use-case: passive updates без активного monitoring, работает без Alertmanager (внешний Prometheus/Alertmanager стек не развёрнут — future).
  • Строка рынка — кликабельная ссылка на страницу события Polymarket (https://polymarket.com/event/<slug>), собранная из того же slug, что даёт подпись. Подпись экранируется полным экранизатором MarkdownV2, адрес — НЕТ: внутри (…) Telegram экранирует ровно ) и \, а лишние слэши перед - и . порвали бы ссылку. Пустой slug (вход опередил гидратацию Gamma) ⇒ ссылки нет, остаётся запасная подпись [cid:…].
  • Ранг корзины (· rank N) — rank_exit_states.last_rank по token_id через PersistencePort::latest_rank_for_token; читается и по терминализованным строкам, потому что уведомление о закрытии печатает ранг на момент закрытия. Состояние ранга заводится тиком стратегии, а не филлом (на боевых данных — через 8–108 мс после открытия), поэтому на открытии чтение повторяется один раз через 250 мс. Ранг неизвестен ⇒ строка идёт БЕЗ него; подставлять порог или ранг решения запрещено — это другие величины.

In-process AlertEngine ​

Параллельно с Prometheus path: EngineMetricsTap подписан на EventBus и для конкретных событий (LifecycleHalted → Critical; PositionRedeemFailed { terminal: true } → Error) немедленно вызывает TelegramSink без ожидания Prometheus tick. Гарантия: на halt дашборд блокируется state'ом + Telegram получает сообщение в течение секунд, даже если Alertmanager недоступен.

Cross-channel coupling: PmEngineHalted Alertmanager rule существует как резерв, если AlertEngine не успел дойти до сетевого вызова до process exit. Дублирование терпимо — оператор предпочитает two messages над zero.

Что НЕ через SSE ​

Operations metrics (latency histograms, counters) НЕ идут через SSE. SSE зарезервирован за discrete lifecycle events (SignalEmitted, OrderPlaced, Fill, LifecycleStateChanged, redeem events, и т.п.). Histogram aggregation, scrape-based alerts, infrastructure observability — Prometheus pipeline.

Это разделение зафиксировано как архитектурное правило: попытка вкатить counter/histogram через SSE = code-review block. Дашборд не должен зависеть от Prometheus, monitoring не должен зависеть от дашборда.

Операционные процедуры (Telegram bot setup, alert rules, scrape config) — ../operations/alerts-and-monitoring.md раздел «pm-engine observability (Phase K)».

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

  • Per ADR-007 (ACCEPTED) — 4-layer KISS: прежняя структура pm-bot (domain/application/io/persistence) сохраняла идею слоёв, но pm-engine добавил явную Hexagonal-границу через port-трейты, чтобы исключить прямую зависимость domain от Polymarket SDK и SQLite. Слой application старого pm-bot растворён в engine/ (router + registries) и lifecycle/.
  • Per ADR-010 (IMPLEMENTED через unify) — Unified OrderClient: контракт расширен до событийного потока (events()); production имеет единственную live-реализацию, test-only doubles проверяют границу без моделирования исполнения.
  • Per ADR-022 (ACCEPTED) — Unified OrderbookStore: переиспользуется как есть через port OrderbookFeed.
  • Per ADR-029 (ACCEPTED) — Unified PositionRegistry: усиливается через single-writer requirement и расширяется на orders и signals (4 архитектурных гейт-правила вместо одного).
  • Per ADR-009 (СНЯТ) — standalone capture/replay: операторская запись сырого рынка и её offline-потребители удалены; ретроспектива строится по фактической телеметрии live/probe с явным учётом разрывов доставки.
  • Per ADR-031 (ACCEPTED) — risk engine kill switch: kill_switch_state переехал в выделенный EmergencyGuard компонент (EMERGENCY-GUARD слой по якорю A7, не RISK-LIMIT). С 2026-08-25 (unified-trading-gate-and-entry-readiness) EmergencyGuard — ВЛАДЕЛЕЦ причины, а состояние стоит в TradingGate; решение о допуске принимает рубильник.
  • Per openspec unified-trading-gate-and-entry-readiness (D1–D7, 2026-08-25) — единая точка допуска вместо четырёх независимых предикатов: закрытый перечень причин остановки, область действия как ЗНАЧЕНИЕ причины, один ответ денежному пути и прогрев входа как второй последовательный механизм с готовностью, выводимой из поколения открытия. Отвергнуто: параметризованный «гейт с идентификатором» (убил бы исчерпывающий разбор, и пятая причина молча унаследовала бы разрешение) и взведение прогрева в момент открытия (вернуло бы вызов из рубильника в прогрев — ровно ту связанность, которую работа снимает).
  • Per design D1–D10 (unify-orders-positions-signals-events/design.md) — выбор EventBus = in-process MPSC + outbox (отвергнуты Redis/Kafka как лишняя инфра для single-node); процесс-лок OS-level (отвергнут вариант «без лока» — риск mixed-mode на одной БД); режим = свойство БД целиком (отвергнут per-row флаг — даёт скрытую сложность и нарушает A10).

Cross-references ​