Skip to content

Целевая модель защиты: RISK-LIMITS, EMERGENCY-GUARD, ENTRY-POLICY/EXECUTION ​

Уровень: target. Источник правды для трёх категорий защит pm-engine. Противоречащие документы (ADR-017 EV cap deprecated, любые упоминания «risk-checks» как единого списка, kill switch как RISK-LIMIT) подлежат удалению во второй итерации.

Контекст и scope ​

Что покрывает. Жёсткое разнесение трёх классов защит по якорю A7. Описание ровно 3 canonical RISK-LIMITS. Описание ЕДИНОЙ ТОЧКИ ДОПУСКА (TradingGate) с закрытым перечнем из четырёх причин остановки, чья область действия — собственное значение причины. Описание EMERGENCY-GUARD как владельца двух из этих причин. Описание прогрева входа как ВТОРОГО последовательного механизма после допуска. Описание ENTRY-POLICY / EXECUTION (debounce, entry-window, stop-loss exit) как части стратегии. Где применяется каждая категория, в каком reason'е публикуется rejection.

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

  • Трейтовые сигнатуры StrategyContext хелперов — см. extension-points.md.
  • Где живут knobs (всё в config.db) — см. storage.md и memory feedback_strategy_config_via_ui.
  • SSE-канал, через который reject выходит наружу — см. wire-contract.md.

Якорь ​

project_canonical_risk_limits (ровно 3), project_canonical_vision A7 + A10, feedback_no_hidden_logic (RULE-NEW-01). Источник: openspec spec order-router-and-sdk + design «Enforcement layers».

Три категории защит ​

«risk-checks» как единый список не существует. Существуют три категории, и их нельзя сливать ни в общий список, ни в общий enforcement point.

