Тема
Directional / Harvester — единственная активная торговая стратегия
Источник правды для стратегии
directionalс профилем Harvester. Соответствует якорю A1 канонического видения (single active strategy per ADR-024 ACCEPTED 2026-05-13). Объединяет предыдущие документыdirectional-prd.md(PRD) иdirectional.md(FSD) в одну точку входа. Все противоречащие документы устаревают по мере SSOT-конвергенции.
Контекст
directional — единственная стратегия в активном scope ядра (ADR-024). Профиль Harvester ориентирован на «сбор» дешёвых лотов выигрышного исхода в позднем окне жизни бинарного рынка Polymarket и удержание позиции либо до выхода по лимитной цене $0.99, либо до on-chain resolve через redeemPositions.
Стратегия работает на одном маркете за раз, одной стороне (single-side YES OR NO), без перекрёстных хеджей. Прибыль формируется из разрыва между ценой входа в полосе [0.95, 0.98] за акцию и финальной выплатой $1.00 за выигравшую долю, минус газ и потенциальный slippage stop-loss. Все остальные алгоритмы (binary-arb / multi-arb / single-token-mm / maker / maker-rebate / merge-arb) находятся в restoration backlog как будущие layers поверх extension API (ADR-025 PROPOSED), а не как параллельный код.
Product Vision
Что должно происходить с точки зрения торговли
Оператор хочет получать структурный edge на бинарных CTF-рынках Polymarket в стадии, когда исход уже почти определён (вероятность winning outcome ≥ 95 %), но книга всё ещё предлагает дисконт от номинала $1.00. Стратегия должна:
- Сканировать допустимые рынки (admission), вычислять выигрышный outcome по probability-модели.
- Когда
best_askвходит в полосу[0.95, 0.98], размещать лимитный BUY (по cheapest-first внутри полосы) с готовностью допокупать (DCA) при последующих fills. - Сразу после первого fill публиковать пассивный GTC SELL по
$0.99(scalp-exit, если оператор хочет выйти раньше resolve). - При проседании
best_bidниже сконфигурированного порога (live:0.80) — после debounce-периода — отменять обычный exit и выпускать SELL согласноexecution.stop_exit(config-driven stop-loss, ADR-027). - После UMA resolution автоматически выполнять
redeemPositions(через WS-событие или 10-минутный backstop).
Целевые рынки
Стратегия функционально работает на любом бинарном рынке (market.tokens.len() == 2). Operationally набор рынков сужается ключом discovery.tag_ids в strategies.definition (боевая строка harvester-engine-v2 2026-07-30: tag_ids: [104596], market_type: neg-risk). Env-ключа MARKET_TAG_FILTER в pm-engine НЕТ, а одноимённый ключ market_tag_filter в definition снят 2026-07-30 как никогда не читавшийся. Поддерживаются стандартные CTF-binaries и neg-risk-wrapped (gate настраивается per-strategy). Multi-outcome без бинарной обёртки — out of scope.
Целевая прибыль и риск
- Целевой edge на позицию: $0.01–$0.05 на акцию (вход 0.95–0.98 → exit 0.99 / redeem 1.00), за вычетом газа.
- Размер позиции на один маркет:
budget_per_market(live 2026-07-30: $20.00). Нижняя граница осмысленного значения — ДВА биржевых лота по потолку цены входа: клип заявки входа обязан покрывать сам вход и резерв на добор (см. инвариант 2 ниже), иначе движок вход не выпускает. - Общий бюджет стратегии:
total_budget_micro_usd(canonical RISK-LIMIT 3, strategy-level вconfig.db strategies.definition) с опциональным реинвестированием realized PnL (ADR-026). - Горизонт удержания: от минут до часов; до полного resolve — до нескольких суток на weather daily.
Current Behavior (pm-engine target; на VPS после атомарного rollout)
Сужение вселенной по названию рынка — ручка discovery.slug_filter
Обычно движок работает по ТЕГАМ, а тег даёт все города и все даты сразу. Ручка discovery.slug_filter позволяет оставить в работе только те рынки, чьё название содержит заданный кусочек текста, — например поймать один конкретный город на одну конкретную дату и запустить движок ЗАРАНЕЕ, ещё до того как этот рынок появится в списке.
Задавать нужно кусочек названия, а не название целиком. Названия у погодных рынков устроены так: highest-temperature-in-karachi-on-august-13-2026-32c. Значит подстрока karachi-on-august-13 оставит ровно один город и ровно одну дату, а все прочие корзины того же события пройдут (их несколько — это нормально, событие одно).
Две ловушки, которые РАСШИРЯЮТ вместо сужения — их не ловит никакая проверка, потому что «слишком широко» неотличимо от «намеренно широко»:
- Голое имя города пропускает все даты. Написав просто
chengdu, вы получите этот город на все дни, какие есть в листинге, а не на один. Дату надо писать в само значение. - Кусочек текста может попасть в чужое название.
york-on-august-14содержится внутриnew-york-on-august-14, поэтому пройдут оба города.
И одна ловушка, которая наоборот НИЧЕГО не пропустит: регистр букв имеет значение. Chengdu-on-august-14 не совпадёт с chengdu-on-august-14.
САМАЯ ОПАСНАЯ ловушка — сузить УЖЕ ВНУТРИ события. У погодного события много корзин (…-32c, …-33c, …-34c и т. д.), и слой отбора считает своё решение по ВСЕМ корзинам события сразу. Если написать в значение хвост с корзиной — karachi-on-august-13-2026-32c — движок увидит ОДНУ корзину и будет считать, что это всё событие целиком. Проверено по коду: снимок отбора строится из того набора, который остался ПОСЛЕ фильтра, и другого источника состава события у него нет.
Последствия разные и оба плохие, поэтому этого делать нельзя:
- при отборе «по рангу» единственная оставшаяся корзина автоматически выглядит фаворитом события (соперников в наборе просто нет), и вход, который на полном наборе был бы отклонён, может быть разрешён;
- при отборе «по расстоянию до фактического фаворита» усечённая ось чаще всего признаётся негодной, и входов не будет вовсе.
Отличить это по величинам обнаружения НЕВОЗМОЖНО: они покажут «до фильтра 40, допущен 1» — то есть внешне успех. Правило простое: значение обязано заканчиваться на дате и НЕ содержать хвост корзины. Перед запуском убедитесь, что значение пропускает ВСЕ корзины нужного события, а не одну.
Как проверить, что получилось. Сразу после запуска смотрите две величины движка: сколько рынков дал тег ДО фильтра и сколько осталось ПОСЛЕ. Если осталось 12 вместо ожидаемого одного события — значение задано слишком широко. Если осталось 0, а до фильтра было много, — это НОРМАЛЬНОЕ ожидание: нужная дата ещё не листингована, движок работает и ждёт. Если 0 было ещё ДО фильтра — сломан сам тег или недоступен источник, и фильтр тут ни при чём. Имена величин и таблица разбора — в docs/operations/alerts-and-monitoring.md.
Сохранить значение — не значит применить его. Движок читает настройки ОДИН раз, при запуске, и на лету их не перечитывает. Пока процесс не перезапущен, он продолжает работать со старым значением — то есть, возможно, торговать всем тегом, хотя в настройках уже стоит узкий фильтр. Различить эти два состояния можно только по величине «действующее значение фильтра», которую отдаёт сам запущенный процесс. Сверять надо её, а не строку в настройках.
Три обязательных условия, иначе движок не стартует: значение не может быть пустым или из одних пробелов (пустой кусочек текста содержится в любом названии, то есть фильтр был бы объявлен применённым и не сузил бы ничего); пробел по краям не «чинится» молча, а отвергается; вместе с фильтром обязательно должно быть включено периодическое переобнаружение — иначе рынок, появившийся после запуска, не будет найден никогда, а это и есть весь смысл затеи.
Что фильтр НЕ трогает. Уже открытые позиции и уже выставленные заявки не теряют сопровождения ни при каком значении: такие рынки берутся в работу отдельно и фильтру не подчиняются — и на такте, и после перезапуска. Запись рыночного потока (capture) фильтром тоже не сужается: она пишет всё событие целиком. Поэтому запись и боевую сессию под узким фильтром нельзя сравнивать напрямую — это разные наборы рынков.
Жизненный цикл сделки
admission scanner
→ Direct first-rejection chain (см. ниже)
→ ClosureWatcher проверка (asset не должен быть closed)
→ entry-policy gates (ADR-030 debounce в pm-engine не портирован)
→ BUY @ entry_price_base ± entry_price_offset_milli (зажим ≤ max_entry; `execution.initial_entry`)
→ on partial/full fill:
• публикация GTC SELL @ exit_price (recompute на total накопленную позицию)
• опциональный DCA top-up в пределах budget_per_market
→ stop-loss hot loop (best_bid < stop_loss_bid_threshold в течение stop_loss_debounce_secs):
• one-owner handoff обычного exit + SELL @ current best_bid согласно `execution.stop_exit`
• market → StopLossBlacklist на stop_loss_cooldown_hours
→ market resolved (UMA):
• WS-driven: on_market_resolved → Decision::Redeem
• Backstop: evaluate_periodic для застрявших позиций (REDEEM_BACKSTOP_SECS)Для Direct канонический порядок первого отказа задан цепочкой приписывания blocker_since (таблица GATES, crates/pm-engine/src/strategies/directional/runtime_state.rs:1882). ЧЕТЫРЕ головных исхода даёт ОДИН вопрос допуска AdmissionControl::admit (crates/pm-engine/src/engine/admission_control.rs), задаваемый в on_market_data до диспатча в Direct-ветку: ТРИ причины остановки в порядке старшинства StopKind::rank (crates/pm-engine/src/engine/trading_gate.rs:205) и прогрев — ВТОРОЙ последовательный механизм, спрашиваемый только у намерения, ОТКРЫВАЮЩЕГО позицию (выход, стоп, защитный повтор продажи и погашение через него не проходят):
- drain — остановка входов оператором и автоматикой;
- lifecycle halt — остановка жизненного цикла; ЕДИНСТВЕННАЯ причина, чья область действия — все заявки, включая выходы (
StopScope::AllOrders); - kill switch — рубильник по накопленному PnL;
- exchange health — деградация доступа к CLOB (
exchange-health-resilience); счётчик воронки называетсяblocked_exchange_degraded, а трасса допуска —exchange_health, и оба имени работа не переименовывает; - warmup — прогрев после открытия рубильника (готовность выводится из поколения открытия, а не сообщается оповещением);
- cooldown;
- post-only crossing cooldown → router-refusal deferral (отсрочка после отказа самого движка; учитывается теми же счётчиками risk limit / emit error);
- matching-engine restart deferral;
- outcome-side filter;
- deadline resolution → too-late → too-early;
- probability availability/floor;
- live ask → entry-price band → bid-depth liquidity → born-armed guard;
- selection: context → integrity guard → quote freshness → probability rank → event concurrency;
- LDI → regime → emergency cooldown;
- order-price bid availability → local-crossing guard;
- optional entry-quality: planned-size bid support above the prospective stop threshold → distinct Gamma-cohort confirmation;
- minimum size → duplicate signal → risk limit → emit error → entry emitted.
Каждая оценка инкрементирует только счётчик первого проваленного гейта: EntryGateCounters::note_admission_refusal (crates/pm-engine/src/strategies/directional/runtime_state.rs:1093) увеличивает РОВНО ОДИН счётчик по ответу допуска, разбором без _-ветви.
Две новых ступени на дашборд пока не доходят — НЕЗАВЕРШЕНО (проверено 2026-08-25). Ряды pm_engine_entry_funnel_total{stage} и поминутный ярус ClickHouse получают все ступени, потому что их источник — EntryGateCountersSnapshot::stages() (crates/pm-engine/src/strategies/directional/strategy.rs:15631 и :15652). Кадр entry_funnel_tick собирается ПОИМЁННЫМИ полями контракта pm-bot-events (crates/pm-engine/src/wire/bot_event_map.rs:1009), а полей blocked_lifecycle_halt в BotEvent::EntryFunnelTick нет. Знаменатель markets_evaluated при этом увеличивается ВЫШЕ вопроса допуска (…/strategy.rs:14958 против :15102), поэтому такой отказ уходит на дашборде в НЕПРИПИСАННЫЙ остаток цепочки — тот самый случай, о котором предупреждает комментарий к DELTA_KEYS (frontend/pm-engine-dashboard-web/src/lib/funnelAggregate.ts:344): «молча завышает остаток и рождает ложную „Ошибку parity“ на исправном движке». До закрытия разрыва порядок выше совпадает с blocker_since движка, но НЕ с воронкой дашборда.
Temporal reject завершает только ветку нового входа: мониторинг открытой позиции, stop-loss и exit продолжаются независимо. Оба temporal bound отключены (None) — deadline не разрешается и поток сразу переходит к probability.
Selection-аудит выполняется только для кандидатов, уже прошедших temporal, probability и book-safety гейты и имеющих live in-band best_ask. Поэтому out-of-window/no-ask оценки не вычисляют probability rank, не инкрементируют blocked_selection_* и не создают rank-veto rows.
Опциональная секция liquidity_profile — только наблюдение. При record_decisions=true движок добавляет append-only профиль обеих сторон книги ТОЛЬКО торгуемого токена — СВОИМ вызовом, независимо от записи трассы selection_signals (у трассы своя единица — рынок). Книга берётся из снимка, который потребило решение; отсутствующая сторона книги записывается нулём уровней, а не исключает решение из выборки. max_levels_per_side — единая явная граница bid/ask и для профиля решения, и для расширенного stop_dip_contexts; полные числа уровней и объёмы обрезанных хвостов сохраняются. Секция отсутствует — ради профиля книга не читается, stop-dip сохраняет прежнюю top-16-bid форму. Эти данные не участвуют ни в одном торговом решении и описывают только кандидатов, дошедших до selection-стадии; распространять вывод на весь discovered-набор нельзя.
На той же строке профиля живёт исход попытки входа — колонки entry_outcome (союзный тип из 19 значений: emitted, size_too_small, risk_limit_rejected, duplicate_signal, drain_halted, lifecycle_halted, exchange_degraded, entry_warmup_pending, post_only_crossing_race, matching_engine_restart, unresolved_submit_blocked, ambiguous_submit, signal_emit_error, order_emit_error, local_crossing_guard, no_reachable_exit_target, not_attempted, unknown, pre_migration; EntryAttemptOutcome::as_str, crates/pm-engine/src/ports/persistence.rs:1476) и entry_signal_uid. Единица строки названа ОТДЕЛЬНОЙ колонкой population (четыре значения — admission_evaluation / pre_attempt_rejection / order_attempt / router_accepted), а не выводится из пустоты других колонок. router_accepted означает «вызов отправки вернул Ok МАРШРУТИЗАТОРУ» и про биржу НЕ говорит ничего (в сухом прогоне за маршрутизатором стоит подставной клиент заявок); биржевая судьба заявки определяется боевым режимом и записями orders/fills по entry_client_order_id — см. docs/target/storage.md. Строку получают только ПОПЫТКА выставить заявку (ровно одна на попытку; повторная попытка по тому же рынку получает СВОЮ строку со своей формой книги) и ОТКАЗ ДО ПОПЫТКИ — одна строка на ЭПИЗОД отказа, не на тик. Оценка допуска строк не получает вовсе: её знаменатель — счётчик воронки entry_attempts. Основание — измеренное окно в ПОЛТОРЫ МИНУТЫ сухого прогона 2026-08-05: 6388 строк, из них 6215 (97 %) повтор отказа duplicate_signal, при 52 реально отправленных заявках; один рынок дал 1995 строк; темп — порядка 200 000 строк в час. Каденция ПОСЛЕ смены единицы измерена форвардом (сухой прогон 14 мин 10 с, чистая база): 482 строки — 169 router_accepted, 313 pre_attempt_rejection, ни одной admission_evaluation; темп ≈2 000 строк в час, таблица 1.94 МБ. Полный разбор популяций и правило закрытия эпизода — docs/target/storage.md. Отказ до попытки (включая вето) пишется терминальным исходом сразу вставкой, попытка — unknown с последующей write-once финализацией по идентичности строки (id, возвращённый вставкой), где число затронутых строк обязано быть ровно одно; NULL не используется. pre_migration движок не производит вовсе — это значение backfill'а миграции для строк, накопленных до появления колонки; оно заведено, чтобы unknown сохранил единственный смысл «авария записи». Дальнейшая судьба заявки сюда не копируется — она в orders, связь entry_signal_uid → signals.id → orders.signal_id. Полные правила и обоснование выбора места — docs/target/storage.md, блок «decision_liquidity_profiles.entry_outcome».
Одно значение появилось с unified-trading-gate-and-entry-readiness, и это ломает сравнение эпох. lifecycle_halted до этой работы своего значения НЕ имел: отказ маршрутизатора по остановке жизненного цикла и записывался как risk_limit_rejected — то есть выглядел в форензике как «бюджета не хватило». Строки, накопленные ДО выката, этого значения не несут и нести не могут; долю risk_limit_rejected до и после сравнивать нельзя, не разделив выборку по границе выката.
Значение kill_switch_blocked из первой редакции этой работы СНЯТО вместе с вариантом PnlKillSwitch (2026-08-25): авто-риск-стоп по накопленному PnL входит в Drain, а не в отдельную причину. Оно только писалось и обратно не разбиралось, поэтому исторические строки не затронуты.
Читать записанное — только штатным инструментом:scripts/analysis/forensics.sh liquidity-profile --engine (единственный читатель таблицы; форма ликвидности по обеим сторонам, возраст стакана, колонки вход/ордер и блок «расщепление попыток, не ставших входом»; единица строки — ПОПЫТКА входа либо ЭПИЗОД отказа до попытки, популяция печатается колонкой поп у каждой строки, а доли и когорты считаются ВНУТРИ своей популяции; судьбу позиции несёт только налившаяся попытка). Смежные подкоманды того же семейства: entry-dip (частота «вошли — сразу провалилось»), stop-counterfactual (стоп был лишним или спас), dust-exposure, rejected-sweep. Полное описание — scripts/analysis/README.md.
Границы применимости, установленные замером 2026-08-05 (сухой прогон, 210 решений по погодным рынкам; запросы — openspec/changes/outcome-liquidity-profile-recording/tasks.md §10):
- в бою слой выключен, строк там нет ни одной — выкат не выполнялся;
- популяция — строгое подмножество выборки решений: профиль пишется ВНУТРИ
try_emit_entry, поэтому решения, отбитые раньше (гейт качества входа в запрете, прогрев, пустая сторона книги), не дают ни строки, ни исхода; - рынок даёт максимум ДВЕ строки за эпоху Reset (первое вето и первый апгрейд в pass) — подтверждено наблюдением, а не только кодом: на двух независимых срезах одного прогона число строк каждого класса в точности равно числу различных рынков (61/61 и 149/149 при n = 210, 68/68 и 160/160 при n = 228);
max_levels_per_side = 32обрезал 84.4% строк и ЦЕЛИКОМ по бидам; при 80 обрезка нулевая с обеих сторон (максимум 72 уровня бидов, 29 асков);- возраст записанного стакана относительно решения: медиана — десятки миллисекунд (74.5 мс при n = 210, 77 мс при n = 228), p90 — около секунды, максимум РАСТЁТ с выборкой (32.7 с → 39.9 с); отрицательных нет;
- утверждение «глубина погодных стаканов обрывается после ~5 уровней» опровергнуто: бид-сторона — медиана 38 уровней, минимум 16, максимум 72, строк с ≤5 уровнями ноль, в топ-5 уровнях медианно 14% нотионала.
Опциональная секция entry_quality_confirmation работает только для Direct neg-risk входа и требует одновременно активный probability-rank selection и stop_loss. Она не вводит новую цену защиты: после вычисления реальной цены и канонического размера будущего BUY движок суммирует bid-доли на уровнях не ниже уже существующего проспективного stop-threshold. Вход поддержан только если этот объём покрывает весь рассчитанный размер. Затем кандидат должен полностью пройти цепочку на явно заданном числе разных Gamma-когорт подряд; повторные WS-тики того же Gamma-снимка счётчик не увеличивают, любой отказ сбрасывает наблюдение.
mode=shadow вычисляет и трассирует оба условия, но не блокирует и не отменяет заявки. mode=enforce до подтверждения не создаёт сигнал/BUY, а при последующей потере любого текущего допуска снимает только полностью неисполненный resting BUY первичного входа. Частичный fill уже является реальным инвентарём и этим контуром не отменяется. Отсутствующая секция — точный legacy-путь без дополнительного состояния, чтения и отмен.
Состояние на 2026-08-05. В боевой config.db секции нет; в локальном сухом прогоне она стоит в mode=shadow с двумя когортами. Срабатываний никто не наблюдал: счётчики pm_engine_entry_quality_* публикуются только на метрической поверхности, в лог не печатаются, и строки effective … config для этой секции при старте нет вовсе — по логу нельзя установить даже режим. Числа для enforce не существует: метод его вывода специфицирован отдельно (openspec/changes/entry-protection-threshold-derivation) и требует подтверждённых биржей наливов, которых у сухого прогона нет по построению. Прямой ввод в enforce, минуя наблюдение, запрещён.
Что этот контур НЕ объясняет. Замер сухого прогона 2026-08-05: позиций 3, и это число НЕ РАСТЁТ, тогда как BUY EXPIRED за время наблюдения поднялся с 569 до 683 при 2 BUY FILLED. Главный сток воронки «решение → позиция» — заявки, ИСТЕКАЮЩИЕ НЕНАЛИТЫМИ, а их терминализует срок жизни заявки, а не путь снятия из этого контура (он снимает BUY при ПОТЕРЕ пригодности кандидата). Приписывать проседание воронки нехватке поддержки бидов нельзя.
Active selection использует единственный канонический ранг полной когорты события: no_price_pip ASC, затем raw_member_index ASC. Точная ничья по цене поэтому не делит место. Одна логическая оценка читает одну immutable ревизию CurrentMarketContextStore; это же значение управляет вето и записывается как entry_probability_rank. Вход допущен при probability_rank >= selection.min_probability_rank; недоступный или просроченный канонический контекст закрывает вход. Градусы, groupItemThreshold и price-gap с лидером в admission не участвуют. Абсолютную цену кандидата по-прежнему ограничивают probability floor и live entry-price band, которые стоят выше в цепочке.
Стена ранга у стоящей первичной заявки
resting_entry_rank_wall повторно проверяет живой неисполненный остаток первичной BUY-заявки до любого её снятия или перевыставления. Правило не трогает DCA, защитные SELL-заявки и уже налитую часть первичного входа. Ранг берётся из той же immutable канонической ревизии, что у вето входа и rank_deterioration_exit; локальная competition-rank формула и MarketMetadata.selection не имеют права управлять отменой.
Заявка остаётся допустимой, только когда одновременно выполнены два условия:
current_rank >= selection.min_probability_rank;- если задан
entry_observation.max_rank_slip = S, тоadmission_rank - current_rank <= S. Движение от фаворита даёт неположительную разность и не ограничивается.
admission_rank — ранг фактического допуска кандидатуры этапом наблюдения входа. Он записывается в signals.context_json и потому остаётся тем же после перевыставления и рестарта. Новый entry-сигнал при заданном max_rank_slip без этого ранга не выпускается. Если база отсутствует у уже живой заявки, shadow записывает admission_rank_unavailable, а enforce снимает заявку: неизвестная база не доказывает соблюдение допуска.
Конфигурация версии 12 всегда содержит явный tagged-объект только с режимом. Исторические звенья версии 9 → 11 сохраняют прежнюю форму стены, а миграция 11 → 12 меняет только policy защитного выхода по рангу:
json
"resting_entry_rank_wall": { "mode": "off" }off не вычисляет решение. shadow применяет тот же предикат и на первом пригодном нарушении записывает «сняло бы», не меняя поток заявок. enforce на первом пригодном нарушении немедленно передаёт заявку общему OrderRouter с причиной RestingEntryRankWall; ожидания по числу снимков или времени нет. Недоступность канонического ранга — непригодность заявки: shadow записывает «сняло бы», а enforce запрашивает терминальную отмену через OrderRouter. Две живые первичные BUY одного сигнала также блокируют кадр и получают причину multiple_entries, без произвольного выбора одной заявки.
Закрытый результат стены сохраняется до конца кадра: on_market_data_direct, stale-снятие и reactive reprice не могут следом отправить вторую команду или новую BUY. При допустимом ранге эти пути работают как раньше. Частичный налив, резервацию и гонку отмены с наливом разрешает только канонический путь OrderRouter::cancel. TTL остаётся независимым владельцем срока заявки; если он терминализирует её раньше оценки стены, поздняя отмена не отправляется.
selection_signals имеет явную границу эпох: новые решения записывают nullable-колонки probability_rank, leaders_ahead и min_probability_rank; у исторических d_fav/distance/gap-строк они остаются NULL. Legacy-колонки сохраняются только для чтения старой истории и не переиспользуются под rank. Время production-границы фиксируется отдельно при будущем деплое; эта реализация сама production-конфиг не меняет.
Изменение canonical-entry-funnel-gate-order создаёт observability boundary: проценты воронки и selection-veto population до и после production deployment нельзя объединять в одну однородную временную серию. Engine-semantics boundary: 2026-07-25 16:17:31 UTC; обновлённые dashboard/SPA стали доступны в 2026-07-25 16:22:57 UTC.
Канонический стор ранга — единственный источник трёх денежных решений.CurrentMarketContextStore хранит уникальный total order полной сырой когорты Gamma-события. Вето входа, стена стоящей первичной BUY и защитный выход читают его через один typed adapter, который один раз фиксирует монотонное время и полную identity ревизии на логическую оценку. Legacy competition-rank остаётся только историческим и диагностическим значением: fallback к нему запрещён. PositionSnapshot.probability_rank и живой PositionRankProjection читают тот же стор, а rank_exit_current_rank несёт значение, принятое защитной FSM. Недоступность явно очищает обе колонки; короткое расхождение возможно только между доставкой двух sparse SSE-событий и исчезает на следующем секундном тике стратегии.
Семантические инварианты профиля Harvester (полный канонический реестр knobs и их семантика — в parameters-reference.md; конкретные значения для текущего live-инстанса — в подсекции «Пример настройки стратегии» ниже):
Entry trigger. Кандидат принимается, когда в полосу
[entry_price_band_lower, max_entry_price]попадает прогнозная цена заявки — результатentry_price_base + entry_price_offset_milliДО зажима, то есть ровно та цена, которую стратегия выставит (openspecentry-price-band-basis, 2026-08-13). Приentry_inside_spread_improvement_enabled=trueпрогнозом становится результат bounded one-tick алгоритма ниже; legacy-режим сfalseсохраняет эту формулу без изменений. Верхняя граница коридора и потолок цены заявки — ОДИН knobmax_entry_price_milli, отдельной второй границы нет. Измерение идёт ДО зажима намеренно: зажим втягивает любую цену в потолок, и полоса по зажатой цене имела бы непроходимую по построению верхнюю границу. Следствие-инвариант: на пройденной полосе зажим тождественен, поэтому «цена, по которой допустили» и «цена, по которой отправили» совпадают всегда.Отказ по недоступности цены называет ту сторону книги, которой требует НАСТРОЕННАЯ база:
BestAsk— сторону продажи (blocked_no_ask),BestBid— сторону покупки (blocked_no_bid),Mid— обе плюс инвариант спреда. При пассивной базе односторонний стакан без продажи вход НЕ отклоняет: заявку на покупку в него положить можно.До 2026-08-13 полоса мерилась по
best_askпри любой базе. Для боевой пассивной конфигурации (BestBid) это означало допуск по цене продажи при оплате цены покупки; замер 33 свежих корзин 2026-08-13 дал 19 отказов из 31 по величине, которую стратегия не платит.Cheapest-first preference (
directional-entry-buffer) — способность УДАЛЁННОГО крейта pm-bot: в pm-engine нет ниevaluate_periodic, ни сортировки кандидатов.Entry price source. В legacy (
entry_inside_spread_improvement_enabled=false) цена BUY-ордера =entry_price_base+entry_price_offset_milli(знаковый сдвиг), где база ∈ {BestAsk= best_ask,BestBid= best_bid,Mid= (best_bid+best_ask)/2}; результат ПРИВОДИТСЯ К СЕТКЕ РЫНКА ВНИЗ и зажимается в[один шаг рынка, приведённый вниз max_entry_price_milli](openspecorder-price-tick-grid-conformance). При включённом bounded режиме допустимы толькоDirect, нулевой offset и maker-onlyexecution.initial_entry. Оба top-of-book уровня очищаются от собственных BUY и SELL. При spread2..=5ticks выбираетсяBestBid + 1 tick,BestAsk - 1 tickилиMid; вне окнаBestBidостаётся на bid,BestAskиспользует bid, аMidсохраняет midpoint. Пустая/перевёрнутая/некратная шагу книга и рассинхрон own-book дают fail-closed исход; собственная заявка не выпускается, stale-withdraw не отменяет заявку.Правило сетки — почему оно здесь, а не «где-нибудь на отправке». Шаг цены — свойство КОНКРЕТНОГО рынка, а не константа вселенной: живой замер 2026-08-19 по вселенной обнаружения харвестера (
tag_id=104596) даёт0.001у 661 рынка и0.01у 429, причём 79 из 100 событий содержат ОБА шага среди своих корзин. Биржа отвергает заявку, чья цена не кратна шагу («price 0.939 breaks minimum tick size rule 0.01»), и именно так родился инцидент 2026-08-10/11: настройкаentry_price_offset_milli = -1на рынке с шагом0.01уводила цену с сетки, и 652 заявки подряд были отвергнуты при НУЛЕ входов.Направление приведения задаётся СМЫСЛОМ величины, а не стороной сделки: цена лимитной покупки — ВНИЗ (не платим дороже задуманного); лежащая цель продажи — ВВЕРХ (не продаём дешевле); рыночная цена продажи, взятая от живой заявки на покупку (капитуляция стопа), — ВНИЗ, иначе цена встала бы выше контрагента и FAK не исполнился бы; потолок в роли зажима — ВНИЗ; пол — ОДИН ШАГ рынка, а не единица канонической шкалы. Округление к БЛИЖАЙШЕМУ запрещено: при отступе меньше шага и сеточной базе оно вернуло бы саму лучшую цену продажи, а такая post-only заявка заведомо пересекает книгу.
Признанная плата решения оператора от 2026-08-11 (вариант B). Ключ остаётся в ТЫСЯЧНЫХ доллара, миграции конфигурации нет, но набранная величина — это ПОЖЕЛАНИЕ: она исполняется ближайшим допустимым уровнем сетки в безопасную сторону. Отступ меньше шага рынка буквально неисполним — на рынке с шагом
0.01набранная одна тысячная исполняется движением на ОДИН ЦЕНТ (0.94 → 0.93), а на рынке с шагом0.001та же настройка даёт0.950 → 0.949. Сегодняшний исход на грубой сетке — не «отступ в десятую цента», а отсутствие заявки вовсе, поэтому сравнивать надо «нет входа» против «вход на цент ниже». Фактическая цена выпущенной заявки попадает в durable-запись (orders.price_pip) — оператор видит исполненную величину, а не задуманную.Неизвестный либо опровергнутый биржей шаг — явный отказ, а не догадка. Подстановка умолчального шага запрещена (RULE-NEW-01): вход получает терминальный исход воронки
blocked_no_tick_size, стоящий ПЕРЕД ступенью доступности стороны книги (свойство РЫНКА доминирует над состоянием КНИГИ), а открытая позиция на таком рынке остаётся без цели выхода и без капитуляции стопа и поднимает алярм операторского класса. Отдельная ступеньblocked_no_exit_targetзакрывает вход, если на сетке нет цены цели выхода строго выше цены входа-кандидата: обнаружить это после налива значило бы держать позицию без достижимой цели. Базис цены ордера — ОН ЖЕ базис гейта входимости: полоса (п.1) проверяется по той же величинеentry_price_base + offsetпротив того жеmax_entry_price_milli, и арифметику даёт одна функция (prospective_entry_price), а не две. Фильтр вероятности от книги не зависит вовсе — он читает GammaoutcomePrices[i].Mid/BestBidдают maker-цену ниже best_ask (ордер пассивно сидит в книге);BestAskпересекает спред (taker — несовместимо сpost_only=true). По умолчаниюBestAsk, offset0. Sizing и cost считаются от итоговой цены ордера, не от best_ask. Минимальный размер входа: размер заявки — максимальное целое число долей, для которогоgross(order_price, shares) + fee(max_taker_fee_bps, order_price, shares) <= доступному budget_per_market; у maker-only (post_only=true) fee-reserve равен нулю. Единственный пол —min_size_shares, без скрытого умножения на два. Поэтому штатный бюджет одного лота торгует, если рассчитанный клип не меньше биржевого минимума. DCA сопровождает partial fill best-effort строго в остаткеbudget_per_market: фактическая стоимость уже налитой позиции включает реальную комиссию, а неналитый остаток заявки сохраняет резерв по cap, записанному вместе с ордером. При бюджете одного лота гарантировать ещё один полный лот на добор невозможно без нарушения канонического risk-limit. Этот residual dust-risk принят оператором 2026-07-31 вместо полной остановки всех входов малого бюджета; см. openspecentry-clip-completion-reserve.Max fill willingness. Лимит цены покупки задаётся knob'ом
DIRECTIONAL_MAX_ENTRY_PRICE— ордера, требующие более дорогих fills, отвергаются.Локальный гейт пересечения (post-only). Перед отправкой заявка сверяется с книгой ТОГО ЖЕ снимка: при
post_only=trueиorder_price >= best_askона не отправляется вовсе — такой BUY биржа отвергает гарантированно. Отсев происходит ДОentry_attempts, поэтому это отказ ДО попытки (local_crossing_guard, популяцияpre_attempt_rejection), а не неудавшаяся попытка; в воронке дашборда — отдельная ступень «Пересекло бы книгу». Отличать отpost_only_crossing_race: там заявка ушла и проиграла гонку снимка с биржей, здесь гонки нет. Измерение, породившее гейт (боевое окно 2026-08-06, ОДНА конфигурация): 766 из 766 попыток исходаpost_only_crossing_raceимелиorder_price >= best_askуже в снимке движка, то есть локальный отсев не теряет ни одной сделки. Условие срабатывания задаётся арифметикой: гейт молчит, покаentry_price_base + entry_price_offset_milliостаётся строго ниже лучшего аска, и срабатывает, когда смещение дотягивает цену до него — например при спреде, равном шагу цены, и смещении на тот же шаг. Числом отсева НЕЛЬЗЯ сравнивать разные политики цены входа: у каждой свой отбор рынков.Политика исполнения — отдельная ось.
executionобязательно задаёт пять ролей:initial_entry,dca,target_exit,stop_exit,rank_exit. Первые четыре роли независимо выбираютlimit_gtc,limit_gtd { ttl_seconds },fakилиfok; только limit-формы несутpost_only.execution.rank_exitуже: допустимы толькоlimit_gtcсpost_only=trueлибоlimit_gtdсpost_only=trueи действительнымttl_seconds. Ограничение действует и при выключенномrank_deterioration_exit, а несовместимая историческая строка не исправляется автоматически и останавливает сохранение, активацию либо запуск. BUY-роли дополнительно несутmax_taker_fee_bps: cap обязателен для любой taker-capable политики и запрещён для post-only maker.EntryFlow::DirectиEntryFlow::WatchArmопределяют, когда допустить вход, но оба используют однуexecution.initial_entry; arm-specific типа, buffer и потолка цены больше нет.Exit. На первый fill выставляется SELL по цене
exit_priceи политикеexecution.target_exit; пассивное ожидание существует только у limit-политики.DCA. Включено. На каждый fill — additional BUY в пределах
budget_per_marketиmin_size_shares; exit пересчитывается на total accumulated position приrecompute_exit_on_fill = true. Пол размера добора — ОДИН биржевой лот. Добор best-effort дотягивает непродаваемый остаток, только если оставшийся лимит рынка покрывает полный лот; скрытого превышенияbudget_per_marketнет. Требование оператора дословно: «докуп только в DCA, если покупка меньше лимита бюджета на маркет, то можно докупать до лимита, ограничений нет».После entry/DCA fill CLOB может коротко не разрешать SELL нового баланса. Primary-exit повторяется с фиксированным backoff; граница transient-lag измеряется от первого
BalanceSettling-отказа ТЕКУЩЕГО непрерывного эпизода, а не отopened_atпозиции. Поэтому долив старой позиции получает такое же ограниченное settlement-окно, но непрерывный отказ после границы эскалируется как возможная реальная потеря баланса. Успешный SELL-submit или terminal cleanup сбрасывает эпизод. Это reliability-константы кода, не strategy knobs и не env-overrides.TTL — часть политики каждой роли:
ttl_secondsсуществует только внутри выбранногоlimit_gtdкаждой из пяти ролей. Дляlimit_gtc,fakиfokэтого поля нет; наследования между ролями и глобального TTL нет.N >= 180— обязательная эффективная длительность GTD. Wire обязан передатьnow + 60 + Nиз-за security threshold Polymarket; фактическую терминализацию сообщает биржа событием отмены. Оператор выбирает значение явно через configurator. Пол 180 с — решение оператора 2026-08-13 (BUY_ORDER_TTL_MIN_SECS), строже биржевого правила: Polymarket ограничивает срок НА ПРОВОДЕ (минимум 180 с), а провод несётN + 60, поэтому биржу устроил бы иN = 120. Значение ниже пола отвергается при ЗАГРУЗКЕ конфигурации (старт движка и запись формы дашборда — оба черезvalidate_strategy_config); приN = 60биржа отбила 118 заявок подряд (2026-08-12). Обе величины — заданную и уходящую на биржу — движок печатает при старте для всех ролей строкойeffective directional order execution, а форма показывает под каждым GTD-полем.
Auto-redeem (hybrid). WS-event-driven primary path через
Strategy::on_market_resolved+ periodic backstop вevaluate_periodic(константаREDEEM_BACKSTOP_SECS). Backstop сканируетself.positionsи эмититDecision::Redeemдля cids, отсутствующих вMarketCache::active_markets(), у которыхfilled_atстарше backstop-окна. Идемпотентно: обе ветки удаляют позицию после emit.Stop-loss (config-driven, ACTIVE на live
harvester). Hot path фиксирует первый sub-threshold tick; на recovery таймер сбрасывается. После непрерывного пробоя выполняется one-owner handoff от обычного выхода и строится защитная продажа с политикойexecution.stop_exit; для немедленных политик (fak/fok) размер и цена выводятся планом по видимой книге (§«Стоп-контур pm-engine» ниже), а не берутся безусловно по текущемуbest_bid. FAK/FOK/limit здесь являются явным выбором оператора и не меняют владение защитным контуром. После исполнения market blacklist'ится наstop_loss.cooldown_seconds.Market filter. Operationally сужено через
discovery.tag_ids+discovery.market_typeвstrategies.definition— ЕДИНСТВЕННЫЙ фильтр набора рынков. Функционально стратегия работает на любом binary; выбор категорий — operational decision.
Пример настройки стратегии
Конфигурационный snapshot с production-инстанса
harvester-liveна дату 2026-05-20. Это иллюстрация типичных значений knobs, не SSOT-эталон поведения. Реальная настройка живёт вconfig.db strategies.definitionи меняется оператором через UI без правки документации или кода. Семантика каждого knob — вparameters-reference.md.
| Knob | Значение | Назначение |
|---|---|---|
entry_price_band_lower | 0.95 | нижняя граница полосы цены входа |
entry_price_base | best-ask | источник базовой цены входа |
offset_cents | 0 | сдвиг от базовой цены входа |
entry_inside_spread_improvement_enabled | false | bounded one-tick улучшение внутри спреда 2..=5 ticks; требует Direct, нулевой offset и maker-only initial BUY |
max_entry_price_milli | 980 (= 0.98) | верхняя граница полосы входа И потолок цены заявки |
DIRECTIONAL_MIN_PROBABILITY | 0.95 | минимальная вероятность winning outcome |
execution.initial_entry | limit_gtd / limit_gtc / fak / fok | независимая форма первого BUY; taker-capable BUY требует свой max_taker_fee_bps |
execution.dca | независимо от initial entry | форма добора и собственный BUY fee cap |
execution.target_exit | например, maker limit_gtc | форма обычного SELL по цели |
execution.stop_exit | например, fak | форма защитного SELL после stop-триггера |
execution.rank_exit | maker-only limit_gtc либо limit_gtd с post_only=true | форма постепенной SELL-защиты по ухудшению ранга; FAK/FOK принадлежат не этому контуру |
exit_price | 0.99 | цена пассивного GTC SELL exit |
recompute_exit_on_fill | true | пересчитывать exit на total накопленную позицию |
min_size_shares | 5_000_000 (= 5 долей) | минимальный размер ордера (биржевой лот); at-rest micro-shares (× 10^6, on-chain CTF balanceOf precision). UI вводит/показывает 5 целых долей, scaling-seam масштабирует в micro. Единственный пол заявки ВХОДА и пол ДОБОРА равны этому значению; скрытого удвоения нет |
budget_per_market_micro_usd | 5_000_000 (= $5.00) | бюджет на один маркет |
stop_loss_enabled | true | stop-loss активен |
stop_loss_bid_threshold | 0.80 | порог по best_bid для триггера stop-loss |
stop_loss_cooldown_hours | 5 | период blacklist маркета после stop-loss |
stop_loss_debounce_secs | 5 | debounce непрерывного bid < threshold |
REDEEM_BACKSTOP_SECS | 600 | константа кода: окно periodic backstop для auto-redeem |
discovery.tag_ids | [104596] (боевая строка 2026-07-30) | operational сужение admission scanner |
Knobs entry_debounce_window_ms, entry_debounce_max_band_drift и risk_kill_switch относятся к удалённому pm-bot и не входят в текущий DirectionalConfig; это не «выключенные» поля боевой конфигурации.
Текущие параметры
Полный канонический реестр knobs стратегии (5-классовая taxonomy: RISK-LIMIT / EMERGENCY-GUARD / ENTRY-POLICY / EXECUTION / OBSERVABILITY) — в parameters-reference.md. Здесь — только семантические группы:
- RISK-LIMIT (canonical 3):
shares_per_market_max,budget_per_market_micro_usd,total_budget_micro_usd(+ companionreinvest_realized_pnlper ADR-026). Все 3 canonical RISK-LIMITS живут strategy-level вconfig.db strategies.definition; bot-level env-knob'ов в target нет. Ограничения по доле баланса кошелька в списке НЕТ — оно удалено 2026-07-28 решением оператора. - EMERGENCY-GUARD: runtime
EmergencyGuardпроцесса; отдельногоrisk_kill_switchв strategy JSON нет. - ENTRY-POLICY:
finish_type+entry_window_seconds+late_entry_seconds;entry_price_base+entry_price_offset_milli;min_probability_milli;max_entry_price_milli; явные freshness-порогиdiscovery.orderbook_max_age_msиdiscovery.universe_max_age_secs. Вероятностный гейт использует GammaoutcomePrices; отдельного live-ask rescue в pm-engine нет. Живой стакан участвует в цене, band/depth и freshness-гейтах, но не подменяет отсутствующую/низкую Gamma-вероятность. - EXECUTION:
execution.{initial_entry,dca,target_exit,stop_exit,rank_exit}с независимой tagged-политикой;ttl_secondsтолько уlimit_gtd;max_taker_fee_bpsтолько у BUY-роли и только для taker-capable политики; такжеexit_target,recompute_exit_on_fill,min_size_sharesи вложенная секцияstop_loss. - OBSERVABILITY: directional entry-debounce counters отсутствуют вместе с непортированным ADR-030 механизмом.
Все strategy knobs живут ИСКЛЮЧИТЕЛЬНО в config.db (strategies.definition JSON, ADR-015). Env используется только для infrastructure (RPC, paths, labels, BOT_WALLET_LABEL, BOT_STRATEGY_LABEL).
Anchor source и entry-window gate
Per A9 canonical vision, entry-gate decisions driven by capability outcome-tradability-detection:
EndDate— GammaendDateas-is. Надёжно только для 5-min crypto (tag 102892). На weather daily совпадает 0/55 раз (forensics 2026-05-16).GstOffset { offset_seconds }—gameStartTime + offset_seconds. Для sports/esports. Примеры: NBA 12 600 s, UEFA 18 600 s, CS2 BO3 ~3 h, Dota2 BO5 ~10 h, Valorant ~6 h 45 m.GstLocalMidnight—gameStartTime → next local midnightв таймзоне города изweather_city_slugчерез embedded GeoNamescities15000. Для weather daily.
Два optional seconds-grain bounds: entry_window_seconds (refuse если secs_to_anchor > N, too-early) и late_entry_seconds (refuse если secs_to_anchor < N, too-late). Оба None ⇒ predictive gate disabled, бот полагается только на reactive closure.
Verdict surface: AllowEntry | RefuseTooEarly | RefuseTooLate | RefuseUnresolved | PredictiveGateDisabled. RefuseUnresolved — fail-closed (missing gameStartTime для GstOffset, или unresolved weather_city_slug для GstLocalMidnight).
Reactive closure detection (ClosureWatcher)
Параллельный канал с entry-window gate. Два источника подают в единый append-only ClosureWatcher registry по asset_id:
- Gamma poll —
closure_poll_taskобходит активный outcome set каждыеclosure_poll_interval_seconds(default10) и кормитaccepting_orders / closed / resolved. - User-WS cancel-reason fallback —
parse_user_ws_messageфорвардитcancellation_reasonчерезclosure_watcher.on_user_ws_cancel. Pinned closure-reason set:MARKET_CLOSED,MARKET_NOT_ACCEPTING_ORDERS(+ lower_snake variants); unknown reasons — WARN.
На closure detection watcher эмитит OutcomeClosed; closure_event_subscriber диспатчит Decision::CancelByLifecycle. Entry-gate сверяется с is_closed(asset_id) перед допуском входа.
Never use a computed deadline as a closure signal — только observable closed / accepting_orders через ClosureWatcher.
LDI entry gate (Phase I6d-3, pm-engine)
В дополнение к существующим entry conditions (min_probability_milli + entry_price_band_*), harvester в pm-engine использует Liquidity Drain Index (LDI) как microstructure phase classifier. Цель — отличить sustained convergence от ложного движения цены на тонкой ликвидности.
Entry допускается только когда все условия pass:
- Spot probability ≥
min_probability_milli(existing). - Best_ask в
[entry_price_band_lower_milli, max_entry_price_milli](existing). ldi >= min_ldi_for_entryANDphase >= min_phase_for_entry(NEW).
ldi — композит шести компонент книги (refill_decay, impact_acceleration, ask_persistence_decay, flow_consistency, depth_collapse, diversity_collapse) с весами из user-spec формулы. phase — дискретная классификация Uncertain / PossibleConvergence / ConvergenceStarted / StrongDrain / TerminalPhase. Все веса, окна EMA и thresholds — конфигурируемые knobs (ldi.compute).
Стартовые defaults для harvester'а:
min_ldi_for_entry: 0.50(совпадает с порогомConvergenceStarted).min_phase_for_entry: ConvergenceStarted.
Подбираются на реальных торговых данных; в коде стратегии нет hardcoded порогов (feedback_no_hardcoded_strategy_params).
ОСОЗНАННОЕ СОСТОЯНИЕ ЖИВОГО КОНФИГА (2026-07-04, оператор): на текущем live-инстансе
harvester-engine-v2LDI-гейт НАМЕРЕННО отключён — оба порога стоят в пропускающих значениях (min_ldi_for_entry ≈ 0,min_phase_for_entry = Uncertain), так что условие (3) выше — тавтология и НЕ фильтрует ни один вход. Причина: Ф3-форвард-окно собирается БЕЗ LDI-фильтра, чтобы увидеть «сырое» поведение алгоритма и решить о самом фильтре на данных, а не на догадках. Следствие для интерпретации: любая Ф3-статистика (win-rate, EV, dust-частота) отражает поведение БЕЗ LDI-гейта; включение гейта — ОТДЕЛЬНОЕ решение после EV-вердикта, не смешивать до/после в одной выборке. Это не баг конфигурации, а зафиксированный выбор сбора данных.
Если LDI snapshot недоступен (token не tracked в MarketStateStore или history пуста на cold start), поведение управляется allow_entry_without_ldi:
false(fail-safe default) — entry blocked, debug log.true— entry allowed, warn log. Используется только для bootstrap / smoke сценариев.
Forensics: при каждом решении entry эмитится structured log с ldi, phase, components и пороговыми значениями — оператор может в логах видеть, почему конкретный outcome был допущен или отклонён.
Реализация: pure-функция evaluate_ldi_gate в crates/pm-engine/src/strategies/directional/config.rs; вызов из DirectionalStrategy::on_market_data после probability+band checks. LDI compute proxied через StrategyContext::ldi_snapshot. Боевой контекст (EngineStrategyContext) метод переопределяет и берёт входы расчёта (кольцо событий + карта времён жизни ценовых уровней) под read-lock'ом MarketStateStore, без копии MarketState; сам расчёт идёт вне lock'а. Копирующий trait-default (MarketStateStore::get + compute_ldi_snapshot) остался только для тестовых реализаций контекста. Результат обеих веток совпадает, включая cold-start-контракт «нет собственной wire-истории ⇒ None ⇒ решает allow_entry_without_ldi».
Стоп-контур pm-engine: quote ARMS, tape FIRES, коррборация подтверждает
Стоп-лосс pm-engine — трёхступенчатый контур поверх базового трио knobs (stop_target / cooldown_seconds / debounce_seconds).
stop_target — тегированный вариант, а не число: absolute { price_milli } (фиксированный порог по best_bid) либо relative_to_entry { pct_below_bp } (порог = цена входа минус базисные пункты). Разрешённый порог используется ARM'ом, FIRE'ом и трассой решения единообразно. Ключа threshold_milli в конфигурации БОЛЬШЕ НЕТ — секция stop_loss помечена deny_unknown_fields, поэтому попытка вписать его в config.db роняет загрузку определения стратегии fail-fast'ом на старте.
ARM (котировка). Устойчивый провал
best_bid < thresholdдлительностью ≥debounce_secondsвзводит стоп. Котировка сама по себе НЕ стреляет.CONFIRM (лента). При
trade_confirm_enabled = trueвыстрел разрешён только по реальному third-party SELL-принту с ценой≤ threshold, прошедшему фазовый dust-гейт (trade_confirm_dust_shares/trade_confirm_dust_micro_usd; в устойчивой фазе notional-гейт снят, shares-гейт =min(конфиг, 0.5 × позиция)). Принты — прямые SELL этого токена ЛИБО синтетические комплемент-принты (агрессивный BUY Yes ≡ SELL No по1 − p, тот же объём;MarketStateStoreсинтезирует и помечаетsynthetic_complement).Коррборация (двухисточниковая проверка качества принта). Квалифицирующий принт подтверждает стоп только по одному из правил:
- Правило 1 — безусловное: принт «глубокий И крупный» — цена
≤ threshold − confirm_deep_margin_milliИ размер≥ confirm_strong_shares. Согласие второго источника не требуется. - Правило 2 — прямой принт требует согласия комплемента: последний синтетический принт окна
confirm_corroboration_lookback_secs(текущий взгляд Yes-ленты на цену NO,1 − p_yes) тоже≤ threshold. Нет синтетики в окне — согласие по умолчанию, но только если окно ПОЛНОСТЬЮ покрыто синтетическим источником (см. предусловие ниже). - Правило 3 — синтетический принт требует согласия прямой ленты: в том же окне нет прямых принтов с ценой
≥ threshold + confirm_opposite_margin_milli. Нет прямой ленты в окне — согласие по умолчанию, и снова только при полном покрытии прямого источника на этом окне.
Предусловие полноты — часть правил 2 и 3, а не примечание к ним. Согласие по умолчанию действует ТОЛЬКО когда требуемый правилу источник доказанно непрерывно покрыт на ВСЁМ каузальном окне кандидата
[принт.ts − lookback, принт.ts]. Если источник на этом окне не покрыт (не подписан, был разрыв, эпоха началась позже) — это НЕПОЛНОТА, а не «покрытое пустое окно»: кандидат съедается как неполный (счётчикstop_confirm_incomplete_candidates, метка кадраincomplete_window), подтверждения не даёт, позиция остаётся ARMED до следующего кандидата с полным окном. До правил 2/3 такой кандидат вообще не доходит. Правило 1 — единственное исключение: оно свидетелей не спрашивает и от их покрытия не зависит.Отказ коррборации = стоп остаётся ARMED до следующего qualifying-принта; причина видна в INFO-логе (throttled) и счётчиках
stop_confirm_blocked_complement/stop_confirm_blocked_tape.Принятый trade-off (осознанная цена анти-whipsaw защиты): при обвале, печатающем только мелкие прямые принты (не проходящие правило 1), с устаревшим несогласным синтетическим свидетелем в окне выстрел может быть отложен до
confirm_corroboration_lookback_secs(live: 120 с) — пока несогласный свидетель не выйдет из окна. Граница trade-off'а: правило 1 (глубокий ∧ крупный принт) — эскейп без задержки для любого реального обвала масштаба.Полнота окна свидетелей: stop-confirm и stop-flow читают отдельную per-token trade-only историю. У неё нет count-limit: Book/BBO/PriceChange продолжают жить в диагностическом ring на 500 записей, но не занимают ни одного места в causal tape. Trade-история удерживается до
untrackтокена; это осознанный выбор correctness-over-memory — даже всплеск из >500 сделок не вправе потерять свидетеля.Источники direct и synthetic-complement имеют независимые эпохи покрытия, и покрытие принадлежит ПОДПИСКЕ, а не потоку данных: эпоху открывает первый доставленный кадр подписки, а разрывы (переподключение, переполнение канала, тишина источника) пишет боевой код там, где меняется жизненный цикл подписки. Пустое окно даёт default-consent только если ТРЕБУЕМЫЙ правилу источник наблюдался на всём интервале без разрыва. Отсутствующий источник — это НЕПОЛНОТА, а не покрытое пустое окно, и она удерживает обычные правила 2/3 fail-closed; правило 1 deep-and-strong остаётся безусловным. Какой именно источник сделал окно неполным, называют и лог, и строка
stop_dip_contexts(своя эпоха, свойcontinuous_since, своя причина разрыва) — при включённом RTDS прямой источник это соединение RTDS, а комплемент остаётся анонимным.Каждый qualifying-print классифицируется один раз на ARM-эпизод; состояние общее для market-data и
on_tick, и veto/incomplete не пересматриваются. Хранится МНОЖЕСТВО потреблённых идентичностей, а не курсор: запись, пришедшая позже, но с более ранним биржевым временем, встаёт ПОЗАДИ фронтира, и курсор проглотил бы её нерассмотренной. Переходыклассификация → гейт потока → защёлкавыполняются ОДНОЙ транзакцией под мьютексом решаемого сигнала, а у эпизода есть поколение: восстановление котировки между классификацией и защёлкой обесценивает решение, и защёлка с устаревшим поколением отбрасывается, а не воскрешает подтверждение. Принятое подтверждение latch'ится до recovery/terminal (защёлка хранит САМО подтверждение — принт, границы его окна, покрытие и класс, а не пустышку), поэтому partial FAK может продолжить продажу остатка без повторной интерпретации той же сделки. Recoverybest_bid ≥ thresholdочищает debounce, эпизод, его processed-множество и защёлку ОДНИМ набором.Число кандидатов, съеденных как неполные, публикуется отдельным счётчиком
stop_confirm_incomplete_candidates(единица — КАНДИДАТ, кадр может поднять его больше чем на единицу). Прежнийstop_confirm_witness_window_truncatedсохраняет имя и единицу — один инкремент на КАДР с непригодным окном.- Правило 1 — безусловное: принт «глубокий И крупный» — цена
Исполнение подтверждённого стопа для немедленных политик (execution.stop_exit = fak либо fok) строит план по видимой стороне книги — реализация plan_protective_sale, crates/pm-engine/src/strategies/directional/exit_size_decision.rs — а не продаёт безусловно по best_bid. Force-exit и адресная продажа оператора (bounds = None, вне стоп-политики) книгу не читают и остаются прежним FAK по best_bid; покоящиеся политики (limit_gtc/limit_gtd) заявку немедленно не наливают, плана для них нет, и размер с ценой остаются прежними.
Учтённый остаток позиции и потолок (уточнение биржи из decide_exit_size либо сам остаток, если биржа ничего не называла) квантуются к лоту CLOB, книга бида очищается от собственных покоящихся уровней, и план разбирает семь исходов (SalePlanKind::ALL):
- вершина поглощает (
top_absorbs) — лучший уровень книги вмещает весь остаток без сублотового хвоста; - остаток продаваем (
remainder_sellable) — заявка на весь остаток не поглощается вершиной целиком, но то, что не исполнится, само останется не меньше лота (частичный налив для FAK штатен, отдельной обработки не требует); - потолок биржи (
named_cap) — биржа сама назвала доступное количество ниже учтённого остатка; план его не оспаривает и не пытается наполнить сверх; - сдвиг вглубь (
deepened) — вершина не покрывает весь остаток, но более глубокий уровень книги покрывает его целиком — цена заявки сдвигается на этот уровень; - заранее ужато (
shrunk) — полного покрытия нет ни на одном уровне: FAK продаёт с вершины книги остаток минус один лот CLOB, FOK ищет ближайший по убыванию цены уровень книги, чья накопленная глубина вмещает урезанный размер не меньше биржевого минимума, и продаёт по цене этого уровня; - хвост неустраним (
tail_unavoidable) — ни полного покрытия, ни допустимого ужатия нет: продаётся весь лотовый остаток поbest_bid, а возможный хвост признаётся неустранимым именно этой попыткой; - книга неизвестна (
book_unknown) — книги нет вовсе либо после приведения к полу цены исполнения на ней не осталось ни одного уровня; план строится поbest_bid.
FAK и FOK разбирают эти исходы разной лотовой арифметикой. FAK считает ожидаемый продаваемый остаток от СЫРОГО, ещё не квантованного к лоту размера позиции — квантование от уже урезанного booked_size теряло бы сублотовую часть и превращало её в ложный ноль ровно там, где она и застревала в боевом инциденте, ради которого заведён этот шов. FOK не различает частичный и полный налив: недопокрытие меньше лота всё равно не даёт заявке «всё или ничего» исполниться целиком, поэтому исход remainder_sellable для FOK НЕДОСТИЖИМ вовсе — любой неполный налив отменяет всю заявку.
Пол цены исполнения (stop_loss.execution_floor_margin_milli) — ЕДИНСТВЕННАЯ нижняя граница плана: если он задан и приведённый к действующей сетке рынка best_bid ниже него, план не строится ни для одного из семи исходов (SaleDecision::FloorUnreachable), заявка не подаётся, стоп остаётся ARMED. Оба гейта пола — сырой, до построения плана, и построенный по плану (после приведения цены к сетке, потому что внесеточный уровень покоящейся заявки книги может пройти пол сырым и уйти под него после приведения) — ведут к ОДНОМУ наблюдаемому исходу: note_stop_execution_floor_held_gate, счётчик pm_engine_stop_execution_floor_held_total. Пол ОПЦИОНАЛЕН и по умолчанию ОТСУТСТВУЕТ: без него (floor = None) гейт пола не участвует вовсе, план строится всегда (при известном шаге рынка), и поведение капитуляции побайтово то же, что было до появления пола (решение оператора 2026-07-14 о снятии прежнего exit_floor = avg_entry − max_loss).
Успешный план создаёт заявку одной из ролей ExitOrderRole (crates/pm-engine/src/ports/intent_role.rs). Исход shrunk — ЕДИНСТВЕННЫЙ, где заявка сознательно меньше учтённого остатка, и только он несёт ужатую роль: sharp_stop_partial под неподтверждённым котировочным стопом, sharp_stop_confirmed_partial под стопом, подтверждённым лентой. Остальные шесть исходов плана, как и путь без плана вовсе (покоящаяся политика, force/manual-exit), продают весь решённый размер и несут обычную роль стопа (sharp_stop/sharp_stop_confirmed). Роль, а не наблюдаемый исход биржи, определяет ЭФФЕКТ полного налива ИМЕННО ЭТОЙ заявки на позицию (ExitOrderRole::sell_position_effect, тотальная функция без умолчания): у ужатых ролей полный налив УМЕНЬШАЕТ позицию, не закрывает её — остаток продолжает жить под тем же стопом, а защитная выемка учёта неожиданного хвоста на нём не включается; у всех остальных ролей полный налив ЗАКРЫВАЕТ позицию целиком. Два вопроса — исполнена ли ЗАЯВКА целиком (решает биржа) и закрывается ли ПОЗИЦИЯ целиком на этом наливе (решает роль) — ортогональны именно из-за исхода shrunk: без него их можно было бы путать, приравнивая любой терминальный SELL к закрытию позиции. Успешная отправка плана считается счётчиком pm_engine_stop_exit_plan_total{kind} — ../operations/alerts-and-monitoring.md §«Prometheus /metrics endpoint».
Partial-филл принимается на любом исходе плана; остаток остаётся под подтверждённым latch-эпизодом.
Отложка окном рестарта matching engine — молчание ограничено. Отказ капитуляции с MatchingEngineRestart (HTTP 425 / post-only-only окно рестарта) — ОТЛОЖКА, не сбой: стоп остаётся ARMED, per-tick повторов в окне нет, ре-атака после retry_after (при отсутствии подсказки — дефолт 120 с, RESTART_DEFER_DEFAULT_MS). Per-defer алярм подавлен НАМЕРЕННО — окно работ биржи не операторская авария; видимость даёт троттлённый WARN (не чаще 30 с, RESTART_DEFER_LOG_INTERVAL_MS). Молчание при этом ОГРАНИЧЕНО: когда по одному сигналу накапливается ≥5 отложек подряд (STOP_RESTART_DEFER_ESCALATION_COUNT; считаются только отказы submit'а — gate-hit'ы внутри defer-окна доучитывают лишь время) ИЛИ суммарное время эпизода без исполнимого стопа превышает 10 мин (STOP_RESTART_DEFER_ESCALATION_MS), поднимается Critical-алярм stop_deferred_restart_prolonged через канал stop-alarms (троттл 15 мин на сигнал, STOP_ALARM_THROTTLE_MS) — позиция, счётчик отложек и время без защиты в тексте. Механика отложки не меняется: стоп остаётся ARMED и ре-атакует дальше. Эпизод сбрасывается успешным филлом капитуляции (full или partial) либо терминалом сигнала. Наблюдаемость: gauge pm_engine_stop_restart_defer_active (позиций в отложке сейчас) + counter pm_engine_stop_restart_defer_seconds_total.
Протухшая книга — fail-open (решение оператора 2026-08-06). Возрастной гейт свежести применяется ТОЛЬКО к BUY-входам (EntryFreshnessGuard::check_buy); стоп-контур свежестью книги НЕ придерживается — оценивает и стреляет по последней известной книге. Асимметрия, обосновывающая выбор: ложный выстрел по протухшему дипу ограничен лимитом FAK — заявка встречает реальные (восстановившиеся) биды и исполняется не хуже честного срабатывания стопа на том же уровне, либо чисто убивается без филла; fail-closed же (придержать стоп при старой книге) замораживает защиту ВСЕХ позиций одновременно ровно на время инцидента данных, когда обвал вероятнее, — неограниченная просадка против ограниченной лимитом ранней продажи. Пропуск обвала из-за замороженной книги — проблема отсутствия данных, не политики гейта; её смягчают самовосстановление WS-контура и trade-confirm подтверждение по ленте (ступень CONFIRM выше). Фиксация решения в слоёвой модели защит — ../target/risk-and-emergency.md §«Политика стопа при протухшей книге».
Все четыре confirm-ручки — REQUIRED-ключи config.db strategies.definition (RULE-NEW-01, fail-fast при отсутствии): confirm_deep_margin_milli, confirm_strong_shares (micro-shares), confirm_opposite_margin_milli, confirm_corroboration_lookback_secs (1..300 с — ограничено data-provisioning окном движка). Миграция: docs/operations/config-migration-2026-07-17-stop-confirm-corroboration.sql.
Защитные entry-вето стоп-контура
- Born-armed вето. Вход запрещён, если
best_bidна момент решения уже нижеstop_loss.threshold: позиция родилась бы со взведённым стопом. Порог выводится из stop-loss конфига (отдельной ручки нет); приstop_loss = Noneвето неактивно. Счётчикblocked_born_armed. - «1 бакет на событие» (
selection.max_concurrent_per_event, REQUIRED приselection.mode ≠ off, ≥ 1). Кандидат neg-risk события ветируется, когда занятых сиблингов уже ≥ порога; сиблинг занят, пока на нём жив активный сигнал (resting-вход, OPEN или SELLING позиция) — закрытая позиция корзину освобождает. Сидит в selection-гейте после distance-veto. Счётчикblocked_event_concurrency.
Scope по entry-flow. Оба вето живут в Direct-цепочке:
- Born-armed в WatchArm отсутствует BY DESIGN: модель arm-on-dip противоречит вето по уже низкому биду.
- Selection-гейты в WatchArm не реализованы; комбинация
selection.mode ≠ off+entry_flow = WatchArmотклоняется на загрузке fail-fast.
Известные ограничения current implementation
shares_per_market_maxиreinvest_realized_pnlреализованы и входят в конфигуратор; прежние GAP-01/GAP-03 закрыты.- Full periodic reconciliation остаётся отдельной незавершённой способностью; bootstrap reconciliation и reconnect reconciliation не являются её заменой.
- Directional entry-debounce ADR-030 не портирован в pm-engine.
- Stop-loss slippage: без сконфигурированного пола цены исполнения (
floor = None, умолчание) капитуляция на gap-down может давать avg fill значительно ниже threshold (наблюдаемо: 1¢–44¢) — план по книге (см. §«Стоп-контур pm-engine» выше) может СДВИНУТЬ цену глубжеbest_bidради полного покрытия остатка, но не выше него. Trade-off принят; сконфигурированныйstop_loss.execution_floor_margin_milliсужает этот диапазон снизу ценой удержания стопа ARMED, если сам пол недостижим.
Target Behavior (после unify-orders-positions-signals-events)
Что изменится
Реимплементация с нуля в crates/pm-engine/src/strategies/directional/:
- Signal → OrderIntent через StrategyContext SDK — стратегия не вызывает
OrderClientнапрямую; эмититOrderIntentчерез context,OrderRouterприменяет 3 canonical RISK-LIMITS централизованно через allocation-id pattern (design D15). - EventBus + outbox для journaling —
BotEventидёт в единую шину, persistence — через outbox pattern, без прямых writes из strategy hot loop. - Единая реальная execution boundary — стратегия не ветвится по режиму; production
OrderClientвсегда биржевой, а--probeограничивает объём через отдельные live safety gates. Детерминированные doubles существуют только в тестах. - ENTRY-POLICY checks (entry-window, closure/freshness, selection and liquidity gates) через typed helpers; stop-loss остаётся отдельным EXECUTION-контуром.
- Strategy SDK API стабилизируется как extension points — будущие layers (binary-arb, multi-arb, ...) поднимаются как новые файлы + регистрация в static registry, не как in-tree if-ветки.
- Typed
DirectionalConfigstruct — все knobs стратегии описаны named typed полями; десериализация изstrategies.definitionJSON; unknown ключи fail-fast. Никакого universal condition engine.
Структура config (без значений)
DirectionalConfig группируется по 5-классовой taxonomy (см. parameters-reference.md). Семантические группы knobs:
- RISK-LIMIT block — 3 canonical: shares-per-market, USD-per-market (budget_per_market), total strategy budget + reinvest flag. Enforce — централизованно в
OrderRouter. Лимита «% от баланса кошелька» нет. - EMERGENCY-GUARD block — kill switch toggle и пороги (ADR-031). Enforce — в
EmergencyGuardперед router. - ENTRY-POLICY block — entry price source, max entry price, min probability, anchor source, entry-window bounds, explicit orderbook/universe freshness, selection and liquidity gates. Entry-debounce в текущем pm-engine отсутствует.
- EXECUTION block — exit price, buy order TTL (optional), recompute-exit-on-fill flag, min size, sweep flag, stop-loss sub-config (optional: threshold, cooldown, debounce).
- OBSERVABILITY block — counters, surfaced через MetricsTick/SSE.
Конкретные значения каждого knob — operator decision, хранятся в config.db strategies.definition per ADR-015. SSOT не фиксирует numeric defaults.
HarvesterPhase FSM
Per design D13, стратегия декларирует typed enum FSM:
Idle— нет активного сигнала на маркете.EntryPending— эмитирован entry BUY, ждём fill.InPosition— позиция открыта, exit GTC в книге.ExitPending— эмитирован FAK SELL (stop-loss), ждём подтверждения.Closed— позиция закрыта (exit fill / stop-loss fill / redeem).
Переходы — linear; обратный переход существует только Closed → Idle на новом сигнале по тому же маркету. Каждая смена фазы записывается макро transition!(from, to, reason) в одной SQLite-транзакции с domain mutation, через events.StrategyFsmTransition. На bootstrap фаза восстанавливается через ctx.load_fsm_phase(signal_uid).
Что НЕ изменится
Торговая семантика сохраняется по PRD/FSD и parameters-reference.md: семантика entry trigger, DCA, exit, stop-loss остаётся той же; auto-redeem формализован через Phase J state machine (см. ниже «Redeem cycle (pm-engine Phase J)»). Меняется только реализация (SDK-driven, mode-agnostic, через allocation-id). Конкретные числовые параметры остаются operator-controlled в config.db.
Migration path
Один пас: pm-bot удалён, pm-engine стартует с нуля. Существующие настройки стратегии в config.db переносятся как есть (schema совместима, schema_version bump per «schema-driven config migrations» из proposal). Legacy pm-bot-state.db удаляется без миграции (см. target/storage.md). Capture-replay parity gate гарантирует SHA-256 идентичность прогонов до/после.
Redeem cycle (pm-engine Phase J)
Harvester положительной позиции (winning) → CTF tokens становятся redeemable после market resolution. Phase J автоматизирует claim cycle.
Trigger
ClosureEvent (от ClosureWatcher) — MarketResolved (WS) или AcceptingOrdersFalse (Gamma poll) → DirectionalStrategy::on_closure помечает eligible positions в Pending с due_at = closure.ts + redeem_backstop_secs.
Eligible positions: OPEN или SELLING на момент closure. CLOSED (sold через normal exit fill) skipped — нет CTF balance. REDEEMED уже handled.
Backstop window
redeem_backstop_secs (default 600 s = 10 минут) — задержка перед attempting redeem. Reason: UMA dispute window, oracle settlement может занять время после closure event. Submitting too early = transient failure.
Execution
DirectionalStrategy::on_tick раз в N секунд вызывает ctx.due_redeems(now, limit) → для каждой due position ctx.redeem_position(uid).
RedemptionService диспатчит к RedemptionPort:
- Standard market:
redeem_standard(condition_id)через CTF контракт. - Neg-risk market:
redeem_neg_risk(owner, condition_id, yes_token_id, no_token_id)черезNegRiskAdapter(resolved через Gamma metadata).
State machine
NotRequired(default) — position не нуждается в redeem (закрыта через sell).Pending— closure detected, awaiting backstop.Submitted— adapter call в полёте.Confirmed— tx mined successfully, position перешла вREDEEMED.Failed— terminal failure или max retries exhausted; operator manual.
Retry policy
redeem_retry_max_attempts(default3).redeem_retry_backoff_secs(default[60, 300, 900]= 1 min, 5 min, 15 min).
Transient failures (network, RPC, mempool) → retry с exponential backoff. Terminal failures (zero balance, invalid token id, already redeemed) → immediate Failed без retry.
Restart safety
Pending rows persisted в SQLite с due_at + retry_count. Process restart → load_due query на next tick → continues redeem cycle с preserved state.
Edge case: Submitted на момент crash — operator manual intervention (system не auto-resubmits чтобы prevent duplicate tx). См. runbook «Stuck SUBMITTED status».
Wire events (Phase J6)
PositionRedeemSubmitted— successful submission.PositionRedeemConfirmed— tx mined.PositionRedeemFailed { terminal: bool }— failure (terminal=trueдля immediate terminal/invalid,falseдля max retries exhausted).
Probability-rank deterioration exit
Активная политика canonical_first_breach_v2 использует тот же канонический total-order rank, что вето входа и стена стоящей BUY. Пусть X = rank_deterioration_exit.exit_probability_rank, а R — текущий ранг. Первое свежее наблюдение R <= X немедленно передаёт выход постепенному maker-контуру. Текущая вероятность No и средняя цена входа остаются диагностикой и не могут задержать защиту. Обычная цель 0.99 остаётся независимым альтернативным путём закрытия. Книга и tick-size участвуют только после rank-решения, когда нужно доказать исполнимость новой SELL. Чистое ядро — crates/pm-engine/src/strategies/directional/rank_exit.rs, durable-состояние — rank_exit_states.
Конфиг обязателен и явен:
json
"rank_deterioration_exit": {
"enabled": true,
"policy": "canonical_first_breach_v2",
"exit_probability_rank": 1
}Форма показывает enabled и явную границу выхода. Она принимает старую V1 строку для осмотра, но каждое сохранение текущей конфигурации пишет canonical_first_breach_v2. Миграция v11 → v12 меняет только этот литерал и сохраняет согласованные min_probability_rank=4, max_rank_slip=0 и exit_probability_rank=1. policy всегда сохраняется в config.db; default и env-зеркала нет. При включённой защите обязательно 1 <= X < E; отсутствие объекта/поля, старая секция d_fav_deterioration_exit, неизвестная policy, неверный порядок границ или включение без активного probability-rank selection валят старт.
В таблице открытых позиций нормальная фаза показывает не общий бейдж «Норма», а последний принятый канонический rank (rank N). Seed и живой sparse-update используют durable rank-состояние и в tooltip несут текущую вероятность No и среднюю цену входа; до первого валидного наблюдения значения не выдумываются. При недоступности стор явно очищает текущий ранг и в колонке наблюдения, и в колонке защиты, не сбрасывая уже начатую защитную фазу.
V2 оценивает каждую открытую позицию на сериализованном секундном Strategy::on_tick, а не ждёт market-data кадр собственного токена. Под Drain полный discovery может быть разрежен в десять раз, но targeted refresh событий удерживаемых позиций и открытых заявок продолжает идти на каждом базовом такте координатора; при интервале 60 секунд это 60 секунд, а не 600. Если связь condition_id → Event.id ещё не прогрета, координатор восстанавливает родителя ограниченными пачками через open/closed market lookup и коммитит результат тем же каноническим путём. Отказ не заменяется старым снимком.
Первый distinct свежий breach переводит Normal прямо в MakerChase. Недоступный контекст не разрешает новую SELL и не разоружает уже начатый выход. Для восстановления ordinary target нужны две полные distinct RevisionIdentity выше границы; повтор той же identity ничего не продвигает, а рестарт очищает незавершённое доказательство восстановления.
V1 сохранён только для чтения, phase-aware гидрации и ограниченного отката до первого внешнего действия нового процесса. В его исторической семантике rank-only или below-entry-only чтение обновляет диагностику, но не трогает цель и не накапливает evidence. Первый entry-relative breached-снапшот начинает 30-минутное наблюдение (BreakEvenWatch — историческое имя durable фазы), становится первой контрольной точкой и оставляет обычную цель 0.99 на месте. Следующая контрольная точка фиксируется не раньше чем через 900 секунд. Подтверждение требует трёх точек за минимум 1 800 секунд со строго падающей ценой P1 > P2 > P3. Уникальные breached-снапшоты внутри 15-минутного интервала обновляют текущие rank/No для диагностики, но не двигают evidence. Если очередная допустимая контрольная цена равна предыдущей или выше неё, BreakEvenWatch сохраняется, текущая точка становится новым P1, а полное 30-минутное наблюдение начинается заново — цель 0.99 не снимается. Любой healthy или недоступный снимок во время наблюдения сбрасывает sequence и возвращает Normal, также не затрагивая цель. Политика может продлевать наблюдение сколь угодно долго в боковом/восстанавливающемся рынке; независимый stop-loss при этом продолжает оцениваться каждый тик и остаётся единственным контуром аварийного FAK.
Перед отменой ordinary target V2 сначала проверяет, что execution.rank_exit является maker-only GTC/GTD с post_only=true, а затем из одного текущего кадра доказывает ненулевой sellable remainder, tick-size и непересечённую книгу с post-only ценой строго выше best bid. Если доказательства нет, цель остаётся единственным SELL и публикуется sell_execution_context_unavailable. После успешной durable защёлки и терминального handoff ставится один post-only SELL по текущему best ask. Далее не чаще раза в 60 секунд цена монотонно снижается к текущему best ask максимум на 1¢ за одну замену, с выравниванием по tick и строго выше best bid. У rank-контура нет maker-эпохи и нет перехода в FAK: он продолжает постепенный maker-выход до fill, восстановления или независимого stop-loss. Вероятность YES в это решение не входит. Только stop-loss вправе терминально снять rank-maker и выполнить аварийный FAK согласно собственной execution.stop_exit.
Два последовательных distinct канонических healthy-снапшота возвращают цель из MakerChase. При восстановлении текущий rank-SELL сначала терминально сверяется/отменяется, учитываются racing partial fills, и только затем цель ставится на свежий остаток. Неоднозначная отмена сохраняет защитную фазу и запрещает второй SELL до безопасного retry. Maker с доказанно отменённым ордером и остатком ниже биржевого лота переходит в Normal без новой цели: это успешная остановка продажи, а не неоднозначность. Повтор одной полной revision identity evidence не двигает. Невалидный canonical context во время активного постепенного выхода очищает recovery evidence, но сохраняет текущий единственный SELL. Cooldown после фактического rank-maker fill — 3 часа; повтор отсутствующей защиты — не чаще 5 секунд.
Причина entry-relative предиката — два противоположных production-инцидента 2026-08-02. Atlanta 88–89°F перешла в rank 2 при безопасном No около 92¢; чистый rank отменил цель 0.99 и закрыл позицию, купленную по 86¢, через rank_break_even_exit по 92¢. Исполнение было корректным по прежней V1, но ordinal rank не различал «кандидат стал опаснее» и «один sibling стал доминировать, а кандидат стал безопаснее». Последующая попытка подтвердить rank общим входным floor 65¢ дала false-negative: Beijing 94→71 при rank 2 и Shenzhen 77→66.5 при rank 1 оставались Normal. Сравнение с каноническим средним входом различает оба случая без нового knob.
TakerExit и RankTaker сохраняются только как формат чтения durable/order состояния, созданного предыдущим бинарём. После рестарта такая строка через тот же strict terminal/detail протокол переводится в MakerChase и один post-only maker по свежему best ask; новый rank-FAK не создаётся и не перезапускается. Неоднозначность сохраняет прежнего владельца и запрещает replacement. Это обеспечивает денежно-безопасный cutover без dead legacy поведения в новом контуре.
Все смены SELL-роли, включая sharp stop и цель 0.99, проходят один протокол: cancel текущего владельца → доказанный terminal/detail → повторное чтение ордеров без живого SELL → повторное чтение позиции → submit ровно на свежий остаток. Неоднозначная отмена, нулевой остаток или dust запрещают replacement. Sharp stop остаётся выше по приоритету, но больше не выставляется рядом с зарезервировавшей тот же инвентарь целью.
Обе policy — правила исполнения защиты, а не утверждение об EV-оптимуме. Их тюнинг допустим только на post-fix forward fills по обязательной EV-модели.
Этап наблюдения входа (entry_observation)
Механизм реализован в коде, в бою ВЫКЛЮЧЕН. Секция directional.entry_observation опциональна: её отсутствие (равно как явный null) означает, что рынок, прошедший цепочку фильтров, идёт прямо в заявку — поведение Direct без изменений. Включает этап только оператор правкой конфигурации, и первый разрешённый режим — теневой (mode = "shadow"), который поток заявок не меняет. Не ссылаться на этап как на действующий гейт входа, пока оператор не объявил обратное. Порядок включения, приёмки, перехода в блокирующий режим и отката — runbook §Этап наблюдения входа.
Что этап добавляет к Direct
Между цепочкой мгновенных фильтров качества рынка и заявкой у Direct нет никакого состояния: оценка, прошедшая все гейты, немедленно превращается в заявку. Этап вводит промежуточное состояние «кандидат под наблюдением»: рынок, прошедший все фильтры, получает кандидатуру, а вход разрешается только той оценке, на которой выполнена КОНЪЮНКЦИЯ включённых предикатов.
Наблюдение — механизм РЕШЕНИЯ, а не способ исполнения: тип заявки не меняется — уходит та же заявка настроенного Direct, стадия не трогает ни post_only, ни max_taker_fee_bps, ни базис цены. Нового варианта entry_flow не появляется, секция требует entry_flow = "Direct" и НИЧЕГО БОЛЬШЕ: под Direct законны обе формы входа — maker-only (post_only = true, боевая строка стоит так) и taker-capable (post_only = false с обязательным max_taker_fee_bps, см. п.5 «post_only и комиссия» выше), и обе проходят через этап одинаково. Читать «этап гарантирует maker-исполнение» нельзя: он не гарантирует ничего об исполнении. Стадия стоит в конвейере оценки после гейта режимов/аварийной остановки и ДО расчёта цены заявки, то есть до entry_attempts и до любого счётчика хвоста: удержание кандидата не является ни попыткой входа, ни потерей пригодности.
Реализация: crates/pm-engine/src/strategies/directional/entry_observation/ (state — предикаты кандидатуры, direction — ценовой ряд, ex_self — наблюдаемая цена, ledger — бюджет погони, journal/verdict — вердикты, metrics — операторская плоскость).
Настройки: одна секция, десять ключей
Типы, границы и запрет неизвестных ключей — в DirectionalConfig (crates/pm-engine/src/strategies/directional/config.rs) (EntryObservationConfig); человеческие пояснения каждой настройки, как их видит оператор, — в форме конфигуратора (frontend/pm-engine-dashboard-web/src/components/strategyFormSchemas.ts, раздел entry_observation). Ниже — соответствие ключей карточкам и семантика выключения.
Восемь карточек этапа с карты торгового цикла в форме получили более явные имена: «Подтверждение снимками» → «Сколько разных снимков справочника подтвердят кандидата», «Стабильность ранга» → «Допустимое ухудшение места в рейтинге», «Осушение ниже порога» → «Потолок оценки осушения за время наблюдения», «Бюджет налива» → «Ограничить погоню за наливом»; «Минимальное время наблюдения», «Допустимый откат» и «Требуемое направление цены» совпадают почти дословно, «Границы отмены» ключа не имеет вовсе.
Ключ definition | Карточка формы | Тип и границы | Форма отключения |
|---|---|---|---|
mode | «Режим этапа» | "shadow" | "enforce" | — (служебный) |
min_observation_seconds | «Минимальное время наблюдения» | u64, 0..=86400 | 0 |
required_snapshot_confirmations | «Сколько разных снимков справочника подтвердят кандидата» | u32, 0..=30 | 0 |
max_pullback_milli | «Допустимый откат от максимума цены» | u32, 1..=500, nullable | null |
required_price_direction | «Требуемое направление цены» | "rising" | "non_falling" | "any" | "any" |
price_direction_window_seconds | «Окно, за которое смотрится направление» | u64, 10..=600, кратно 10, nullable | null (только вместе с any) |
max_rank_slip | «Допустимое ухудшение места в рейтинге» | u32 без прикладного потолка, nullable | null |
max_ldi_during_observation | «Потолок оценки осушения за время наблюдения» | f64, 0.0..=1.0, nullable | null |
fill_budget | «Ограничить погоню за наливом» | объект {max_total_seconds: u64 60..=86400, reprice_floor_tolerance_milli: u32 1..=200}, nullable | null |
verdict_retention_hours | «Сколько хранить вердикты наблюдения» | u32, 1..=8760 | — (служебный) |
Переключатель формы «Наблюдать за кандидатом до заявки» (entry_observation.enabled) ключом definition не является: выключенное положение записывает "entry_observation": null, включённое — секцию целиком. Искать его в строке настроек бесполезно.
Отсутствие ключа ≠ null. Присутствие секции обязывает задать ВСЕ десять ключей явно, включая отключаемые: форма отключения — явный null, а не пропущенный ключ. Разбор идёт через промежуточную wire-форму именно ради этого различия — у serde пропущенное Option-поле само по себе даёт None, то есть «забыл ключ» было бы неотличимо от «выключил осознанно» (RULE-NEW-01, без молчаливых умолчаний). Частичная секция валится на загрузке с полным путём недостающего ключа.
Секция, у которой выключены все семь семантических ручек, созревает мгновенно и воспроизводит сегодняшний Direct — обратная совместимость по построению.
Значения формы — стартовые значения ТЕНИ, а не боевые. Они выбраны ради информативности вердиктов (порог, который не срабатывает никогда, и порог, который срабатывает всегда, одинаково ничему не учат) и обоснованы в design Q1 (openspec/changes/entry-observation-stage/design.md). Боевые значения назначает оператор на воротах приёмки по собранным форвард-данным; сравнивать настройки, снятые в разных эпохах процесса, запрещено — знаменатель у них разный.
Что отвергается на загрузке
Секция валится fail-fast (не предупреждением) в каждом из случаев:
- частичная секция, неизвестный ключ, неверный тип — с полным путём ключа;
entry_flow ≠ "Direct";max_rank_slipбезselection.mode = "neg_risk_probability_rank";max_rank_slipприdiscovery.market_type = "binary"— слой отбора прозрачен на не-neg-risk рынке, а такой профиль поиска других рынков не даёт, значит ранг не вычислялся бы ни разу;required_snapshot_confirmations > 0безdiscovery.rediscovery_interval_secs— снимок справочника штампует только координатор периодического опроса, без него счётчик не сдвинулся бы никогда;required_snapshot_confirmations > 0приdiscovery.market_type = "binary"— снимок справочника есть когорта neg-risk события, которой такой профиль не порождает;- окно направления, несогласованное с направлением (есть при
any, нет при прочих), вне границ либо не кратное ширине корзины; mode = "enforce"вместе с ЗАДАННЫМmax_ldi_during_observation— отвергается любое не-nullзначение, включая0.0: проверка смотрит на присутствие значения, а не на его величину, и «выключить потолок нулём» не получится. В блокирующем режиме допустим единственный вариант —null(см. «Осушение как линза» ниже).
Предупреждение (не отказ) effectively_disabled печатается, когда допуск отката или пол перевыставления не у́же ценового коридора входа: настройка формально включена, но защиты из неё не получается.
Названная ловушка без предупреждения. Потолок осушения, равный порогу мгновенного гейта ldi.min_ldi_for_entry или ниже его, пробивается уже на оценке допуска: каждая дошедшая до стадии оценка этот пол уже прошла. Кандидатура умирает мгновенно и всегда, распределение времён созревания не собирается вовсе. Загрузчик такую пару не комментирует; денег она не стоит (в блокирующем режиме заданный потолок запрещён вовсе, а тень заявок не двигает), но выкатывать её бессмысленно.
Созревание — конъюнкция на каждой оценке, без защёлки
«Созрел» — свойство ОЦЕНКИ, а не хранимое состояние. Оценка, дошедшая до стадии, сначала коммитит своё событие в состояние кандидатуры (ценовой отсчёт, счёт снимков, экстремумы просадки), и только потом предикаты считаются на ОБНОВЛЁННОМ состоянии: оценка, завершающая созревание, эмитит на себе; оценка, пробивающая порог, отменяет на себе.
Предикаты (все включённые обязаны выполняться одновременно):
- Время — от момента допуска прошло не меньше
min_observation_seconds. Время берётся исключительно из часов движка (ClockPort), никогда из таймстампов полезной нагрузки, и ИМЕННО из МОНОТОННЫХ часов (ClockPort::monotonic_ms), а не из настенного UTC. Настенное время прыгает (NTP-коррекция, ручная правка даты, миграция виртуальной машины), и прыжок вперёд засчитал бы выдержку, которой не было, а прыжок назад продлил бы её на величину прыжка. По монотонным часам меряется КАЖДАЯ длительность: выдержка кандидатуры, корзины ряда направления, все интервалы леджера погони и ОБЕ длительности эпизода — активная (episode_active_ms) и полная (episode_wall_ms, в кодеEpisodeTelemetry::total_ms). Различает эти две наличие живой заявки: полная включает зазоры между циклами GTD, активная нет; источник часов у них ОДИН, а слово «wall» в имени колонки — словарь спеки («wall and active times»), а не указание мерить настенными. Настенное время остаётся ровно у журнальных отметок, которые оператор читает как дату (момент допуска, момент вердикта, момент начала эпизода), и ни одного решения не принимает. Отдельная граница, предписанная спекой: счёт снимков справочника (пункт 2) сравнивает штампы котировок САМИХ снимков — это настенный домен по определению величины. - Подтверждение снимками — засчитаны K различных наблюдённых снимков справочника. Снимок различается тем же детерминированным отпечатком когорты (
gamma_cohort_epoch), которым его различает теньentry_quality_confirmation. Засчитывается когорта со строго бо́льшим штампом котировок, чем у последней засчитанной; равный штамп счётчик не двигает; обратный — сбрасывает серию и пишет вердикт нарушения целостности данных; разрыв больше допуска свежести (две закреплённые каденции опроса, 120 с — та же величина, что боевой горизонт свежести оси ранга) начинает счёт заново с текущего снимка. Живые WS-тики книги счётчик не двигают. Гарантируется «K различных наблюдённых снимков, каждый в пределах допуска свежести от предыдущего засчитанного», а НЕ «ни один снимок справочника не был пропущен» — номеров опросов у справочника нет, и доказать непропуск нечем. - Направление — текущая наблюдаемая цена сравнивается с базой окна
price_direction_window_seconds.non_falling— не ниже базы,rising— строго выше,any— предикат выключен. Ряд хранится корзинами по 10 с, привязанными к моменту допуска (в каждой — первая и последняя цена), пустые корзины заполняются последней известной, цена допуска сидируется синтетическим отсчётом. База — последняя цена самой свежей корзины, ЦЕЛИКОМ лежащей не позжеnow − T: из будущего база не приходит никогда, у молодой кандидатуры базой служит отсчёт допуска. Сорванное направление НЕ отменяет кандидата — он просто не зреет, пока картина не восстановится. - Откат — максимальная за кандидатуру просадка от бегущего максимума наблюдаемой цены не превышает
max_pullback_milli. Отсчёт от пика, а не от цены допуска: цена, ушедшая вверх и рухнувшая обратно, отменяет кандидата, даже оставаясь выше начальной. Пробой ОТМЕНЯЕТ. - Стабильность ранга — ранг не приблизился к фавориту больше чем на
max_rank_slipмест относительно ранга допуска; движение ОТ фаворита не ограничено ничем. Пробой ОТМЕНЯЕТ. - Осушение (LDI) — максимум LDI за кандидатуру не превышает
max_ldi_during_observation. Читается тот же снимок, который эта же оценка уже добыла для мгновенного гейта LDI; сравнение — с тем же квантованием (quantize_unit_micro) с обеих сторон. Недоступный снимок (холодный старт) предикат НЕ валит, момент помечается флагом в журнале. Пробой ОТМЕНЯЕТ.
Наблюдаемая цена — ex-self лучший бид: лучший бид за вычетом собственных resting-заявок движка (если собственная заявка — единственная поддержка уровня, наблюдаемой ценой становится следующий уровень). Наблюдаемая сторона ВСЕГДА бидовая и от политики цены входа не зависит. Основание — не «заявка стоит у бида»: BUY входа стоит на бидовой СТОРОНЕ книги, но при боевой политике цены (entry_price_base = BestAsk, entry_price_offset_milli = -10) — внутри спреда, на тик ниже лучшего аска, и при широком спреде сама становится лучшим бидом (отсюда и вычет ex-self, иначе движок наблюдал бы собственную поддержку). Бид выбран потому, что на нём говорит стоп-контур и по нему оценивается выход, то есть просадка бида — просадка стороны, на которой живёт позиция. При taker-capable конфигурации (post_only = false) заявка может пересечь спред и исполниться немедленно; наблюдение и тогда меряет бид, но толковать его следует как просадку РЫНКА, а не места исполнения. Рассинхрон приватного состояния заявок и публичной книги помечается липким признаком ex_self_sync_uncertain_seen.
Отказ хвоста ничего не защёлкивает. Если конъюнкция выполнена, но заявку отверг хвост Direct (лимит риска, дубль, размер, локальный гейт пересечения, свежесть на роутере) — кандидатура остаётся под наблюдением с накопленным состоянием, ничего не начинается заново, следующая оценка пересчитывает всё. Собственное «ещё не созрел» стадии регрессией тоже не является.
Созревание объявляется только на оценках рыночных данных, никогда на тиках. Молчащая книга замораживает созревание: нет свежего подтверждения — нет сигнала. За тиками остаются только гигиена карты по истёкшему сроку окна входа и своевременность сноса стоящей заявки по исчерпанию бюджета.
Границы отмены
Кандидатура ОТМЕНЯЕТСЯ (а не входит подешевле), когда:
- оценку её рынка отверг перечислимый гейт ВЫШЕ стадии — вердикт несёт имя гейта (
drain,exchange_health,lifecycle_halt,warmup,cooldown,outcome_filter,entry_window,probability,ask_available,entry_price_band,bid_depth,born_armed,selection,ldi,regime;ObservationUpstreamGate::label,crates/pm-engine/src/strategies/directional/entry_observation/mod.rs:255). Семнадцатое имя,unattributed, недостижимо по построению и существует затем, чтобы забытая врезка проявлялась данными: строка вердикта с этим именем в журнале означает дефект атрибуции, а не новую причину отмены; - пробит порог самой стадии — откат, дрейф ранга, потолок осушения;
- рынок закрылся (
market_closed, только над живым состоянием); - рынок исчерпал бюджет активной погони (
budget_deadline_reached) — свойство РЫНКА, а не кандидатуры, поэтому отдельная причина.
Отмена немедленно сбрасывает всё накопленное состояние. Вернуться рынок может только новым полным проходом цепочки фильтров, и наблюдение начинается С НУЛЯ: ни времени, ни снимков, ни ценового ряда не переносится.
Бюджет погони (fill_budget)
Погоня — не жизнь одного сигнала, а последовательность коротких сигналов: истёкшая без налива BUY-заявка закрывает сигнал, следующая заявка идёт целиком новым сигналом через весь гейт-чейн. Поэтому бюджет живёт на уровне РЫНКА.
- Счётчик — суммарное время АКТИВНОЙ погони по рынку за эпоху процесса: сумма завершённых интервалов заявок плюс текущий интервал, пока заявка жива. Паузы между попытками не считаются; перекрывающиеся интервалы объединяются, а не складываются.
- Интервал начинается локальной эмиссией и заканчивается только терминальным событием заявки на бирже. Отправка, чей приём биржей ещё не подтверждён, бюджет не тратит и надгробия не ставит; на подтверждении интервал создаётся задним числом от момента локальной эмиссии (и если это сразу исчерпывает бюджет — надгробие ставится немедленно вместе с отменой); отказ post-only интервала не создаёт вовсе; неопределённый исход отправки блокирует новую эмиссию до сверки.
- Исчерпание терминально в пределах эпохи процесса. Надгробие ставится ДО отправки отмены, поэтому налив, успевший проскочить, открывает позицию штатно, а рынок остаётся закрытым для НОВЫХ входов до перезапуска движка либо до закрытия самого рынка. Отмена повторяется идемпотентно с отступом, естественный предел повторов — собственный GTD заявки. Авторитетная проверка стоит в пути эмиссии, тиковый проход отвечает только за своевременность сноса.
- Пол цены перевыставления: каждая следующая заявка эпизода обязана стоить не меньше
цена первой заявки эпизода − reprice_floor_tolerance_milli. Ниже — заявка не выставляется, а часы бюджета продолжают идти. Вверх перевыставление ограничено обычным коридором цен входа, отдельной настройки нет. chase_abandoned— заявка терминализована без налива, и кандидат больше не проходит фильтры. Остаток бюджета переносится на возможный следующий эпизод; рынок может вернуться только полным кругом фильтров и полным наблюдением заново — суммарная экспозиция погони ограничена рыночным бюджетом.- Перезапуск процесса обнуляет всё: состояние наблюдения, учёт погони и надгробия несохраняемые. Повторный бюджет после перезапуска виден в анализе по отметке эпохи процесса в журнале.
- Закрытие рынка снимает учёт погони вместе с надгробием; в блокирующем режиме здесь же снимается стоящая entry-заявка (без секции и в тени её не снимает никто — сегодняшнее поведение).
fill_budget = null — сегодняшняя погоня без предела по времени и без нижней границы цены.
Тень — измеритель, а не симулятор
В теневом режиме поток заявок обязан совпадать с Direct без секции: ни один допуск не задерживает заявку, ни одна отмена её не подавляет. Расхождение — не настройка, а дефект.
Тень измеряет ДВЕ объявленные величины:
- Телеметрию реальной погони — длительности эпизодов (настенные и активные), наливы, цены, момент, в который блокирующий режим ЗАПРОСИЛ БЫ отмену. Точный записанный факт — «реальный налив произошёл позже момента запроса»; задержка подтверждения отмены меряется отдельно и уже в блокирующем режиме.
- Динамику предикатов на наблюдаемой книге — распределения времён созревания, причины would-отмен, максимумы осушения, цепочки эпизодов, цену в момент would-созревания.
Чего тень НЕ делает и что по ней утверждать нельзя:
- виртуальных заявок, виртуального хвоста и виртуальных бюджетных эпизодов нет; композиция «наблюдение-тень × бюджет-тень» не заявляется;
- для отложенного входа тень даёт «вход был бы доступен по цене X», а не «налив состоялся бы»: позиция в очереди пассивных заявок не моделируется;
- достоверность — для ПЕРВОГО цикла каждого рынка; многоцикловая статистика появляется только после включения блокирующего режима и служит критерием его отката.
Загрязнение выборки и правило калибровки. Кандидатура несёт два липких признака: own_order_seen (при её жизни хотя бы на одной оценке стояла собственная заявка — глубина, ранг и осушение загрязнены) и ex_self_sync_uncertain_seen (плюс счётчик загрязнённых оценок). Ценовые предикаты калибруются только по кандидатурам без признака рассинхрона; глубина, ранг и осушение — только по кандидатурам без own_order_seen. Откат на загрязнённую выборку ЗАПРЕЩЁН: нулевое чистое покрытие означает, что предикат по тени не калибруем, и включать его в блокирующем режиме не на чем. В тени собственная заявка стоит почти всю жизнь кандидатуры (вход не задерживается), поэтому доля загрязнённых наблюдений заведомо велика — приёмка обязана показать сегментацию и долю чистого покрытия, а не среднее по всем.
Осушение как линза
max_ldi_during_observation — потолок за время наблюдения, а не пол на момент решения: мгновенный гейт ldi.min_ldi_for_entry требует достаточно высокой оценки в момент входа, потолок наблюдения запрещает подниматься выше него хоть раз за кандидатуру. Роли разные, мгновенный гейт этой настройкой не затрагивается.
В блокирующем режиме ЛЮБОЙ заданный потолок ЗАПРЕЩЁН загрузчиком — отказ смотрит на присутствие значения, а не на его величину, поэтому 0.0 отвергается наравне с 0.9, и единственная допустимая форма в этом режиме — null. Причина: после перезапуска своей истории у движка нет, снимок недоступен, предикат пропускал бы всё — fail-open несовместим с режимом, который обязан блокировать. Порог никогда не калибровался, поэтому линза стартует выключенной, а журнал всё равно пишет достигнутый максимум — калибровка идёт без порога.
Что этап оставляет оператору
- Журнал вердиктов в
engine.db:entry_observation_verdicts(строки ШЕСТИ типов —milestone/candidate_terminal/market_terminal/order_terminal/episode/data_integrity; допуск строки НЕ пишет, но каждая строка несёт момент допуска). Шестой тип,data_integrity, — НЕ терминальный: он пишется, когда снимок справочника пришёл с таймстампом котировок РАНЬШЕ засчитанного (колонкаintegrity_issue=backward_snapshot_quote_ts) и сбросил цепочку подтверждений, а кандидатура при этом ЖИВА. Запрос, перечисляющий типы поимённо, обязан его учитывать: пропуск занижает число сбросов стрика и оставляет их невидимыми, а включение в терминальную популяцию ломает знаменатель «ровно одна терминальная строка на кандидатуру». Рядом —entry_observation_epochs(эпоха процесса, счётчики полноты и отметка чистого конца),entry_observation_verdict_retention(водомер обрезки). Пишет отдельная задача-писатель через ограниченную очередь; торговый путь синхронной записи не делает и от отказа журнала не зависит — журнал это аудит, а не авторитет. - Метрики
/metrics:pm_engine_entry_observation_{candidates, admitted_total, matured_total, cancelled_total{reason}, shadow_verdicts_total{verdict}, saturation_refused_total},pm_engine_entry_fill_budget_exhausted_total,pm_engine_entry_reprice_floor_blocked_totalи счётчики журнала (…_journal_{enqueued,committed,write_failed,dropped,pruned}_total,…_journal_flush_watermark_seq). SSOT имён — describe-блокиcrates/pm-engine/src/observability/metrics.rs. - Воронка входа (только в блокирующем режиме; в тени и без секции воронка бит-идентична прежней): ступени удержания по удерживающему предикату (
observation_pending_min_time/…_snapshots/…_direction), отмены по причине (observation_cancelled_pullback/…_rank_slip), терминальность бюджета (observation_budget_terminal), отказ по насыщению карты (observation_saturation_refused) и две величины погони (chase_budget_exhausted,chase_floor_blocked). - Трасса допуска (по одному рынку за раз, по умолчанию ВЫКЛЮЧЕНА): включается инфраструктурной переменной
PM_ENGINE_ADMISSION_TRACE_CONDITION_IDсcondition_idинтересующего рынка, пишет в лог-цельadmission_trace. Этап публикует три стадии:entry_observation(исходыpassed,blocked:<удерживающий предикат>,cancelled:<причина>,transit:active_entry_signal,saturation_refusedи теневые двойникиshadow:tracked/shadow:cancelled:<причина>/shadow:saturation_refused),entry_fill_budget(budget_terminal) иentry_reprice_floor(blocked:below_floor, в тениshadow:below_floor). Префиксshadow:значит «измерено, но поток заявок не тронут». Это единственная поверхность, которая отвечает на вопрос «почему остановлен ИМЕННО ЭТОТ рынок ИМЕННО СЕЙЧАС»: метрики и воронка агрегатны, а строка журнала рождается только на завершении кандидатуры или эпизода. - Оракул приёмки — SQL
ENTRY_OBSERVATION_BUDGET_ACCEPTANCE_SQL(crates/pm-engine/src/adapters/storage/sqlite.rs): соединяет журнал с durable-таблицейordersи бухгалтерией позиций и для ЛЮБОГО значения бюджета выдаёт поимённо, какие погони были бы отменены и какие победители отсечены, вместе с цепочками эпизодов. Эпохи без чистого конца в расчёт не входят.
Карта кандидатур ограничена жёстким потолком в 10 000 записей; при насыщении новые допуски ОТКЛОНЯЮТСЯ (зреющий кандидат никогда не вытесняется) со счётчиком и липким флагом.
Границы, названные явно
- Потолок осушения — только тень. В блокирующем режиме он запрещён загрузчиком; снятие запрета требует отдельного изменения вместе с политикой прогрева LDI, и в том же изменении обязана появиться ступень воронки для отмены этой причиной.
- Популяция строк исходов заявок ≠ популяция терминализованных заявок. Отказ биржи по уже принятой заявке погони закрывает интервал бюджета, но строки
order_terminalне пишет — спека называет ровно три имени исхода. Знаменатель по исходам заявок обязан это учитывать; оракул приёмки им не пользуется (он считает по строкамepisodeиorders). - Погоня, выпущенная при насыщенной карте, эпизодом не становится. Строка журнала обязана нести момент допуска, а его у такой заявки нет. В блокирующем режиме состояние недостижимо; в тени выборка по погоням теряет эпизоды тех периодов, когда карта была полна.
- Чистый конец эпохи не доказывает полноту. Порядок завершения задачи-писателя и производителей вердиктов не установлен, поэтому строка, предложенная в момент завершения, может быть учтена принятой и не зафиксирована, а инкремент потери — лечь после снимка счётчиков эпохи.
dropped = 0у чистой эпохи — необходимое условие, а не доказательство. - Операторский Reset обесценивает связь журнала с заявками. Он помечает всю накопленную популяцию признаком утраченной связи (
linkage_lost) той же транзакцией, что удаляетorders; молчаливая пустота была бы хуже. Окно сбора тени поэтому объявляется «между операторскими сбросами». - Офлайн-реплей СНЯТ 2026-08-21 вместе со всем бэктестом (решение оператора,
composable-portfolio-strategy-domain§D13). Отчётыpm-backtest, снятые на записях neg-risk рынков при включённом отборе по рангу, были недействительны и до снятия: предикаты снимков и ранга в реплее источника не имели вовсе, пока драйвер не начал строить когорту события. Ссылаться на них нельзя; проверка идёт на живых сессиях и телеметрии.
Decision rationale
Versioned configuration lifecycle
Harvester keeps one stable strategies.label; tail and active-motion experiments are immutable versions under that identity, not new *-vN rows. Saving requires an explicit draft or publish choice. Drafts may be replayed by version while live/restart continues to resolve the published snapshot. Publishing changes only the config selected by the next start: there is no hot reload and no implicit process restart. The dashboard therefore distinguishes latest, published and loaded versions and marks published != loaded as pending restart.
Restore is copy-forward: an older snapshot is migrated on a copy, validated by the current DirectionalConfig contract, and inserted with a new monotonic number. enabled is operational state outside the snapshot. All profitability and tuning conclusions remain subject to the mandatory post-fix forward-data and Harvester EV rules in this document; config versioning does not make old captures valid for tuning.
ADR-024 (directional-first architecture) — ACCEPTED 2026-05-13
Единственная стратегия в активном scope — directional/Harvester. Остальные (binary-arb, multi-arb, single-token-mm, maker, maker-rebate, merge-arb) ARCHIVED-BY-024 или DEFERRED. Возвращаются как layers поверх стабильного extension API (ADR-025).
ADR-027 (directional-stop-loss) — ACCEPTED
Per A4 canonical vision, stop-loss переработан в config-driven форме. Параметры (stop_loss_bid_threshold, stop_loss_cooldown_hours, stop_loss_debounce_secs) живут в config.db. По 5-классовой taxonomy относится к EXECUTION (exit-mechanic), не к RISK-LIMIT. Live с 2026-05-16: 0.80 / 5 h / 5 s (инстанс тогда назывался harvester-live на pm-bot; с 2026-07-22 боевой инстанс — harvester-engine на pm-engine).
ADR-030 (entry-debounce) — DEFERRED / NOT IMPLEMENTED
Исторический замысел optional debounce-гейта не портирован из pm-bot. В DirectionalConfig, форме и runtime нет его knobs или состояния. Возврат требует отдельного OpenSpec change и новых parity/observability контрактов.
ADR-026 (realized PnL reinvest) — ACCEPTED
Companion к canonical RISK-LIMIT 3 (total_budget_micro_usd, strategy-level в config.db strategies.definition): общий бюджет стратегии может реинвестировать realized PnL через reinvest_realized_pnl. Поле реализовано и surfaced в конфигураторе.
ADR-022 (unified OrderbookStore) — ACCEPTED
Стратегия читает orderbook через единый OrderbookStore (pm-sdk) с source-tracked merge: WS book/delta/price_change + REST snapshot, timestamp guard. Depth-hook — через full_orderbook(). Стратегия не держит свой parallel book.
ADR-016 (timezone handling) — ACCEPTED
TimezoneCache используется ИСКЛЮЧИТЕЛЬНО для display / auto-redeem TTL. Не для entry-gate. Entry-gate driven by per-strategy finish_type per A9.
Anchor-source / per-outcome deadline — per A9
AnchorSource enum (EndDate / GstOffset / GstLocalMidnight); reactive closure через ClosureWatcher (Gamma poll + user-WS). Capability spec: openspec/changes/archive/2026-05-16-per-outcome-deadline-detection-audit/specs/outcome-tradability-detection/spec.md. Открытый NAMING-01: переименование в finish_type.
Этап наблюдения входа (entry-observation-stage)
Разрыв, который этап закрывает: между «прошёл все фильтры» и заявкой у Direct не было состояния, поэтому мелькнувшая картина давала вход, а погоня за наливом не имела предела. Решения — почему бюджет считается по РЫНКУ, а не по сигналу; почему созревание объявляется только на оценках; почему отмена означает полный круг с нуля; почему тень объявлена измерителем, а не симулятором; откуда взяты стартовые значения — в design изменения (openspec/changes/entry-observation-stage/design.md) (решения D1–D13, таблица Q1). Нормативные требования и сценарии — дельта спеки (openspec/changes/entry-observation-stage/specs/entry-observation-stage/spec.md). Механика в текущем коде и границы честности — раздел «Этап наблюдения входа» выше.
Cross-references
docs/strategies/parameters-reference.md— каноническая 5-классовая taxonomy параметров.docs/target/engine.md— целевая архитектура движка (где стратегия будет жить как SDK consumer).docs/target/risk-and-emergency.md— 3 canonical RISK-LIMITS, в которых работает стратегия.docs/target/extension-points.md— Strategy SDK API.openspec/changes/unify-orders-positions-signals-events/— план миграции.crates/pm-engine/src/strategies/directional/— production directional/Harvester code.openspec/changes/entry-observation-stage/— этап наблюдения входа: дизайн, дельта спеки, протокол защиты. Операторский порядок включения и отката — runbook §Этап наблюдения входа.openspec/specs/directional-order-ttl/spec.md— каноническое поведение TTL: explicitnull⇒ GTC,Some(N >= 180)⇒ GTD, live expirationnow + 60 + N.openspec/changes/add-bootstrap-shutdown-safety/— bootstrap/shutdown safety тред (открыт,halted_bulk_anomaly).- ADR-rationale (консолидирован в этом SSOT +
docs/target/*):ADR-024(directional-first),ADR-027(directional-stop-loss),ADR-030(entry-debounce),ADR-026(realized-pnl-reinvest),ADR-022(unified-orderbook-merge),ADR-016(timezone-handling-policy),ADR-031(risk-engine →../target/risk-and-emergency.md).