Тема
Целевая модель защиты: 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 и memoryfeedback_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 drift | OrderRouter capability + финальная граница live adapter | SubmitAuthorizationViolation(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 — детектор доступа к CLOB | ExchangeDegraded |
Область действия — ЗНАЧЕНИЕ, а не место проверки. До этой работы «остановка жизненного цикла закрывает и выходы» означало «её строка стоит ВЫШЕ раннего возврата для 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.dbstrategy definition (directional.shares_per_market_max). - Unit: at-rest micro-shares (
Shares = shares × 10^6, on-chain CTFbalanceOfprecision); все три величины неравенства — micro, сравнение micro-vs-micro. Оператор авторит/видит ЦЕЛЫЕ доли через UI scaling-seam (FieldKind 'shares', ×10^6 на submit, ÷10^6 на display), какbudget_per_market_micro_usdпоказывает $ при хранении μUSD. Публикуемый пример «100 долей» означает at-rest100_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.definitionJSON. Bot-level env-knob'ов нет. - Status: источник —
strategies.definition.total_budget_micro_usd. Companionreinvest_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_exitforce-exit) не могут заблокировать выход, следует из ЗНАЧЕНИЯ областиNewEntries, а не из места проверки. По той же причинеLifecycleHaltглушит обе стороны — у неё областьAllOrders. При взведённой причинеDrainBUY-аллокация отклоняется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>(atomictriggered+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(PKwallet_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 }(wirekind = "drain_mode_changed") — для аудита/SSE.
Двустороннее различие: Drain vs System Restart
| Свойство | Drain (единый стоп) | System Restart |
|---|---|---|
| Endpoint | POST /api/local/drain ({reason?, emergency_exit?}) / POST /api/local/resume / GET /api/local/drain | POST / 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), а не был слит с остановкой жизненного цикла, потому что обязан быть:
- Обратимым на живом процессе (
resume) и персистентным через Reset —LifecycleHaltAPI снятия не имеет вовсе (якорь A6: только полный перезапуск процесса). - Пропускающим SELL-выходы — область
DrainэтоNewEntries, областьLifecycleHaltэтоAllOrders. Различие областей и есть причина иметь две разные причины: свести их значило бы либо запереть выходы при операторском стопе, либо открыть их при остановке загрузки. - Старшим гейтом —
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_access | JSON 401/403, HTML/WAF block или истёкшая попытка authenticated read | Мгновенный вход в Degraded/Open |
definitive_maintenance | MatchingEngineRestart (HTTP 425 / post-only-mode) и 503 с телом биржи («Trading is currently disabled» / cancel-only) | Мгновенный вход в Degraded |
suspect | SubmitOutcomeUnknown (PoolError::RequestTimeout на submit, 5xx без тела биржи, неразбираемое тело), WorkerDied, 5xx на cancel и reconcile-GET | Порог по окну |
transport | PoolError::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 — вход в деградацию всегда от собственной телеметрии. Страница добавляет три вещи:
- Упреждение работ — страница
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"}. - Удержание HalfOpen — пока работы активны, переход
Degraded/Open → Recovering/HalfOpenподавлен. Снятие удержания НЕ разрешает пробу, наступившую по времени: отсчёт чистого окна начинается ЗАНОВО. Пока проба была запрещена, движок не пробовал, поэтому тишина этого интервала уликой восстановления не является. Окно считается от ПОЗДНЕЙШЕГО из двух моментов — входа вDegradedи снятия удержания, — так что пятиминутные работы не дают права пробовать сразу после их окончания. Само снятие восстановления не доказывает. - Корроборация — показание страницы попадает в текст алерта деградации («подтверждено статус-страницей» / «страница молчит»); на решение гейта НЕ влияет.
Отказ самого поллера — 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) -> {Open | Closed}` | Per-strategy finish_type (EndDate / GstOffset / GstLocalMidnight) + bounds |
| `ctx.debounce_gate(signal_key) -> {Armed | Suppressed}` | Armed-state в config.db knobs |
| `ctx.stop_loss_eval(position) -> {Hold | Exit}` | 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'а:
- Ограничивает размер риска отдельной позиции или совокупный бюджет? → должен совпасть с одним из 3 canonical RISK-LIMITS. Если не совпадает — это не RISK-LIMIT (HIDDEN, escalate). «Три» — это состав списка на сегодня, а не запрет на его расширение: новый canonical-лимит вводится явным решением оператора с обновлением этого раздела. Запрещено обратное — молча добавить ограничение риска мимо списка.
- Срабатывает на накопленном PnL / системном состоянии и блокирует поток? → EMERGENCY-GUARD. Требует ADR и НОВОГО ВИДА в закрытом перечне
StopKindс явно назначенными областью действия, местом в старшинстве и ключом домена кодов — плюс владельца, который его взводит и снимает. Пятый вид ломает сборку, пока все три вещи не названы; «дописать проверку рядом» вариантом не является. - Решает «когда / по какой цене» эмитить intent? → ENTRY-POLICY/EXECUTION. Живёт в стратегии, knob в
config.db. - Метрика/лог? → 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_visionA7+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; capabilitymanual-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.