КатегорияЧто защищаетГде enforce'итсяReject reason variants
RISK-LIMITРазмер риска отдельной позиции / совокупный бюджетOrderRouter в Engine`RiskLimit(SharesPerMarket
EMERGENCY-GUARDНакопленный PnL / системное состояниеTradingGate — единая точка допуска в OrderRouter::allocate, ВЫШЕ трёх лимитов. EmergencyGuard свою причину только ВЗВОДИТKillSwitch, LifecycleHalt
ENTRY-POLICY / EXECUTIONКогда и по какой цене эмитить intentСтратегия через StrategyContext helpers(стратегия просто не эмитит intent; нет reject в OrderRouter)
SUBMIT-INTEGRITYЧто на wire ушёл ровно авторизованный intent без scale/conversion driftOrderRouter capability + финальная граница live adapterSubmitAuthorizationViolation(authorization_mismatch|wire_value_mismatch|invalid_value)

Architectural rule: ни один reject reason из колонки 4 не должен появляться в чужой колонке. Держит его КОНСТРУКЦИЯ ТИПОВ, а не проверка исходников: RiskLimit(_) строится только внутри проверок лимитов в OrderRouter, а KillSwitch / LifecycleHalt не строятся нигде, кроме преобразования взведённой причины рубильника в отказ резервации (ReservationDenied::from_stop, order_router.rs). Каждый вид причины несёт свой ключ домена таксономии как СОБСТВЕННОЕ значение (StopKind::reject_domain_key), поэтому «взять чужой код» означает изменить объявление вида, а не написать строку в другом файле. Автоматической проверки этого правила в воротах НЕТ — если она понадобится, это отдельная работа.

Единая точка допуска — TradingGate ​

Остановка торговли — ОДИН механизм с ОДНИМ вопросом и ОДНИМ ответом, а не набор предикатов, чьи ответы вызывающий обязан согласовать между собой. Реализация: crates/pm-engine/src/engine/trading_gate.rs; способность trading-gate-admission (openspec changes/unified-trading-gate-and-entry-readiness/specs/trading-gate-admission/spec.md).

Рубильник ничего не детектирует. У него нет часов, порогов, окон, проб и ввода-вывода: его состояние меняется только когда ВЛАДЕЛЕЦ причины её взводит (raise) или снимает (clear). Денежный путь задаёт один вопрос (admit) и получает Admission::Allowed либо Admission::Blocked(StopReason).

Четыре причины — ЗАКРЫТЫЙ перечень ​

Причины «прочее» или «неуточнённая» не существует. Вид причины (StopKind) несёт как СОБСТВЕННЫЕ значения три вещи: область действия, место в старшинстве и ключ домена долговечных кодов отказа. Ни у одной из трёх нет умолчания.

ПричинаОбласть действияВладелец — кто ВЗВОДИТ и СНИМАЕТWire-reason отказа
Drainтолько новые входыEntryHaltState — персистентная остановка входов (шесть источников, перечислены в §«Единая машинерия перехода») плюс эфемерный барьер рестартаDrainMode
LifecycleHaltВСЕ заявки, включая выходыEmergencyGuard — явная остановка ИЛИ любое состояние жизненного цикла, отличное от рабочегоLifecycleHalt
PnlKillSwitchтолько новые входыEmergencyGuard — activate_kill_switch (боевого взводящего НЕТ, см. §Слой 2)KillSwitch
ExchangeAccessтолько новые входыExchangeHealthMonitor — детектор доступа к CLOBExchangeDegraded

Область действия — ЗНАЧЕНИЕ, а не место проверки. До этой работы «остановка жизненного цикла закрывает и выходы» означало «её строка стоит ВЫШЕ раннего возврата для Side::Sell в OrderRouter::allocate», а «drain выходы не закрывает» — «его строка стоит ниже». Проверить такое нельзя, и пятая причина была бы угадыванием, куда вписать строку. Теперь оба факта — значения StopScope (NewEntries / AllOrders), вопрос задаётся ОДИН раз и ВЫШЕ обхода Side::Sell, а действие строится из стороны заявки: BUY = новый вход, SELL = выход, независимо от того, кто намерение породил. Значения областей взяты из ФАКТИЧЕСКОГО поведения на 2026-08-25 и заново не назначались.

Старшинство, а не конъюнкция. Несколько взведённых причин — обычное состояние, и admit называет РОВНО ОДНУ: сначала отсеиваются причины, чья область данное действие не покрывает (такая причина в ответе не участвует вовсе), затем среди оставшихся берётся старшая по объявленному порядку Drain → LifecycleHalt → PnlKillSwitch → ExchangeAccess. Слоты причин независимы: снятие одной другую не снимает и не ослабляет, поэтому вход остаётся закрытым, пока стоит хотя бы одна покрывающая причина.

Пятая причина ломает сборку. Виды порождаются одним списком (declare_stop_kinds!), а каждый разбор причины на денежном пути и в наблюдаемости — исчерпывающий match без _-ветви. Новый вид невозможно завести, не ответив за его область, старшинство, долговечный код и ступень воронки.

Поколение открытия ​

open_epoch — монотонный счётчик, растущий ровно на единицу на переходе «взведена хотя бы одна причина, закрывающая новые входы» → «не взведено ни одной». Он выводится из набора взведённых причин, а не из доставки оповещения, и верен при нуле подписчиков. Снятие невзведённой причины поколение НЕ двигает. Запуск процесса начинается с уже взведённой LifecycleHalt (TradingGate::at_startup), поэтому достижение рабочего состояния — это первое открытие, и оно растит поколение как любое другое.

Поколение — ЕДИНСТВЕННАЯ связь рубильника с прогревом входа (см. ниже), и связь эта — чтение, а не вызов. Рассылка переходов (subscribe, произвольное число подписчиков) существует для наблюдаемости и в правильность денежного пути не входит.

Где задаётся вопрос — ровно две точки, одно написание порядка ​

Порядок «сначала допуск, затем — и только для намерений, ОТКРЫВАЮЩИХ позицию, — готовность входа» записан ОДИН раз, в AdmissionControl::admit (crates/pm-engine/src/engine/admission_control.rs). Спрашивают его двое, и оба держат ОДИН И ТОТ ЖЕ Arc<AdmissionControl>, поэтому разойтись в вердикте не могут:

  • Головной (экономия работы). StrategyContext::entry_admission — первое, что спрашивает ветвь входа в DirectionalStrategy::on_market_data, выше дедупликации оценок и выше всей цепочки гейтов. При отказе путь входа не выполняется вовсе.
  • Маршрутизаторный (безопасность). Тот же вопрос внутри OrderRouter::allocate, ниже идемпотентного возврата и ВЫШЕ обхода Side::Sell. Он закрывает пути, головной проверки не проходящие (добор, ветвь watch→arm→FAK, любой будущий эмиттер BUY), и гонку взведения причины между двумя вопросами.

Идемпотентный возврат стоит ВЫШЕ вопроса допуска намеренно: иначе повтор с уже выданной резервацией отклонялся бы взведённой причиной.

Ответ допуска один, поэтому «не более одного счётчика воронки на (рынок, такт)» выполняется по построению, а не каскадом отрицаний у вызывающего. Ступени воронки, в порядке старшинства причин и затем прогрев: drain, lifecycle_halt, kill_switch, exchange_degraded, warmup. У каждой остановки СВОЯ ступень: общей корзины risk_limit, в которой отказ по остановке читался бы как «не хватило бюджета», для них нет.

Граница наблюдаемости названа прямо: у ступеней lifecycle_halt и kill_switch операторской поверхности СЕГОДНЯ НЕТ. Их счётчики живут только внутри движка (EntryGateCounters) и читаются двумя способами: приписыванием рубежа в трассе решения (blocker_since) и строкой лога на каждый отказ второго рубежа. Ни в часовую сводку воронки, ни на провод MetricsTick/BotSnapshot, ни в панель воронки дашборда они не выведены — их отсутствие в интерфейсе не означает нулевого значения. Каталог поверхностей — observability.md.

Слой 1 — RISK-LIMITS (ровно 3 canonical) ​

Enforce'ятся в OrderRouter перед вызовом OrderClient.submit. Это единственное место в коде, где живут RISK-LIMITS. Финальная SUBMIT-INTEGRITY-проверка не является ещё одним canonical-лимитом: она не читает budget, не меняет размер и лишь доказывает точное совпадение wire price/size с immutable authorization той же allocation.

Лимит 1 — Shares per market (shares_per_market_max) ​

position.size_shares + order.size_shares ≤ shares_per_market_max

  • Knob: config.db strategy definition (directional.shares_per_market_max).
  • Unit: at-rest micro-shares (Shares = shares × 10^6, on-chain CTF balanceOf precision); все три величины неравенства — micro, сравнение micro-vs-micro. Оператор авторит/видит ЦЕЛЫЕ доли через UI scaling-seam (FieldKind 'shares', ×10^6 на submit, ÷10^6 на display), как budget_per_market_micro_usd показывает $ при хранении μUSD. Публикуемый пример «100 долей» означает at-rest 100_000_000.
  • Status: GAP-01 — реализуется в этом change (unify).
  • Reject reason: RiskLimit(SharesPerMarket).

Лимит 2 — USD per market (budget_per_market_micro_usd) ​

position.entry_cost_micro_usd + order.cost_micro_usd ≤ budget_per_market_micro_usd

  • Knob: config.db (directional.budget_per_market, существующий, перенесён в micro-USD).
  • Status: IMPLEMENTED в pm-engine.
  • Reject reason: RiskLimit(UsdPerMarket).

Лимит 3 — Total strategy budget + companion reinvest ​

Σ open_positions.cost_micro_usd + Σ open_orders.cost_micro_usd ≤ total_budget_micro_usd + (Σ closed_positions.realized_pnl_micro_usd if reinvest_realized_pnl else 0)

  • Knobs: total_budget_micro_usd + reinvest_realized_pnl (boolean companion), оба — strategy-level поля в config.db strategies.definition JSON. Bot-level env-knob'ов нет.
  • Status: источник — strategies.definition.total_budget_micro_usd. Companion reinvest_realized_pnl существует, но без UI exposure (GAP-03, реализуется в этом change).
  • Reject reason: RiskLimit(TotalBudget).

Точка применения ​

rust
// OrderRouter::allocate — ОДНА критическая секция, порядок проверок:
// идемпотентный возврат
//   → ЕДИНСТВЕННЫЙ вопрос допуска (AdmissionControl::admit)
//   → ранний возврат для Side::Sell (выход лимитами не связан)
//   → закрытие рынка → свежесть данных → потолок комиссии
//   → RISK-LIMIT 1..3 → ёмкость итерации
match self.check_risk_limits(&intent) {
    Ok(()) => /* insert Reservation + SubmitAuthorization */,
    Err(which) => return Err(ReservationDenied::RiskLimit { kind: which, .. }),
}
  • Все 3 лимита проверяются в одном месте, до OrderClient.submit.
  • При нарушении allocate возвращает ошибку ВЫЗЫВАЮЩЕМУ и строки заявки НЕ пишет: отказ резервации происходит до создания заявки (см. §«Прогрев входа», абзац «Чего этот отказ НЕ порождает»). Отказ виден стратегии как ContextError, а не как on_order_event(Rejected).
  • Успешная allocation сохраняет точный fingerprint intent и максимальные size/notional. Continuous LIVE принимает только этот envelope и перед POST сверяет canonical scaled integers с Decimal wire values. Probe отдельно сохраняет экспериментальные fixed caps BUY $5 / SELL $6.
  • SELL RISK-LIMITS НЕ проходит. OrderRouter::allocate возвращает резервацию для Side::Sell сразу после вопроса допуска — до всех трёх лимитов и до проверок закрытия рынка, свежести данных и потолка комиссии (проверено по коду 2026-08-25). Что действительно связывает выход: его SubmitAuthorization фиксирует точный exit size/notional, но proceeds с BUY-бюджетом не сравнивает — прибыльный target/stop/forced/manual exit может быть больше legacy $6 и entry cost.

Слой 2 — EMERGENCY-GUARD ​

Отдельный компонент EmergencyGuard в Engine. Это ВЛАДЕЛЕЦ двух причин остановки, а не гейт: он не отвечает на вопрос «можно ли пропустить намерение», а только взводит и снимает LifecycleHalt и PnlKillSwitch в TradingGate (см. §«Единая точка допуска»). Срабатывает не на размере конкретного ордера, а на накопленном PnL / системном состоянии.

Композиция двух источников остановки жизненного цикла — работа ВЛАДЕЛЬЦА. Рубильник держит один слот на вид причины и не решает, стоит ли она. У LifecycleHalt источников два — явная остановка (activate_lifecycle_halt) и любое состояние жизненного цикла, отличное от рабочего (set_lifecycle_state); причина стоит, пока стоит хотя бы один, и снимается, только когда не стоит ни один. Отсюда прямое следствие: возврат цикла в рабочее состояние НЕ снимает явную остановку — у неё нет API снятия (якорь A6, только полный перезапуск процесса).

Рубильник по накопленному PnL БОЕВОГО взводящего НЕ ИМЕЕТ и никогда не имел:activate_kill_switch зовут только тесты, а проекция причины на провод (wire/bot_event_map.rs) — todo!. Фактическая защита по накопленному убытку это CircuitBreaker, и она взводит Drain, а не эту причину. Вариант перечня, его область, место в старшинстве и долговечный код kill_switch сохранены как ПЕРЕНЕСЁННЫЙ долг, а не как действующая защита; ссылаться на него как на работающий рубеж нельзя.

Авто-риск-стоп = DRAIN (2026-07-22 унификация) ​

Автоматический риск-стоп (накопленный убыток / серия стоп-аутов) и ручной операторский стоп — ОДИН И ТОТ ЖЕ механизм: DRAIN MODE (см. слой ниже). Разница только в триггере, не в сути:

  • Авто: circuit-breaker (engine/circuit_breaker.rs + accounting_guard.rs) при серии стоп-аутов подряд ИЛИ rolling-24h realized-убытке выше порога зовёт enter_persistent_drain → взводит тот же EntryHaltState, что и ручной drain.
  • Ручной: оператор → POST /api/local/drain → тот же EntryHaltState.

Оба: блокируют НОВЫЕ BUY, SELL-выходы продолжают течь (позиции сливаются в flat), обратимо через POST /api/local/resume. Снятие — по одному пути (нет CLI; нет отдельного persisted kill_switch_state; нет block-both operator emergency-stop).

Отдельного операторского emergency-stop, глушащего ОБЕ стороны с рестарт-онли снятием, БОЛЬШЕ НЕТ (удалён 2026-07-22 — дублировал drain). SELL-выходы обязаны работать всегда, поэтому единый стоп = drain-семантика.

Lifecycle halt (внутренний, НЕ операторский) ​

LifecycleFSM может перевести процесс в Draining (graceful) или Halt (panic) — в этих состояниях стоит причина LifecycleHalt, и её область — ВСЕ намерения, включая выходы (единственная из четырёх причин с такой областью). Отказ приходит как RejectReason::LifecycleHalt. Используется bootstrap reconciler'ом при превышении bootstrap_auto_heal_action_cap и reset-handler'ом. Это НЕ операторская кнопка — это защита загрузки/перезапуска.

Цена области «все заявки» намеренная и обязана быть видимой: отклонённое ЗАЩИТНОЕ намерение записывается и сообщается отдельной ветвью со своим счётчиком (blocked_lifecycle_halt), а не растворяется в корзине лимитов риска.

Wire-поле is_emergency (MetricsTick/BotSnapshot, из guard.is_lifecycle_halted()) отражает ТОЛЬКО ЯВНЫЙ источник причины (bootstrap/reset), а не выведенный из состояния цикла. Различие важно с этой работы: запуск процесса начинается с уже взведённой LifecycleHalt (TradingGate::at_startup), поэтому «причина стоит» и «движок в аварии» — разные факты, и штатный bootstrap аварией не является.

Гейт подтверждённого покрытия перед автоматическим SELL (confirmed-coverage-forbids-unsellable-verdict) ​

Отдельный от трёх слоёв выше гейт: он не решает, МОЖНО ли выйти (это уже решено — цель или стоп сработали), а решает, стоит ли ОТПРАВЛЯТЬ заявку на доли, которых, по последнему годному измерению цепочки, уже нет. Живёт в strategies/directional/strategy.rs — ensure_primary_exit (обычная цель) и автоматическая капитуляция (bounds.is_some() в стоп-пути) читают ОДНУ и ту же оценку, PositionView::coverage.forbids_unsellable_exit() (ports/persistence.rs), — не два похожих правила, разошедшихся со временем.

forbids_unsellable_exit() истинно ровно на пересечении двух условий: годность измерения Valid (не Stale/None — устаревшая или отсутствующая улика не запрещает попытку выхода, см. ниже) И класс CoverageState::Uncovered — ПОДТВЕРЖДЁННОЕ отсутствие долей, не первый неподтверждённый ноль (UncoveredUnconfirmed): цена промедления капитуляции при падающей цене выше цены одного недоверенного чтения, поэтому первый ноль стоп не гейтит.

Учтённый размер, против которого посчитаны и класс, и годность, — НЕ живой размер отдельной позиции, а сумма учтённых размеров всех активных позиций ОДНОГО кошелька на ОДНОМ токене (balanceOf цепочки отвечает по паре кошелёк-токен, а на токене может лежать более одной активной позиции); у единственной позиции на токене сумма равна её собственному размеру. Налив или продажа ЛЮБОЙ позиции того же кошелька на токене обесценивает измерения ВСЕХ соседей на нём, не только той, что изменилась. Точный состав колонок и правило годности — SoT в docs/target/storage.md §«Подтверждённое покрытие», здесь не пересказывается.

На обычной цели гейт стоит ДО попытки выставить заявку. На автоматической капитуляции — ПОСЛЕ проверки ценового пола и ДО prepare_sell_handoff намеренно: тот терминально снимает стоящую целевую заявку, чтобы освободить инвентарь под stop-exit, и более поздняя проверка отменила бы цель, а затем всё равно отказалась продавать — позиция осталась бы без ЛЮБОГО выхода на книге до следующего тика. Отклонение считается счётчиком exit_blocked_uncovered (не тихо) и, при отсутствии другого выхода на книге, даёт денежную тревогу covered_inventory_rejected_by_exchange (docs/operations/alerts-and-monitoring.md) — позиция остаётся незащищённой (ни цели, ни стопа) до прояснения покрытия или разрешения рынка.

Внутри prepare_sell_handoff тот же предикат читается ДВАЖДЫ — на свежей позиции до отмены владельцев и ещё раз после того, как сетевая отмена вернулась. Годность по времени не монотонна (перевод часов может сделать одно и то же измерение то Stale, то Valid без новой записи), и подтверждённое отсутствие покрытия может стать годным ровно за время отмены; вызывающие (стоп, ранг-защита, ранг-восстановление) отправляют продажу по исходу Ready без собственной повторной проверки, поэтому второе чтение — последнее место, где SELL против подтверждённого нуля долей ещё можно не выпустить. Владельцы в этом случае уже сняты, и это безобидно: подтверждённому нулю долей нечем было их исполнить; замена не подаётся, исход тот же — WithheldCoverageForbidsReplacement.

Восстановление обычной цели ранг-выхода (RestoreOrdinaryTarget) фиксирует фазу Normal в хранилище ТОЛЬКО после того, как ранг-владельцы сняты терминально, и ДО подачи цели — одна durable-запись за такт. Прежний порядок «запись Normal → отмена → при удержании корректирующая запись прежней фазы» оставлял окно: падение процесса между двумя записями оставляло Normal рядом с живым защитным SELL, а гидрация читает это как противоречие и карантинит сигнал. Падение до записи теперь оставляет прежнюю фазу без заявки — защита перевыставляется штатно.

Ручной и аварийный выход гейт ОБХОДЯТ (bounds.is_none() в стоп-пути; операторский emergency_exit в drain, run_manual_exit) — оператор инициирует их сознательно и сам видит фактический ответ биржи; молчаливо снимать эту попытку означало бы решать за него.

Противоречие — обратный случай: измерение годно и говорит Covered (гейт заявку пропустил), а биржа всё равно отклоняет продажу. Это НЕ повод переписать измерение задним числом: engine/coverage_verification.rs только диагностирует случай (coverage.contradiction.diagnostic в events, БЕЗ мутации positions) — сам гейт этот случай не видит (измерение говорит Covered), поэтому попытка продажи продолжается тем же тактовым/backoff темпом, что и любой другой не сработавший с первого раза выход (stop_balance_settling_may_retry), а не превращается в неограниченный поток заявок на биржу.

DRAIN MODE — ЕДИНЫЙ стоп/возобновление (ручной + авто) ​

DRAIN MODE — единственный операторский стоп-режим (рядом с ним только Reset). Реализован и задеплоен на pm-engine (instance harvester-engine); контракт ниже транскрибирован из кода движка. Один и тот же EntryHaltState взводится и оператором (/api/local/drain), и авто-circuit-breaker'ом; снимается одним resume.

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

Emergency exit — опция ликвидации при входе в drain ​

При входе в drain оператор может передать emergency_exit: true (POST /api/local/drain {reason?, emergency_exit?}; в дашборде — галочка в модалке). Тогда движок ПОМИМО блокировки новых BUY ещё и принудительно выводит ВСЕ открытые позиции по текущим ценам (рыночный выход):

  • Персист: sub-флаг force_exit (колонка force_exit в entry_halt_state), переживает рестарт/Reset как и сам drain; взводится ВНЕ зависимости от исхода drain-CAS (re-post на уже дренящем движке обязан взвести).
  • Механизм (engine-path, НЕ прямой CLOB sell-all): стратегия на on_tick при взведённом force_exit для КАЖДОЙ открытой позиции берёт best_bid и зовёт существующий trigger_stop_loss (FAK по best_bid) — интенты НЕ строятся с нуля, переиспользуется протестированный stop-шов. Пустой bid → skip + retry следующий тик (цена не выдумывается).
  • No-double-sell: force-exit и штатный стоп делят ОДИН in-flight-guard per-signal (общий client_order_id-префикс directional-stoploss-) → позиция не продаётся дважды. LIVE-only (default-impl в replay → детерминизм не затронут).
  • Снятие: resume снимает И drain, И force_exit (один путь). Причина Drain не закрывает выходы потому, что её ОБЛАСТЬ — NewEntries: SELL-намерение (GTC sell-лесенки, stop-loss FAK, forced-exit) получает от рубильника Allowed, потому что причина этого действия не покрывает, а не потому, что успевает вернуться раньше проверки. Redeem (on-chain claim, не аллокация) и reconciliation/state-healing (cancel stale, re-track) через допуск не проходят вовсе. Оператор ждёт, пока инвентарь не станет flat (open_positions == 0), затем при необходимости снимает drain (resume) — и торговля возобновляется ПОСЛЕ прогрева входа (см. §Resume-safety).

Что делает и где живёт ​

  • Два рубежа, и оба обязательны. Это те же две точки, что описаны в §«Единая точка допуска»; собственного предиката на денежном пути у drain нет — он задаёт тот же вопрос, что и все остальные причины.
    • Головной (экономия работы). StrategyContext::entry_admission — ПЕРВОЕ, что спрашивает ветвь входа в DirectionalStrategy::on_market_data, выше дедупликации оценок и выше всей цепочки гейтов. При отказе путь входа не выполняется вовсе: не считается отпечаток книги, не считаются цена и размер, не заводится сигнал, не пишется НИ ОДНОЙ строки в signals / events / selection_signals / watch_arm_fak_states. Проверка стоит вокруг ветви входа и только вокруг неё — стоп-лосс, гарантия присутствия выхода, переоценка цели, probability-rank deterioration exit, восстановление добора и алярмы идут ниже по тому же обработчику и выполняются как обычно. Причина переноса: до него сигнал записывался и коммитился РАНЬШЕ, чем маршрутизатор скажет «нельзя», и каждая оценка платила за заведомо невозможный вход (замер на боевом узле за час под дрейном: 145 266 строк signals, 291 584 строки events, база 1.26 ГБ; воспроизведено тестом — 10 снимков дают 10 строк сигналов и 20 строк журнала).
    • Маршрутизаторный (безопасность). Тот же вопрос допуска ВНУТРИ OrderRouter::allocate — СОХРАНЁН и снятию не подлежит. Он закрывает пути, головной проверки не проходящие (добор, ветвь watch→arm→FAK, любой будущий эмиттер BUY), и гонку взведения причины между двумя вопросами. Порядок строк load-bearing больше НЕ является: вопрос задаётся один раз и ВЫШЕ обхода Side::Sell, а то, что drain (и emergency_exit force-exit) не могут заблокировать выход, следует из ЗНАЧЕНИЯ области NewEntries, а не из места проверки. По той же причине LifecycleHalt глушит обе стороны — у неё область AllOrders. При взведённой причине Drain BUY-аллокация отклоняется ReservationDenied::DrainMode { reason } (wire-reason "DrainMode" в BotEvent::OrderRejected), где reason — пометка, с которой владелец взвёл причину.
    • Оба рубежа держат ОДИН разделяемый Arc<AdmissionControl> (голова берёт его у маршрутизатора), поэтому разойтись в вердикте не могут.
  • Наблюдаемость дрейна. Исход обоих рубежей — ОДИН счётчик воронки blocked_drain. Ступень drain — ПЕРВАЯ в цепочке воронки и первая в домене «Допуск к оценке» (funnelAggregate.ts, blocker_since в runtime_state.rs): она отражает место реальной проверки. ВНИМАНИЕ на порядок величины: счётчик теперь растёт на КАЖДЫЙ снимок, дошедший до ветви входа, а не на каждую попытку входа, дошедшую до маршрутизатора, — под дрейном воронка честно показывает «отброшено на первой ступени ≈ 100 %», а не тонкий ручеёк на предпоследней. Причина и момент взведения едут отдельными полями кадра (entry_halt_active / entry_halt_reason / entry_halt_since_ms).
  • Отмена resting BUY. Вход в drain отменяет уже выставленные resting BUY-ордера (только BUY; SELL не трогаются). Без этого resting BUY нальётся → новый инвентарь → «ждём flat» никогда не завершится.
  • In-memory флаг + persist. Состояние — общий Arc<EntryHaltState> (atomic triggered + reason + since в epoch-ms); пишут его drain/resume-handlers, читают MetricsTick-pump и /seed. Каждая смена любого из источников ПЕРЕИЗДАЁТ причину в рубильнике (republish_drain), поэтому HTTP-флип виден допуску на следующем тике без рестарта. На денежном пути EntryHaltState НЕ читается: вопрос «можно ли торговать» задаётся рубильнику. Составной предикат entries_blocked() остался у ДВУХ НЕДЕНЕЖНЫХ читателей, которым нужен факт именно операторской остановки, а не вердикт допуска: быстрый путь листинга (adapters::market_data::listing_watcher) и разрежение переоткрытия (engine::discovery_coordinator). Спрашивать им допуск значило бы замедлять обнаружение при деградации биржи, где оно как раз нужно живым. Строка persist'ится в SQLite-таблицу entry_halt_state (PK wallet_label), которая ПЕРЕЖИВАЕТ рестарт процесса И дашбордовый Reset (таблица намеренно исключена из wipe_runtime_data). Bootstrap пересевает atomic из строки и СРАЗУ взводит причину в рубильнике — иначе между восстановлением строки и первой операторской командой существовало бы окно, в котором персистентная остановка есть в состоянии, но на вопрос допуска не влияет.
  • Wire. Поля drain_mode: Option<bool> и emergency_exit: Option<bool> в MetricsTick/BotSnapshot (skip при None) — источник для UI (кнопка-переключатель + индикация emergency-exit); событие BotEvent::DrainModeChanged { draining } (wire kind = "drain_mode_changed") — для аудита/SSE.

Двустороннее различие: Drain vs System Restart ​

СвойствоDrain (единый стоп)System Restart
EndpointPOST /api/local/drain ({reason?, emergency_exit?}) / POST /api/local/resume / GET /api/local/drainPOST / GET /api/local/restart (legacy wipe-only contract remains at /reset)
Триггероператор (дашборд) ИЛИ авто circuit-breakerоператор
Новые BUYблокируются (entry-halt gate)временно блокируются workflow-owned BUY gate
Выходы (SELL)ПРОДОЛЖАЮТ работать (при emergency_exit — форсируются по рынку)сохраняются либо, при выбранном exit-all, форсируются до durable flat
Отмена ордеровresting BUY; при emergency_exit — ещё и рыночный SELL всех позицийопциональный cancel-all; обязателен и выполняется первым при exit-all
Открытые позициисливаются в flat (при emergency_exit — принудительно сейчас)сохраняются без exit-all; с exit-all рестарт ждёт flat
Точка gateпричина Drain в TradingGate, область NewEntriesта же причина Drain с пометкой operator_system_restart, взводимая эфемерным барьером
Процесспродолжает житьexit(0) + Docker respawn
Подтверждениеопциональный reason в телеheader X-Confirm-System: RESTART + body confirmation/options
Обратимостьобратимо (resume)n/a (одноразовый акт)
ИдемпотентностьДА (200 на повтор)single-flight (409 если уже идёт)
Persistстроки entry_halt_state (+force_exit) переживают Reset/рестарттаблицы сохраняются; wipe только при выбранной галочке и flat
Wire-флагdrain_mode + emergency_exit + DrainModeChangedсобытие RuntimeResetting

System Restart принимает nullable position_limit. Положительное N автоматически включает cancel-all, записывает durable pending iteration после выбранных exit/reset действий и даёт ровно следующей активации этой pending_restart-строки одноразовое право снять прежний Drain — только после reconciliation, durable-активации limiter и перевзвода entry warmup. Активация атомарно потребляет это право; обычный рестарт уже active-итерации Drain не снимает, включая совместимость со старой строкой, где бит ошибочно остался включён. Каждый новый IntentRole::Entry атомарно занимает один слот по signal_uid; DCA и SELL слот не занимают. При commit N-й уникальной позиции движок входит в обычный persistent Drain с reason position_iteration_limit, отменяет resting BUY, оставляет SELL/redeem/reconciliation работающими и отправляет один persistent_drain_entered alert с iteration context.

Это операционный ограничитель тестовой выборки, а не четвёртый canonical RISK-LIMIT: он не измеряет деньги/доли, не живёт в directional.* или config.db, не имеет env override и создаётся только подтверждённым restart workflow. Отказ нового Entry имеет отдельную таксономию PositionIterationCapacity / position_iteration_capacity, не RiskLimit.

(Внутренний lifecycle-halt bootstrap/reset — не операторская строка этой таблицы; это ОТДЕЛЬНАЯ причина LifecycleHalt с областью «все заявки», отражённая в is_emergency, см. выше.)

Почему отдельная причина Drain, а не LifecycleHalt ​

Операторский стоп остался СВОЕЙ причиной со своим владельцем (EntryHaltState), а не был слит с остановкой жизненного цикла, потому что обязан быть:

  1. Обратимым на живом процессе (resume) и персистентным через Reset — LifecycleHalt API снятия не имеет вовсе (якорь A6: только полный перезапуск процесса).
  2. Пропускающим SELL-выходы — область Drain это NewEntries, область LifecycleHalt это AllOrders. Различие областей и есть причина иметь две разные причины: свести их значило бы либо запереть выходы при операторском стопе, либо открыть их при остановке загрузки.
  3. Старшим гейтом — Drain первая в порядке старшинства, поэтому при нескольких взведённых причинах отказ входа называется намерением оператора, а не побочной деградацией.

Resume-safety ​

POST /api/local/resume снимает entry-halt И force_exit одним путём, то есть снимает ОДНУ причину — Drain. Per-market caps и остановка жизненного цикла остаются независимо в силе: слоты причин независимы, снять чужую причину через drain-resume нельзя. Деградация доступа к бирже (см. ниже) тоже независима: resume её не касается — она самоснимающаяся и операторской ручки не имеет.

Возврат входа в работу больше НЕ является немедленным — это наблюдаемое оператором изменение поведения. Снятие ПОСЛЕДНЕЙ причины, закрывающей новые входы, растит поколение открытия, а прогрев входа под новым поколением не достигнут по определению (см. §«Прогрев входа»). Поэтому после resume вход открывается не на первом снимке, а по охвату книг либо по запасному пределу. Немедленным остаётся ровно то, что немедленным было: снятие видно допуску на следующем тике без рестарта процесса, а карта дедупликации оценок под остановкой не заполняется (отпечаток книги не считается), поэтому первый снимок после готовности не может быть отброшен как «неизменившийся».

Важная точность: поколение растёт на переходе «есть блокираторы новых входов → нет», а не на каждом снятии. resume при СТОЯЩЕЙ деградации доступа к бирже (или любой другой причине области NewEntries) поколение не двигает и прогрев заново не начинает — он начнётся, когда снимется последняя из них.

Единая машинерия перехода, шесть источников входа ​

Вход в drain выполняет ОДНА shared-функция перехода (enter_persistent_drain): CAS-flip atomic ПЕРВЫМ (проигравший гонку вход — полный no-op, side effects ровно один раз) → cancel resting BUY → persist → publish DrainModeChanged. Владелец при этом ВЗВОДИТ в рубильнике причину Drain с пометкой источника; на вопрос допуска влияет именно она. Источники входа, различимые полем reason:

  • operator_drain — ручной вход оператора (POST /api/local/drain);
  • circuit_breaker — авто-drain движкового circuit-breaker'а;
  • accounting_invariant — авто-drain accounting-guard'а при нарушении инварианта учёта;
  • user_ws_accounting_unavailable — авто-drain при недоступности учёта филлов по user-WS (терминальный сбой канала / провал reconnect-сверки). Закрытие управляющего канала, вызванное штатным завершением движка, этим drain НЕ является и его НЕ вводит: штатное завершение разрушает плоскость подтверждений по построению (отмена токена завершения → остановка рабочих циклов → закрытие канала), поэтому такое закрытие не несёт факта об учёте. Признак — тот же CancellationToken, которым координатор завершения останавливает рабочие циклы; терминальный сбой и провал сверки вводят drain В ЛЮБОЙ фазе, включая завершение. Подавленный случай не молчит: строка журнала с полями shutdown_in_progress/suppressed и счётчик pm_engine_user_ws_accounting_shutdown_suppressed_total. Реализация: crates/pm-engine/src/engine/user_ws_accounting.rs;
  • unresolved_submit_without_reservation — авто-drain на bootstrap: неразрешённая отправка без живой бюджетной резервации (единственный источник ВНЕ enter_persistent_drain — прямой try_trigger в recover-пути OrderRouter, cancel там не нужен).
  • position_iteration_limit — N-я новая уникальная позиция тестовой итерации; источник передаёт типизированный iteration snapshot в тот же alert, без разбора строки reason.

Условия авто-срабатывания — предмет capability соответствующих контуров, здесь не специфицируются. Наблюдаемое следствие для контракта drain: значение reason в GET /api/local/drain / снапшоте — одно из шести выше; resume снимает entry-halt НЕЗАВИСИМО от источника (единственный ручной выход для всех шести).

Эфемерный барьер рестарта — не седьмой источник drain, а ВТОРОЙ ВЛАДЕЛЕЦ той же причины рубильника. Рабочий процесс System Restart взводит Drain с пометкой operator_system_restart; персистентным drain это не становится — в entry_halt_state ничего не пишется и в GET /api/local/drain не проецируется, поэтому перечень шести значений reason выше остаётся полным. Композиция двух владельцев — работа EntryHaltState (republish_drain): причина стоит, пока стоит хотя бы один, и снимается, только когда не стоит ни один. Поэтому провал рабочего процесса рестарта не открывает вход, который оператор закрыл до него, а снятие операторского drain не открывает вход, пока идёт подготовка рестарта. Обходить это парой «снять + взвести» запрещено: между снятием и взведением набор блокираторов на мгновение опустел бы, поколение открытия выросло, и прогрев начался бы заново без единого открытия входов.

Немедленный операторский алерт автоматического входа. Вход по ЛЮБОЙ автоматической причине (все, кроме operator_drain) СРАЗУ эмитит Critical-алерт persistent_drain_entered в alert-sinks (LogSink всегда, TelegramSink при BOT_TG_*) — в дополнение к 24-часовому reminder'у, не вместо него. Различие несёт типизированный DrainOrigin (Operator / Automatic), а не строка reason: тело операторского запроса может нести произвольную причину, и строковая дискриминация дала бы ложный Critical. Операторский вход не алертится — оператор сам его включил. Доставка fire-and-forget и fail-soft: отказ или зависание синка не блокирует и не задерживает сам drain-переход; проигравший CAS повторного алерта не даёт. Реализация: crates/pm-engine/src/engine/drain.rs.

Немедленный операторский алерт автоматического выхода. Когда новая ограниченная итерация действительно потребляет одноразовое разрешение и снимает активный Drain, движок после durable-clear, очистки EntryHaltState и публикации DrainModeChanged { draining: false } отправляет информационный entry_halt_cleared во все alert-sinks. Сообщение называет автоматическую активацию ограниченной итерации и wallet, а не приписывает переход вызову POST /api/local/resume. Ошибка одного синка журналируется и не мешает последующим синкам; уже выполненный переход не откатывается. Первый неактивный опрос периодического reminder остаётся без сообщения, поэтому повторного Telegram-алерта нет. Реализация: crates/pm-engine/src/observability/entry_halt_reminder.rs.

Контроль ТОЛЬКО через Local-API — не strategy-knob, не env ​

Drain — операторский runtime-контроль, а НЕ параметр стратегии и НЕ env-override (RULE ADR-015: env запрещён для strategy-config; но здесь это и не config вовсе). Никаких directional.* ключей, никаких BOT_*/PM_ENGINE_* env-переключателей самого drain. Живой контроль возможен ИСКЛЮЧИТЕЛЬНО через HTTP: POST /api/local/drain|resume (кнопка UI или curl). pm-cli НЕ добавлен как drain-контроль намеренно: CLI мог бы записать строку в боковую БД, но движок читает её только на bootstrap, а в рантайме видит drain лишь через флип atomic из HTTP-handler — CLI-запись НЕ применилась бы на лету, и её присутствие создало бы ложное впечатление живого контроля. Единственные env, влияющие на доступность endpoint'ов, — транспортные: PM_ENGINE_SSE_ENABLED (default false ⇒ сервера нет ⇒ endpoint недоступен), PM_ENGINE_SSE_BIND (default 127.0.0.1:31342), PM_ENGINE_SSE_TOKEN (bearer; обязателен при non-loopback bind — иначе fail-fast на старте).

Доступ к бирже (EXCHANGE-HEALTH) — ДЕТЕКТОР, самоснимающийся, НЕ persistent drain ​

Отдельный ДЕТЕКТОР рядом с барьером входов: деградация CLOB-upstream — наблюдаемое состояние, взводящее причину ExchangeAccess с областью «только новые входы». Реализация: crates/pm-engine/src/engine/exchange_health.rs (монитор + FSM), crates/pm-engine/src/adapters/status_page.rs (статус-поллер); capability-спеки clob-health-monitoring / exchange-status-polling (openspec exchange-health-resilience, unified-trading-gate-and-entry-readiness). Это не RISK-LIMIT (не читает бюджет и размер) и не операторский стоп: системное состояние, детектируемое по внешнему факту «биржа деградирует / плановые работы».

Монитор — не гейт. Он не решает, можно ли разместить заявку: он классифицирует улики, ведёт окно, владеет единственной пробой и как ВЛАДЕЛЕЦ ПРИЧИНЫ взводит ExchangeAccess на входе в Degraded и снимает её на возврате в Healthy. Решение о допуске принимает TradingGate.

Эпизод — ОДНО взведение и ОДНО снятие. Переходы Degraded ↔ Recovering причину не трогают вовсе, поэтому провалившаяся проба не делает входы допустимыми даже на мгновение.

Классы улик и активная проба восстановления ​

В Healthy/Closed монитор питается исходами реальных попыток (submit / cancel / reconcile-GET), классифицированными существующей таксономией ошибок. В Degraded/Open не чаще раза в 60 с допускается одна read-only authenticated проба полного GET /data/orders через тот же клиент, credentials, rate-budget и egress, что и торговля.

КлассЧто попадаетДействие
authenticated_accessJSON 401/403, HTML/WAF block или истёкшая попытка authenticated readМгновенный вход в Degraded/Open
definitive_maintenanceMatchingEngineRestart (HTTP 425 / post-only-mode) и 503 с телом биржи («Trading is currently disabled» / cancel-only)Мгновенный вход в Degraded
suspectSubmitOutcomeUnknown (PoolError::RequestTimeout на submit, 5xx без тела биржи, неразбираемое тело), WorkerDied, 5xx на cancel и reconcile-GETПорог по окну
transportPoolError::RequestTimeout на authenticated read/cancel; ClobError::Connect — соединение не установилось (возможно, локальная сеть)Тот же оконный счётчик, отдельная метка для диагностики

НЕ улики деградации: RateLimited (429 — наш бюджет), бизнес-отказы с внятным телом биржи (BalanceSettling, PostOnlyCrossing, прочие ExchangeRefused) и локальные до-сетевые отказы. Обычный успех submit/cancel не закрывает circuit.

FSM и пороги (именованные константы, НЕ config.db и НЕ env) ​

  • Healthy/Closed → Degraded/Open: одно authenticated_access или definitive_maintenance ИЛИ ≥5 suspect|transport суммарно за скользящее окно 60 с (DEGRADED_EVENT_THRESHOLD / DEGRADATION_EVENT_WINDOW_MS).
  • Degraded/Open → Recovering/HalfOpen: через 60 с (CLEAN_WINDOW_MS) ровно один вызывающий атомарно получает право на полную authenticated read-пробу; при recovery hold проба не допускается.
  • Recovering/HalfOpen → Healthy/Closed: только после успешного чтения всех страниц GET /data/orders до терминального cursor. Отказ пробы возвращает в Degraded/Open и начинает новый 60-секундный интервал. Тишина и «зелёная» статус-страница не закрывают circuit.

Пороги — именованные константы в exchange_health.rs (прецедент STOP_ALARM_THROTTLE_MS): это не strategy-knobs (ADR-015 не затронут), а конфигурационная поверхность до форвард-свидетельства необходимости тюнинга не вводится (RULE-NEW-01). Калибровка — только на форвард-данных, перенос в config.db — отдельным изменением при доказанной потребности.

Чем отличается от персистентного drain ​

СвойствоПричина ExchangeAccessПричина Drain (persistent)
ХранениеТОЛЬКО in-memory; рестарт процесса ⇒ старт Healthy, передетекция из свежих улик за ≤1 окнострока entry_halt_state, переживает рестарт и Reset
Снятиеавтоматическое по успешной полной authenticated пробе, не чаще раза в 60 с; операторского resume НЕТТОЛЬКО POST /api/local/resume
Область действияNewEntries — только новые входы (взведена в Degraded И Recovering); SELL-выходы, стопы, капитуляция, redeem, reconciliation работаютNewEntries — то же
Место в старшинствемладшая из четырёхстаршая из четырёх
Wire-reason отказаExchangeDegraded (ReservationDenied::ExchangeDegraded { state, maintenance_hold })DrainMode

Причины НЕЗАВИСИМЫ, но ответ — не их конъюнкция. Слоты причин независимы: снятие одной не снимает и не ослабляет другую, поэтому вход остаётся закрытым, пока стоит хотя бы одна. При этом ответ допуска ОДИН и называет РОВНО ОДНУ причину — старшую среди покрывающих действие, а не «все взведённые». Читать отказ DrainMode как «деградации нет» нельзя: он означает лишь, что drain старше.

Рубежа два, как и у всех причин: головная проверка в начале ветви входа стратегии (ступень воронки exchange_degraded) и вопрос допуска в OrderRouter::allocate. Оба спрашивают ОДИН AdmissionControl; счётчик блокировок один — pm_engine_entry_blocked_exchange_health_total (слагаемые взаимоисключающи).

Улика отказа снимается в момент ВЗВЕДЕНИЯ причины, а не в момент отказа. Эпизод — одно взведение и одно снятие, повторное взведение по уже стоящей причине пустая операция, поэтому поля state и maintenance_hold в отказе описывают вход в эпизод и на его протяжении не меняются. Читать их надо именно так: state в отказе всегда Degraded (значение на входе), даже пока автомат успел уйти в Recovering; maintenance_hold: true ⇒ на момент входа в эпизод действовало удержание от статус-поллера (окно плановых работ), false ⇒ только собственная телеметрия. Удержание, взведённое ПОСЛЕ входа в эпизод, поле уже не изменит — живое состояние удержания смотрят по монитору, а не по отказу. Для пути статус-страницы атрибуция при этом верна: поллер взводит удержание ДО того, как пишет улику definitive_maintenance. Ни на область действия, ни на старшинство, ни на долговечный код улика не влияет — все три принадлежат ВИДУ причины, а не её улике.

Почему область — NewEntries для ВСЕХ классов улик, включая definitive_maintenance (решение оператора по вопросу В1 от 2026-08-25). Улика детектора — про ЧТЕНИЕ: инцидент, породивший пакет, это 403 HTML на authenticated GET /data/orders. Отказ чтения НЕ доказывает, что биржа отказывает в приёме заявок, а заблокировать выходы по такой улике значит превратить проблему доступности в проблему незащищённой позиции — стопы не сработают. Условие пересмотра названо явно: зафиксированный в бою случай, когда отправка заявки отвергается биржей при взведённом ExchangeAccess, с разбором обстоятельств. Пересмотр будет правкой ЗНАЧЕНИЯ области у вида причины, а не введением новой конструкции.

Роль статус-страницы — гибрид: телеметрия триггерит, страница дополняет ​

Поллер https://status.polymarket.com/summary.json (Instatus; каденция 30 с, timeout 5 с, always-on без ручки включения — выключатель был бы мёртвой ручкой по RULE-NEW-01) НЕ является триггером Degraded — вход в деградацию всегда от собственной телеметрии. Страница добавляет три вещи:

  1. Упреждение работ — страница UNDERMAINTENANCE, работа INPROGRESS либо анонс NOTSTARTEDYET со стартом в горизонте 5 мин (MAINTENANCE_PRE_DRAIN_LEAD_MS) закрывают входы: поллер пишет в монитор definitive_maintenance-улику и взводит удержание восстановления. Уход работ из активного набора снимает удержание, но не открывает входы: их разрешает только последующая успешная authenticated проба. Отбор по продукту: блок применяется только к работам, задевающим используемые нами API. Статус-страница обслуживает несколько продуктов, и работы по Perpetuals (чужой API) переключают page.status так же, как работы по CLOB. Связи «событие → компонент» в summary.json НЕТ (ограничение Instatus), поэтому отбор двухчастный: для ИДУЩИХ работ поллер лениво опрашивает /v2/components.json и снимает блок только при ПОЛОЖИТЕЛЬНОМ доказательстве «неисправен ≥1 компонент, и все неисправные — чужие»; для ещё не начатого АНОНСА (компонент до старта исправен) единственная улика — имя записи. Всё недоказанное блокирует: все компоненты OPERATIONAL, неисправен наш или неопознанный компонент, отказ зонда, непонятное имя анонса. Списки компонентов — явные константы OUR_COMPONENTS / FOREIGN_COMPONENTS в status_page.rs (не strategy-knob, ADR-015 не затронут); имя вне обоих списков считается НАШИМ и поднимает pm_engine_status_page_schema_anomalies_total{kind="unrecognized_component"}.
  2. Удержание HalfOpen — пока работы активны, переход Degraded/Open → Recovering/HalfOpen подавлен. Снятие удержания НЕ разрешает пробу, наступившую по времени: отсчёт чистого окна начинается ЗАНОВО. Пока проба была запрещена, движок не пробовал, поэтому тишина этого интервала уликой восстановления не является. Окно считается от ПОЗДНЕЙШЕГО из двух моментов — входа в Degraded и снятия удержания, — так что пятиминутные работы не дают права пробовать сразу после их окончания. Само снятие восстановления не доказывает.
  3. Корроборация — показание страницы попадает в текст алерта деградации («подтверждено статус-страницей» / «страница молчит»); на решение гейта НЕ влияет.

Отказ самого поллера — fail-soft: ≥5 подряд неудач опроса (STATUS_POLL_FAILURE_THRESHOLD) ⇒ состояние Unknown, которое НИКОГДА не трактуется как сбой биржи: входы не блокируются, улики в монитор не пишутся, а активный блок работ снимается — гибрид деградирует ровно до чистой телеметрии, без слепоты. Счётчик pm_engine_status_poll_failures_total и порог Unknown считают ТОЛЬКО summary.json: недоступность второго (ленивого) запроса /v2/components.json не переводит поллер в Unknown и не раскрывает вход — она оставляет блок работ по прежнему правилу.

Алерты и метрики контура — ../operations/alerts-and-monitoring.md; операторский регламент — ../operations/runbook.md §«Деградация биржи / плановые работы».

Политика стопа при протухшей книге — fail-open (решение оператора 2026-08-06) ​

Возрастной гейт свежести книги применяется ТОЛЬКО к BUY-входам (EntryFreshnessGuard::check_buy); стоп-контур свежестью НЕ придерживается и оценивает/стреляет по последней известной книге. Это осознанный fail-open, а не пробел. Защита цены встроена в исполнение: капитуляция уходит SELL FAK с лимитом, исполнение хуже лимита невозможно по контракту FAK. Худший исход ложного выстрела по протухшему дипу, которого уже нет: FAK встречает реальные (восстановившиеся) биды — исполнение по лучшей цене либо чистый kill без филла, то есть не хуже честного срабатывания стопа на том же уровне.

Fail-closed (придержать стоп при книге старше порога) отвергнут как активно вредный: он превращает инцидент ДАННЫХ в инцидент ПОЗИЦИЙ — при сбое рыночных подписок стопы всех позиций замерзают одновременно, ровно в тот класс времени, когда обвал вероятнее; выгода (избежать ограниченного лимитом раннего выхода) несоизмерима с риском неограниченной просадки без защиты. Пропуск реального обвала из-за замороженной книги — проблема отсутствия данных, её не решает никакая политика гейта; смягчение — самовосстановление WS-контура и trade-confirm контур стопа (подтверждение реальной лентой сделок). Кодовых изменений в стоп-контуре решение не требует. Rationale стоп-лосса — ../strategies/directional-harvester.md.

Ужатый хвост капитуляции — пол цены и правило эффекта из роли ​

Капитуляция для немедленных политик стопа (fak/fok) строит план по видимой книге вместо безусловной продажи по best_bid; семь исходов плана и лотовая арифметика обеих политик описаны в ../strategies/directional-harvester.md §«Стоп-контур pm-engine». Здесь — денежный инвариант этого плана, а не его механика.

Пол цены исполнения (stop_loss.execution_floor_margin_milli) — ЕДИНСТВЕННАЯ нижняя граница плана: план не строит ни одного из семи исходов дешевле пола, а недостижимый пол держит стоп ARMED вместо продажи по любой цене — молчания здесь нет, оба гейта пола растят один счётчик (pm_engine_stop_execution_floor_held_total). Пол ОПЦИОНАЛЕН и по умолчанию ОТСУТСТВУЕТ; без него план строится безусловно (при известном шаге рынка) — то же поведение, что было до пола. Пол считается от ПОРОГА стопа, а не от цены входа — прежний exit_floor = avg_entry − max_loss снят решением оператора 2026-07-14 как превращавший подтверждённый обвал в вечный hold (Rationale — ../strategies/directional-harvester.md).

Когда план СОЗНАТЕЛЬНО ужимает заявку ниже учтённого остатка (исход shrunk — единственный такой), заявка несёт роль sharp_stop_partial / sharp_stop_confirmed_partial, а не обычную роль стопа. Роль определяет ЭФФЕКТ полного налива ИМЕННО ЭТОЙ заявки на позицию (ExitOrderRole::sell_position_effect, тотальная функция без умолчания, crates/pm-engine/src/ports/intent_role.rs): у ужатых ролей полный налив УМЕНЬШАЕТ позицию, не закрывает её, и НЕ включает защитную выемку учёта на оставшемся хвосте — остаток продолжает жить под тем же стопом и обслуживается следующей оценкой. Все остальные роли выхода (обычный и подтверждённый стоп, целевой выход, ранг-выход, force/manual) закрывают позицию целиком на своём полном наливе. Путать эти два вопроса — исполнена ли заявка целиком (решает биржа) и закрыта ли позиция целиком (решает роль) — значило бы включать защитную выемку учёта на живом, ещё не проданном остатке или, наоборот, терять её на действительно закрытой позиции.

Позиция, упавшая ниже биржевого минимума размера заявки (непродаваема ни при каком размере — ExitSizeDecision::DustStranded), наблюдается отдельно от плана: pm_engine_exit_residual_stranded_total{origin} — ../operations/alerts-and-monitoring.md §«Prometheus /metrics endpoint».

Прогрев входа — ВТОРОЙ последовательный механизм, а не третья причина ​

Прогрев не про биржу и не про оператора, а про НАШИ данные: после перерыва книги в кэше устарели, и вход открывается по охвату книг, а не по факту, что биржа снова отвечает. Реализация: crates/pm-engine/src/engine/entry_readiness.rs; способность entry-readiness-after-gate (openspec changes/unified-trading-gate-and-entry-readiness/specs/entry-readiness-after-gate/spec.md); числовые пороги готовности — способность entry-warmup-before-first-signals.

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

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

Прогрев ни на что не подписан, оповещений о переходах не принимает и метода перевзведения снаружи не имеет. Отсюда следствие, ради которого это и сделано: устаревание НЕЛЬЗЯ пропустить, потому что оно не сообщается, а выводится. Если рубильник открылся заново, поколение выросло, и прогрев не готов ПО ОПРЕДЕЛЕНИЮ. Прежний инвариант — «наблюдатель, увидевший „биржа исправна“, обязан уже видеть взведённый прогрев» — требовал межкомпонентной атомарности, которую приходилось обеспечивать порядком публикации, условной записью и отсечкой устаревшей рассылки. Ничего из этого больше нет.

Практическое следствие для оператора: каждое открытие входов начинает прогрев заново — и после resume, и после закрытия рубильника доступа к бирже, и после достижения рабочего состояния на запуске процесса. Это НЕ три отдельных триггера в коде, а один и тот же переход «есть блокираторы новых входов → нет».

СвойствоПрогрев входа
ХранениеТОЛЬКО in-memory; готовность наступает по охвату книг (coverage_reached) либо по запасному пределу (fallback_deadline). Третья ветвь arrivals_settled работает ТОЛЬКО когда координатора обнаружения нет вовсе — в боевой конфигурации она выключена всегда
ВзведениеЛЕНИВОЕ, при первом вопросе под новым поколением открытия; внешнего вызова перевзведения нет
Снятиеавтоматическое по готовности данных; операторского resume НЕТ
Кому задаётся вопросТОЛЬКО намерению, открывающему позицию (все BUY, включая добор); выходы, стопы, капитуляция, redeem, reconciliation не спрашивают вовсе
Wire-reason отказаEntryWarmupPending (ReservationDenied::EntryWarmupPending)
Код отказаentry_warmup_pending — ОТДЕЛЬНЫЙ от exchange_degraded
Счётчик блокировокpm_engine_entry_blocked_warmup_pending_total (свой, не общий с деградацией доступа)

Спрашивают его те же две точки, что и допуск, и в ТОМ ЖЕ вызове (AdmissionControl::admit): головная проверка в ветви входа стратегии (ступень воронки warmup, счётчик blocked_warmup) и вопрос в OrderRouter::allocate. Второй рубеж заведён решением оператора 2026-08-24: он закрывает пути в обход головы. От ПОРЯДКА событий при открытии рубильника денежный путь не зависит вовсе — готовность выводится из поколения, а не публикуется, поэтому согласовывать моменты наблюдения не требуется ни одному из двух рубежей.

Три исхода вопроса, а не два: «остановлено» и «прогревается» — РАЗНЫЕ факты с разными счётчиками, разными ступенями воронки и разными долговечными кодами (EntryAdmission::Stopped / Warming / Allowed). Смешать их в одно «нельзя» значило бы потерять ровно то различие, ради которого прогрев вынесен во второй механизм.

Связка с каденцией обнаружения, которую нельзя потерять при тюнинге. Ветвь охвата не может сработать раньше ближайшего ПОЛНОГО прохода обнаружения, то есть раньше discovery.rediscovery_interval_secs (настройка оператора в config.db). Запасной предел — код-константа WARMUP_FALLBACK_MS = 120 000 мс. При боевых 60 с запас между ними около минуты; поднять интервал переоткрытия ВЫШЕ 120 с значит выключить ветвь охвата вовсе — прогрев выродится в чистый двухминутный таймер, и заметно это будет только по признаку завершения fallback_deadline и строке WARN в логе. Менять одно из двух чисел, не проверив второе, нельзя.

Причина отказа отдельная намеренно. Свести её с ExchangeDegraded значило бы показать оператору и форензике неисправную биржу там, где биржа здорова. Отдельность проведена по поверхностям, где отказ резервации ДЕЙСТВИТЕЛЬНО наблюдаем: wire-reason EntryWarmupPending, счётчик воронки blocked_warmup, метрика pm_engine_entry_blocked_warmup_pending_total, исход попытки входа entry_warmup_pending в колонке decision_liquidity_profiles.entry_outcome и класс решения стратегии.

Чего этот отказ НЕ порождает, и это верно для ВСЕХ отказов резервации, включая отказ лимитом риска. Строки заявки с orders.reject_reason_code не появляется: отказ резервации происходит ДО создания заявки, и allocate возвращает ошибку вызывающему, а не пишет отказанную строку. То же относится к drain_mode, lifecycle_halt, kill_switch, risk_limit, exchange_degraded, закрытому рынку и прочим причинам недопуска. Прежней функции записи отказанной строки (persist_rejected) в маршрутизаторе больше нет: решение оператора 2026-09-06 (карточка entry-order-role-durable-column) — запись строки на отказ лимитом отклонена, потому что при исчерпанном бюджете строка и событие ложились бы каждые пять секунд на каждый допустимый рынок, а повтор намерения с тем же идентификатором упирался бы в уникальность строки. Каталог кодов в docs/operations/alerts-and-monitoring.md перечисляет причины недопуска как коды ОТКАЗА, и это верно про домен кодов, но не про наличие строк в orders.

Где отказ лимитом наблюдаем. Полнота данных об отказе — на стороне стратегии, которой вид лимита (RiskLimitKind) и дословная причина доезжают в ContextError::RiskLimit { kind, detail }: (1) ряд pm_engine_entry_blocked_risk_limit_total{kind} — метка вида лимита из закрытого набора shares_per_market / budget_per_market / total_budget, инкремент в точке первого отказа (try_emit_entry); (2) кадр воронки допуска admission_funnel_samples (терминальная стадия blocked, причина risk_limit) — в gate_inputs_json атрибуты risk_limit_kind, risk_limit_detail, intent_size_shares, intent_price_pip, intent_cost_micro_usd; (3) след гейта допуска router_refusal с решением blocked:risk_limit и теми же атрибутами — для выбранного рынка трассы; (4) поминутный счётчик воронки blocked_risk_limit и warn в журнале.

Слой 3 — ENTRY-POLICY / EXECUTION ​

Логика «когда и по какой цене эмитить OrderIntent» живёт внутри стратегии, вызывается через StrategyContext helpers. Эти проверки не применяются ни в OrderRouter, ни в точке допуска. Router и рубильник оперируют над intent'ами, которые стратегия уже решила эмитить.

HelperНазначениеИсточник
`ctx.entry_window_check(market) -> {OpenClosed}`Per-strategy finish_type (EndDate / GstOffset / GstLocalMidnight) + bounds
`ctx.debounce_gate(signal_key) -> {ArmedSuppressed}`Armed-state в config.db knobs
`ctx.stop_loss_eval(position) -> {HoldExit}`threshold/cooldown/debounce из config.db
(паттерн стратегии) watch-arm-FAKРеактивный entry-buffer(deprecated reference) ранее ADR-017, ныне стратегический паттерн без RISK-LIMIT-семантики

Если debounce/entry-window/stop-loss решают НЕ эмитить intent — никакого reject в OrderRouter нет, потому что intent до router'а не дошёл. Эти отказы видны через signal_rejected (если был emit signal) или вообще не видны (no-op). Это by design: ENTRY-POLICY формирует поток intents, не блокирует его.

Knobs в config.db ​

По memory feedback_strategy_config_via_ui: все knobs стратегии (debounce armed-state, finish_type, stop-loss threshold/cooldown/debounce, entry-window bounds) живут только в config.db, никаких env-aliases на стороне pm-engine. UI configurator в дашборде — единственный санкционированный способ их менять.

Когда добавляется новый knob — классификация по A10 ​

feedback_no_hidden_logic (RULE-NEW-01): любая логика/параметр/поведение, не соответствующее A1–A9, классифицируется как «скрытая логика» и требует явного решения пользователя.

Алгоритм классификации нового knob'а:

  1. Ограничивает размер риска отдельной позиции или совокупный бюджет? → должен совпасть с одним из 3 canonical RISK-LIMITS. Если не совпадает — это не RISK-LIMIT (HIDDEN, escalate). «Три» — это состав списка на сегодня, а не запрет на его расширение: новый canonical-лимит вводится явным решением оператора с обновлением этого раздела. Запрещено обратное — молча добавить ограничение риска мимо списка.
  2. Срабатывает на накопленном PnL / системном состоянии и блокирует поток? → EMERGENCY-GUARD. Требует ADR и НОВОГО ВИДА в закрытом перечне StopKind с явно назначенными областью действия, местом в старшинстве и ключом домена кодов — плюс владельца, который его взводит и снимает. Пятый вид ломает сборку, пока все три вещи не названы; «дописать проверку рядом» вариантом не является.
  3. Решает «когда / по какой цене» эмитить intent? → ENTRY-POLICY/EXECUTION. Живёт в стратегии, knob в config.db.
  4. Метрика/лог? → OBSERVABILITY (5-я категория из docs/strategies/parameters-reference.md, не enforcement).

Любой knob, который не попадает в одну из этих 4 категорий, по A10 = HIDDEN, требует явного решения пользователя.

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

  • Per якорь A7 (project_canonical_vision + project_canonical_risk_limits) — ровно 3 canonical RISK-LIMITS, никакого «risk-checks» списка. Категориальная ошибка прошлого зафиксирована и исправлена тремя слоями.
  • Per ADR-031 (ACCEPTED) — risk engine kill switch: владелец причины — EmergencyGuard, решение о допуске — TradingGate. Область причины PnlKillSwitch — только новые входы, SELL проходит. Состояние process-local и очищается рестартом; персистентная остановка входов — отдельная причина Drain со своим владельцем EntryHaltState. Боевого взводящего у PnlKillSwitch нет (см. §Слой 2) — как защита он не работает, это перенесённый долг.
  • Per ADR-026 (ACCEPTED) — Realized PnL reinvest: companion лимита 3 (reinvest_realized_pnl). UI exposure (GAP-03) реализуется в unify change. Реализован как boolean → меняет формулу лимита 3, не добавляет ещё один лимит.
  • Per ADR-027 directional-stop-loss (ACCEPTED) + якорь A4: stop-loss НЕ удалён, переработан в config-driven форме. Knobs (threshold, cooldown, debounce) в config.db. Stop-loss exit — EXECUTION слой (внутри стратегии), не RISK-LIMIT.
  • Per ADR-030 (DEFERRED) — entry debounce: в текущем pm-engine не реализован; knobs и armed-state отсутствуют. Возврат — только отдельным изменением.
  • EV cap не существует как защита. Никаких полей ev_* в strategies.definition, никаких EV-cap helper'ов в StrategyContext, никаких ADR на эту тему.
  • Per spec order-router-and-sdk (заархивирован в openspec/changes/archive/2026-07-23-unify-orders-positions-signals-events/specs/order-router-and-sdk/spec.md) — формальный requirement с scenarios: enforcement сосредоточен в OrderRouter, а scenario «No other limit type uses RiskLimit reason» жёстко привязывает reason variant, поэтому никакой дополнительный лимит не может прикинуться canonical'ом. Архивная редакция перечисляет четыре лимита, включая percent-of-wallet — это состояние на момент архивации; лимит удалён 2026-07-28 по решению оператора, действующий список — три.
  • Per design «Enforcement layers» (unify-orders-positions-signals-events/design.md): три слоя в трёх местах кода. EMERGENCY-GUARD до RISK-LIMITS до strategy-internal-checks (но strategy-internal-checks выполняются ещё раньше, в момент решения emit'ить или нет — это не противоречит, потому что они не блокируют, а формируют поток). Уточнение 2026-08-25: «место» слоя EMERGENCY-GUARD — не отдельный вызов перед маршрутизатором, а вопрос допуска ВНУТРИ OrderRouter::allocate, выше трёх лимитов. Слоёв по-прежнему три, отдельных точек проверки — меньше.
  • Per openspec unified-trading-gate-and-entry-readiness (D1–D7, 2026-08-25): область действия причины стала ЗНАЧЕНИЕМ, перечень причин — закрытым, ответ денежному пути — одним, прогрев — вторым последовательным механизмом с готовностью, выводимой из поколения открытия. Отменены как ставшие ненужными: отложенная публикация состояния монитора здоровья, номер поколения перехода как защита денежного пути и отсечка устаревшей рассылки для кэша допуска.

Текущий статус прежних GAP ​

  • GAP-01 (shares_per_market_max) и GAP-03 (reinvest_realized_pnl) — реализованы в typed config, runtime и конфигураторе.
  • GAP-02 (percent-of-wallet) — ОТМЕНЕН 2026-07-28 решением оператора: лимит удалён из кода, конфигурации и canonical-списка. Ограничения по доле кошелька в системе нет; при появлении такой потребности она вводится как НОВЫЙ canonical-лимит через явное решение (см. процедуру классификации выше).

Cross-references ​

  • Где enforce'ятся лимиты в коде: engine.md (OrderRouter, точка допуска TradingGate + EntryReadiness).
  • Где живут knobs: storage.md (config.db).
  • Strategy SDK helpers для ENTRY-POLICY/EXECUTION: extension-points.md.
  • Reject reason payload на wire: wire-contract.md.
  • Операторский drain-флоу (UI-кнопка + endpoints + verify-on-VPS): ../operations/runbook.md §«Manual entry-halt (DRAIN MODE) — оператор».
  • Memory: project_canonical_vision A7+A10, project_canonical_risk_limits, feedback_no_hidden_logic, feedback_strategy_config_via_ui.
  • Openspec: openspec/changes/unify-orders-positions-signals-events/specs/order-router-and-sdk/spec.md; capability manual-entry-halt (DRAIN MODE) — openspec/specs/manual-entry-halt/spec.md; деградация доступа к бирже — openspec/changes/exchange-health-resilience/specs/{clob-health-monitoring,exchange-status-polling}/spec.md; единая точка допуска и прогрев — openspec/changes/unified-trading-gate-and-entry-readiness/specs/{trading-gate-admission,entry-readiness-after-gate,clob-health-monitoring,manual-entry-halt,entry-warmup-before-first-signals}/spec.md.