Skip to content

Аудит горячих путей pm-engine — узкие места и предложения по оптимизации (2026-08-14) ​

Аналитический документ, НЕ спецификация изменения. Оператор жаловался, что движок заметно тормозит при торговле; задача — назвать узкие места поимённо, с обоснованием из кода или замера, и предложить меры с ценой и риском, чтобы оператор выбрал, что запускать в работу.

Метод: чтение кода (эта рабочая копия, git rev-parse HEAD = cef6b94f) + один реальный замер существующим тестом (SQLite-транзакция) + арифметика из констант кода. Живого доступа к боевому VPS не было и не использовалось — везде, где нужен был бы боевой замер, это сказано прямо. Код не менялся, config.db/VPS не трогались.

Обозначения: [Ф] — установленный факт (код или замер), [Г] — гипотеза (согласуется с кодом, но не доказана), [НЕ ИЗМЕРЕНО] — величина, для которой нужен замер, которого нет.


0. Резюме для оператора ​

  1. Главный неизвестный факт, от которого зависит вес всех остальных находок: какой коммит реально исполняется на боевом инстансе. Архитектурный фикс, снимающий старый потолок «64 снимка/с, задержка книги ≈4 с» (замер 2026-07-26, установленный факт задачи), уже слит в main (коммит 9c050740, 2026-07-30; проверено git merge-base --is-ancestor 9c050740 HEAD = true И git branch --contains 9c050740 включает main, не только ветку этой рабочей копии) и присутствует в HEAD этой рабочей копии. Но docs/operations/tech-debt-backlog.md (строка TD-MD-CEILING-3) прямо запрещает выкат этого фикса на торгующий инстанс до закрытия «волны B» (flow-imbalance-stop-confirmation, directional-dca-fill-progress-and-dust-terminal) — обе директории openspec/changes/ на HEAD ещё НЕ архивированы, то есть волна B не закрыта. Проверить нельзя без доступа к VPS. Если боевой бинарь старше этого коммита — старый замер (64/с, 4 с) полностью в силе, и часть выводов ниже вторична по отношению к нему. Если новее — замер недействителен, и вес сдвигается к находкам §3–§7. Нужен один факт от оператора: дата/SHA последнего scripts/deploy-vps.sh engine-bot деплоя.
  2. «Тормозит именно ПРИ торговле» — по коду это ожидаемо, и не из-за среднего темпа. Средний темп заявок за замеренные 2 суток: (2299 BUY + 2135 cancel) ÷ 172800 с ≈ 0.026 операций/с — на два-три порядка ниже любого биржевого лимита. Объяснение — не насыщение, а последовательная блокировка одного task'а стратегии сериями сетевых вызовов внутри одного хука (§3.2): движок построен как ОДИН tokio-task на стратегию со строго последовательным диспетчером — пока хук не вернул управление, не обрабатывается ничего: ни новые рыночные данные, ни события ордеров, ни изменения позиций, ни денежные механизмы тика. Редкая, но плотная серия отмен/перевыставлений замораживает именно то время, пока она идёт — это субъективно ощущается как «движок встал», даже если в среднем нагрузка мизерная.
  3. Диагностика этого — уже в проде, бесплатно, без деплоя. StrategyDriver логирует tracing::warn!(hook, elapsed_ms, strategy, "slow_hook") на КАЖДЫЙ вызов хука дольше 1 секунды (strategy_driver.rs:91,1090-1096). Это первый и самый дешёвый шаг проверки всей гипотезы этого документа — см. §2.
  4. Часть находок применяется, только если включён конкретный ключ config.db (directional.entry_pattern_recording — §7.1) — состояние этого ключа на бою не проверялось (запрещено CLAUDE.md читать/менять config.db этим агентом; проверка — короткая операторская команда).
  5. Найдена одна неучтённая техническая задолженность — stop_dip_contexts растёт неограниченно и не зарегистрирована в tech-debt-backlog.md (§10).
  6. Найдено расхождение источников по лимиту частоты POST/DELETE /order: код заявляет 5000/10с, ранее установленный (не мной) факт — 3500/10с (§11.1). Решение — за оператором, не за этим документом.
  7. Одна посылка задания была неверной и исправлена по коду: user_ws_accounting.rs не занимается разбором филлов — это health-supervisor drain-политики. Реальный путь букинга — correlator.rs → strategy_driver.rs → position_registry.rs (§5.1).
  8. «Парадокс» с противоречивыми числами охвата вселенной (1254→596мс, 726→908мс, 198→1738мс) — разрешён, не «странность»: это две структурно независимые величины, случайно показанные рядом (§9).

1. Архитектурный контекст (общий для всех находок ниже) ​

StrategyDriver::run (crates/pm-engine/src/engine/strategy_driver.rs:320-) — ОДИН tokio-task на экземпляр стратегии, biased tokio::select! с шестью ветвями по жёсткому приоритету: shutdown → position_change → order_events → closure → tick → market_data. Цикл строго последователен: invoke_hook (:1045-1097) дожидается future хука ЦЕЛИКОМ, прежде чем диспетчер снова опросит ветви (crates/pm-engine/src/engine/strategy_driver.rs:1050 fut.await).

Следствие, важное для всего документа: если внутри хука есть .await на сеть или на диск, движок не «зависает» целиком (другие tokio-задачи процесса — периодические свипы, HTTP-сервер, воркер-пул пула CLOB-клиента — продолжают работать), но task конкретно этой стратегии не может обработать ничего ДРУГОГО, пока текущий хук не вернётся: ни новый снимок книги, ни ack по другому ордеру, ни изменение позиции, ни денежный механизм тика (аварийная ликвидация, safety-net стоп-лосса, process_due_redeems). Формулировка «блокирует tokio-реактор» — неточна и не должна использоваться; корректно — «останавливает единственный последовательный цикл принятия решений этой стратегии».

Это архитектурное решение подробно задокументировано и обосновано (порядок ветвей осознан, есть регрессионные тесты на порядок и на то, что ветвь tick держит каденцию под непрерывно пополняемой очередью) в docs/target/engine.md, раздел «Тракт доставки рыночных данных стратегии» (строки 569-905 на момент этого аудита) — документ читать НЕ обязательно, чтобы пользоваться этим аудитом, но он первичен по вопросам про диспетчер и канал market_data; здесь эти вопросы не переизлагаются, только используются.


2. Первый шаг диагностики — уже развёрнут, без кода и без деплоя ​

Прежде чем читать остальной документ, стоит проверить его центральную гипотезу (§0.2) самым дешёвым доступным способом:

  1. slow_hook-предупреждения. strategy_driver.rs:1090-1096 логирует tracing::warn!(hook=<имя>, elapsed_ms=<...>, strategy=<...>, strategy_label=<...>, "slow_hook") на любой вызов ЛЮБОГО хука (on_market_data, on_tick, on_order_event, on_position_change, on_closure) дольше 1 секунды (SLOW_HOOK_THRESHOLD, :91). Команда на живом инстансе: искать строки с полем "slow_hook" в логе движка, сопоставить их elapsed_ms и время с временными метками отмен/перевыставлений заявок. Если таких строк нет вообще — центральная гипотеза этого документа (блокировка сериями сетевых вызовов) неверна для наблюдаемого периода, и это нужно прямо написать в следующей итерации анализа, а не подгонять факты под гипотезу.
  2. structure_census (crates/pm-engine/src/observability/structure_census.rs, 324 строки) — одна INFO-строка target=structure_census раз в 30 с (PM_ENGINE_MEM_CENSUS_INTERVAL_MS, по умолчанию 30000 мс) со всеми размерами коллекций сразу: MarketStateStore (states, price-level-lifetime/event-history счётчики), SignalRegistry (sig_state, sig_reverse, sig_by_market), OrderRouter (or_allocations, or_snapshots и т.д.), PositionRegistry (pos_cache, pos_by_sig_*), EventBus (bus_receivers), число живых forwarder-задач WS (forwarder_tasks — течь задач, не только памяти), 25 карт DirectionalRuntimeState поимённо. Публикуется БЕЗУСЛОВНО, включая нули — «нулевой замер сам по себе — сигнал для наклона» (комментарий кода). Как пользоваться: снять две метки времени, посчитать наклон Δ/время по каждому полю; поля, растущие ПРИ СТАБИЛЬНОМ портфеле — подозрение на утечку; sig_state/pos_by_sig_* растут с ИСТОРИЕЙ намеренно (см. §10) — не тревога сами по себе, тревога — их СКОРОСТЬ роста без плато.
  3. Каденция EntryFunnelTick. on_tick публикует этот кадр БЕЗУСЛОВНО, раз на тик; ожидание — около 60 кадров в минуту (задокументировано в docs/target/engine.md). Устойчивая недостача темпа этих кадров на SSE — косвенный, уже существующий индикатор голодания ветви tick (денежные механизмы) без нового кода.

Эти три источника ничего не стоят снять прямо сейчас на живом инстансе и дают прямой ответ, воспроизводится ли гипотеза §0.2 в реальности, до обсуждения любых мер устранения.


3. Путь заявки: подпись → отправка → ответ → ограничитель частоты ​

Доминирующий трафик — 2135 из 2299 BUY-заявок (93%) отменены; путь отмены и серийные перевыставления определяют то, что оператор ощущает как торможение «при торговле».

3.1 Арифметика: не средний темп, а последовательная сериализация всплеска ​

[Ф] Средний темп (2299+2135)/172800 с ≈ 0.026 оп/с — на 2-3 порядка ниже самого узкого биржевого лимита из задействованных в пути заявки (TickSize 200/10с = 20/с, см. §3.4). Средний темп НЕ объясняет ощутимое торможение.

[Ф] Объяснение — в том, ЧТО происходит внутри ОДНОГО вызова хука, когда он происходит, а не как часто это происходит.

3.2 Серии последовательных cancel внутри одного хука ​

prepare_sell_handoff (crates/pm-engine/src/strategies/directional/strategy.rs:8866-8894, проверено построчно) — обычный for-цикл (НЕ join_all) с .await внутри:

rust
for o in ctx.orders_for_signal(signal_uid).into_iter().filter(|o| { ... }) {
    match ctx.cancel_order(o.uid.clone(), reason).await { ... }
}

по каждой открытой SELL-заявке сигнала (роли MakerChase — 2 кандидата, либо TakerExit — 4 кандидата), вызывается из dfav_on_tick (strategy.rs:9585-9760), которая сама циклом идёт по ВСЕМ «взведённым» позициям с активным rank-deterioration-выходом. После цикла отмен — ещё один сетевой вызов emit_order (:9684-9690,9746-9747).

Итого до 3-5 последовательных сетевых оборотов CLOB внутри одного вызова on_tick на одну позицию, которой нужен reprice, ПОСЛЕДОВАТЕЛЬНО, не параллельно. Ограничитель есть: переоценка одной позиции не чаще раза в MAKER_REPRICE_MS = 60_000 мс (runtime_config.rs:642) — то есть не «на каждый тик», а не чаще раза в минуту НА ПОЗИЦИЮ.

Второй экземпляр без часового ограничителя — strategy.rs:6310-6325, триггер on_position_change(Accumulated) при частичной капитуляции (то есть именно в момент, когда сделки реально идут):

rust
for o in &resting {
    if let Err(e) = ctx.cancel_order(o.uid.clone(), "directional-exit-resize-partial-stop").await { ... }
}
self.emit_primary_exit(ctx, &view).await;

Порядок эффекта: [НЕ ИЗМЕРЕНО] абсолютное время. Структурно — блокировка равна сумме (N_cancel + 1) сетевых round-trip'ов, N от 1 до 4, именно во время ощутимой торговой активности (fill-triggered путь).

Устранение: заменить последовательные for ... await на futures::future::join_all/try_join_all там, где отмены НЕЗАВИСИМЫ друг от друга (разные ордера, разные слоты). Цена — низкая (изолированный рефактор двух функций), риск — низкий (отмены не имеют структурной зависимости друг от друга), НО требует regression-теста на детерминизм реплея (parity.yml) и проверки, что параллельный cancel корректно уживается с бюджетным гейтом CLOB (см. §3.4 — параллельные запросы всё равно спишут бюджет по отдельности, это не проблема корректности).

3.3 Путь отмены — DB и сеть по шагам ​

OrderRouter::cancel (order_router.rs:3270-3316, проверено построчно) на ОДНУ типовую отмену:

  1. find_order_by_uid_any_status(...).await — SELECT №1.
  2. self.client.cancel(...).await — РЕАЛЬНЫЙ сетевой вызов (HTTP DELETE к CLOB через ClobClientPool → бюджет OrderSingle → биржа). Кэша здесь нет и не может быть — это мутация, не справочное чтение.
  3. book_cancelled_row(...) (:3328-3382) — транзакция begin + UPDATE (CAS-терминализация) + INSERT события + commit.
  4. guarded_terminal_release(...) (:3985-4059) — ЕЩЁ ОДИН find_order_by_uid_any_status(...).await (SELECT №2); для частично исполненной BUY-отмены — третий SELECT (has_booked_fills_for_order).

[Ф] Типовая отмена (полностью неисполненная): 2 SELECT + 1 сетевой cancel + 1 транзакция (2 оператора + commit).

[Ф, измерено] DB-часть на этой машине (не VPS, нижняя оценка): существующий тест entry_path_profile_write_costs_no_more_than_the_write_the_path_already_does (sqlite.rs), запущен как есть без правок кода:

profile INSERT   p50=206µs p95=317µs max=512µs
signal+event TX  p50=195µs p95=236µs   ← форма транзакции, идентичная cancel-пути
outcome UPDATE   p50=121µs p95=154µs

Оговорка автора теста, обязательная к повтору: таблица в замере почти пустая (сотни строк), диск этой машины ≠ боевой VPS-диск — это НИЖНЯЯ оценка, не боевая задержка.

Вывод: DB-часть на 2-3 порядка дешевле типичного HTTP round-trip'а, если боевой диск сопоставим по классу с этим ([НЕ ИЗМЕРЕНО] на VPS) — то есть не DB, а именно сетевой оборот определяет длительность блокировки на 93% трафика заявок.

3.4 Ограничитель частоты (rate limiter) — самый узкий бюджет списывается и на кэш ​

submit_inner (crates/pm-engine/src/adapters/clob/live/client.rs:510-700) делает ПОСЛЕДОВАТЕЛЬНО (не tokio::join!): get_neg_risk_for → get_tick_size_for → get_fee_rate_bps_for (пропускается при post_only) → prepare_limit_order (подпись) → send_prepared_order (реальный POST).

Первые три — pre-lookup'ы во внешний крейт polymarket-client-sdk v0.4.4, который кэширует результат ПО token_id без TTL (~/.cargo/registry/.../ polymarket-client-sdk-0.4.4/src/clob/client.rs:744-836, прочитано напрямую) — для уже торгуемого токена (а харвестер переторговывает одни и те же токены) практически бесплатны ПОСЛЕ первого раза. НО каждый вызов, даже кэш-хит, всё равно проходит acquire_with_timeout(budget, RateCategory::TickSize, ...) (worker.rs:632-639,663-670,691-698) — бюджет частоты списывается независимо от того, кэш это или сеть.

[Ф, проверено лично чтением crates/pm-sdk/src/clob/pool/budget_table.rs:44-140]:

Категорияburst/10сsustained/10минЗадействована в
OrderSingle (POST+DELETE /order)500048 000submit, cancel — см. §11.1 (расхождение)
TickSize (все 3 pre-lookup'а submit-пути)200нетsubmit (2-3 списания на заявку)

TickSize — самый тесный бюджет в пути заявки: 200/10с ÷ 3 списания на заявку ≈ 66 заявок/10с с taker-комиссией (100/10с при post_only) — это ТЕСНЕЕ, чем OrderSingle. При насыщении acquire_with_timeout (worker.rs:929-968) крутит sleep-ретраи до acquire_timeout = 5с (config.rs:61) — сон идёт в отдельной worker-задаче пула, но StrategyDriver всё равно ждёт результат целиком, то есть до 5 секунд простоя единственного цикла решений на одну pre-lookup операцию при насыщении бюджета.

Насыщается ли это в бою — [НЕ ИЗМЕРЕНО], и хуже того: не наблюдаемо. Счётчики PoolMetrics.budget_saturations_total / acquire_timeouts_total/external_429_total существуют в pm-sdk (crates/pm-sdk/src/clob/pool/metrics.rs, инкрементируются в worker.rs:942-944, 952-954), но нигде не читаются и не экспортируются в pm-engine (проверено grep по всему crates/pm-engine/src/, ноль совпадений). Средний темп заявок (§3.1, 0.026/с) далёк от насыщения 200/10с, но это средняя, не пиковая величина — во время repricing-серии (§3.2) заявки идут кучно.

Устранение:

  • Подключить pool.metrics() к экспорту (Prometheus либо periodic log) — дешёвая, безрисковая мера, закрывающая слепое пятно наблюдаемости независимо от того, подтвердится ли гипотеза о насыщении.
  • Заменить lookup_by_exchange-подобные pre-lookup'ы вызовом, не списывающим бюджет на кэш-хит (нужна правка сигнатуры в pm-sdk, вне pm-engine) — цена выше, требует координации с владельцем pm-sdk.

3.5 EIP-712 подпись — не находка по масштабу эффекта ​

sign_hash для локального EOA-подписанта (alloy-signer-local-1.8.3/src/lib.rs:98-100, код зависимости прочитан напрямую) — синхронная ECDSA-подпись без spawn_blocking, НО выполняется внутри worker-задачи пула (worker.rs::handle_prepare_limit_order), не внутри задачи StrategyDriver напрямую. [НЕ ИЗМЕРЕНО], но структурно ожидаемый вклад — единицы-десятки микросекунд, на 2-3 порядка меньше сетевого round-trip'а в той же цепочке. Не первоочередная точка приложения усилий.


4. Запись улик (stop_dip_contexts, decision_liquidity_profiles) и SQLite ​

4.1 Запись синхронна и встроена в хук — подтверждено по всей цепочке вызовов ​

Полная цепочка прослежена файл-в-файл, без единого tokio::spawn/mpsc::send на этом конкретном пути: strategy_driver.rs:321 (biased select!) → strategy.rs:10021 (on_market_data) → strategy.rs:1786 (try_emit_entry) → strategy_context.rs:1279-1297 (record_decision_liquidity_profile/record_stop_dip_context) → sqlite.rs (sqlx::query(...).execute(&self.pool).await внутри tx.begin()). Гипотеза «есть фоновая очередь» опровергнута чтением кода.

Контраст в том же модуле: entry_pattern/sink.rs — ДРУГОЙ evidence-поток того же модуля directional — уже использует ПРОТИВОПОЛОЖНЫЙ паттерн: try_send() в bounded mpsc (sink.rs:314, не .await) + отдельная tokio::spawn(writer_loop(...)) (sink.rs:516) + собственное raw-соединение в ОТДЕЛЬНОМ файле БД (writer.rs:81-106, подтверждено тестом database_is_separate_from_any_engine_database, writer.rs:534-559). Это не гипотетическая архитектура — она уже реализована, оттестирована и работает в этом же крейте, просто не применена к stop_dip_contexts/ decision_liquidity_profiles.

4.2 Сколько отдельных транзакций (= fsync при synchronous=FULL) на одно решение ​

Одна попытка входа (только evidence-часть, [Ф], прочитано построчно):

  1. record_selection_signal — отдельный автокоммит.
  2. insert_decision_liquidity_profile (sqlite.rs:2105-2242) — ОДНА транзакция на неск. операций (INSERT профиля + SELECT кандидатов на обрезку
    • условный DELETE + условный UPSERT счётчика) — автор кода прямо объясняет это решение (sqlite.rs:2098-2102): «один fsync вместо двух — на каденции ~10^5 вставок в час это не микрооптимизация».
  3. finalize_selection_signal_outcome — отдельный автокоммит.

Минимум 3 отдельных fsync-границы на одну попытку входа — нижняя граница: бизнес-записи того же решения (сам ордер в orders/positions/events через тот же единственный пул) идут по этой же awaited-цепочке и здесь не трассировались; итоговое число на решение ≥ 3.

Одна оценка стоп-лосса — 0 или 1 запись, гейтуется конфигом (stop_loss.record_dip_context) и троттлом STOP_STATE_REPEAT_LOG_INTERVAL_MS = 60_000 мс НА СИГНАЛ (strategy.rs:763).

Стоимость самого fsync — [НЕ ИЗМЕРЕНО] на боевом диске. Локальный микробенч дал противоречивые числа из-за неявного батчинга CLI-скрипта — публиковать их как стоимость fsync было бы некорректно. Нужен strace -T -e trace=fsync,fdatasync на процессе pm-engine НЕПОСРЕДСТВЕННО на VPS.

4.3 Один пул на весь процесс — точка сериализации шире, чем кажется ​

[Ф] Ровно ОДИН продовый SqlitePoolOptions::new() в непустом коде (sqlite.rs:1246-1250, max_connections(1)), вызывается один раз (main.rs:443, open_storage). config.db и wallets.db НЕ используют sqlx — читаются через rusqlite::Connection напрямую, один раз при старте, не на горячем пути.

[Ф] Arc<dyn PersistencePort> (обёртка над этим единственным соединением) клонируется 30 раз по main.rs — в OrderRouter, PositionRegistry, периодические свипы (resolution_sweep, open_position_reconcile, redeem_drain), health-monitor, SSE-построители, EngineStrategyContext.

Следствие важнее самого числа выделенных ОС-потоков (их 1, катастрофой не является): любая DB-транзакция ЛЮБОЙ части процесса — не только стратегии — встаёт в очередь на checkout этого ОДНОГО соединения. INSERT с 4КБ JSON-блобом в decision_liquidity_profiles блокирует не только следующую итерацию StrategyDriver, но и параллельные запросы SSE /seed, health-монитора, периодических свипов. Это прямое следствие max_connections(1) + общего Arc, доказательства латентности fsync не требует.

Позитивная находка: во всех проверенных периодических задачах (resolution_sweep, open_position_reconcile, RedemptionService::redeem_position) сетевой вызов (Gamma / on-chain RPC) вынесен ЗА ПРЕДЕЛЫ DB-транзакции — соединение не удерживается на время сетевого ожидания (redemption_service.rs:448-461, комментарий кода прямо объясняет это как защиту от краша с зависшей открытой транзакцией). Значит связывающий механизм — не «сеть держит лок», а именно очередь acquire() на пуле во время самих DB-транзакций.

4.4 PRAGMA synchronous — не понижена для engine.db, в отличие от двух других БД ​

[Ф] grep -i synchronous по sqlite.rs — ноль совпадений. По исходнику sqlx-sqlite 0.8.6 (options/mod.rs:187-189,327, прочитано напрямую): если .synchronous(...) не вызван, действует библиотечный дефолт SQLite — FULL.

Контраст: config.db (strategy_config.rs:1657-1660) и wallets.db (wallet_store.rs:283-287) ЯВНО понижены до NORMAL под WAL — осознанное решение, задокументированное комментарием («same pragmas as pm-bot's state.sqlite»). Для engine.db такого решения НЕ найдено — это, судя по всему, отсутствие решения, а не намеренный выбор именно FULL.

Это НЕ рекомендация тихо понизить до NORMAL — engine.db хранит деньги (orders/positions), и NORMAL под WAL может потерять последние закоммиченные транзакции при потере питания (коррупции не создаёт, но данные теряет). Это операторское риск-решение по ВСЕЙ БД, не только по evidence-таблицам — см. §11.2.

4.5 stop_dip_contexts растёт неограниченно — новая незарегистрированная задолженность ​

[Ф] decision_liquidity_profiles — ОГРАНИЧЕН: обязательный retention_hours > 0 (config.rs:694-719, пустое значение запрещено валидацией) + на каждой вставке обрезка PROFILE_RETENTION_PRUNE_BATCH = 4 самых старых строк старше retention_before_ts_ms (sqlite.rs:2175-2238).

[Ф] stop_dip_contexts — НЕ ограничен. grep по sqlite.rs и по всему crates//scripts/ на DELETE FROM stop_dip — ноль совпадений. Явный комментарий у DDL (sqlite.rs:788-796): «В ОТЛИЧИЕ от selection_signals НЕ входит в wipe_runtime_data: данные копятся форвардом неделями, операторский Reset их НЕ стирает». Подтверждено тестом, а не только комментарием: sqlite.rs:8227-8236, assert_eq!(after, 1, "stop_dip_contexts MUST survive Reset (forward-accumulating diagnostics)").

Класс проблемы тот же, что у исторического price_level_lifetimes (уже исправлен, не переисследовался) — растущая структура без TTL. Разница — здесь это диск (SQLite-файл), не оперативная память, поэтому не приведёт к OOM, но приведёт к росту файла БД и, со временем, к деградации локальности B-дерева/checkpoint-издержек. grep по tech-debt-backlog.md на stop_dip — ноль совпадений: риск нигде не зафиксирован как известный.

Устранение: скопировать уже готовый паттерн decision_liquidity_profiles (PROFILE_RETENTION_PRUNE_BATCH, sqlite.rs:2175-2238) на stop_dip_contexts. Цена низкая (переиспользование существующего кода), риск низкий — но горизонт хранения (сколько часов/дней) это решение оператора, а не техническое (диагностика форензики полагается на глубину истории — см. CLAUDE.md §EV validation про то, что стоп-фильные данные используются для форензики).


5. Пользовательский канал и букинг наливов ​

5.1 Поправка к посылке: user_ws_accounting.rs — не парсинг филлов ​

[Ф] crates/pm-engine/src/engine/user_ws_accounting.rs (655 строк, прочитан целиком) — это health-supervisor drain-политики учётной плоскости (ControlChannelClosed/TerminalFailure/ReconciliationFailure), НЕ парсер кадров и не источник PositionChange. position_change_rx в этом файле не упоминается вообще.

Реальная цепочка (проверена файл-в-файл):

  1. Парсинг кадра → OrderEvent: correlator.rs::apply_user_event (:425) → apply_trade/apply_order (:437,566) — СИНХРОННЫЕ функции, матчинг против OrderRegistry (registry.rs, parking_lot::Mutex).
  2. translator_loop (client.rs:1147) → mpsc::Sender<OrderEvent> (буфер 1024, client.rs:70) → StrategyDriver читает в ветке order_events_rx (strategy_driver.rs:363, 3-й приоритет).
  3. StrategyDriver::handle_order_event (:485) на Fill вызывает PositionRegistry::apply_buy_fill/apply_sell_fill (position_registry.rs:898,1067).
  4. PositionRegistry::broadcast_change (:758) → broadcast::Sender<PositionChange> → ТОТ ЖЕ StrategyDriver, ветка position_change_rx (2-й приоритет, обрабатывается раньше следующего order_events) — на СЛЕДУЮЩЕЙ итерации того же цикла, не отдельной задачей.

5.2 Локи через .await — риска не обнаружено (отрицательный, но проверенный результат) ​

PositionRegistry.cache: std::sync::Mutex<...> — во ВСЕХ проверенных функциях fill-пути (apply_buy_fill, apply_sell_fill_inner, open_new_position, grow_existing_position, apply_partial_sell, apply_terminal_sell, book_resolution_pnl) guard дропается ДО первого .await в функции — 14 проверенных сайтов, дисциплина выдержана. OrderRegistry.inner: parking_lot::Mutex в correlator.rs — используется только в синхронных функциях без .await внутри, вопрос структурно не может возникнуть. Дополнительная структурная гарантия: std::sync::MutexGuard не Send, и async-функция, держащая его через .await, не скомпилировалась бы как Send, требуемый для tokio::spawn.

5.3 O(n) линейный скан вместо O(1) по составному ключу ​

[Ф] PositionRegistry.cache: HashMap<PositionUid, PositionState>, но букинг фила ищет позицию по кортежу (signal_id, condition_id, token_id, wallet_label, status), которого нет в ключе — отсюда два полных линейных прохода cache.values() на каждый филл (apply_buy_fill:926-948, apply_sell_fill_inner:1172-1194). Тот же паттерн — в PositionQuery::shares_for_market/sum_open_entry_cost* (:2698,2708,2718), вызываемых OrderRouter на КАЖДОЕ решение о размещении заявки (риск-лимиты 1 и 3).

Порядок эффекта: [НЕ ИЗМЕРЕНО] — O(n), n = число сейчас активных позиций в кеше. Для directional/Harvester (single-entry-per-signal, ADR-024, бюджет ограничивает число одновременных позиций) n структурно небольшое — эффект вероятно малозаметен при текущих объёмах, но это предположение, не замер.

Устранение: вторичный индекс HashMap<(SignalDbId, ConditionId, TokenId, String), PositionUid> рядом с уже существующим positions_by_signal_db_id (прецедент такого индекса уже есть в этом же файле). Цена умеренная, риск — не забыть точку обновления при будущей мутации (тот же класс бага, который код уже документирует на positions_by_signal_db_id, см. §10). Замерить n на бою ПЕРЕД тем, как оправдывать эту работу — без замера цена рефакторинга не обоснована.

5.4 Мелкая находка: лишний клон ради .is_some() ​

OrderRegistry::lookup_by_exchange (registry.rs:274-280) клонирует ВЕСЬ LiveOrderState (включая растущие fills/anonymous_fills) только чтобы проверить наличие — вызывается ТРИЖДЫ за один filled-фрейм (correlator.rs:480,502,568), результат клона сразу отбрасывается. Реальная мутация идёт отдельно, через with_state_mut, без клона. Устранение: добавить is_known(&self, exchange_id: &str) -> bool без .cloned(). Цена — тривиальная, риск — нулевой (read-only). [НЕ ИЗМЕРЕНО] — алгоритмически ограничено числом филлов НА ОДНОМ ордере, вероятно малый эффект.

5.5 DB-стоимость одного филла ​

[Ф] Минимум 2 отдельные SQLite-транзакции (UPDATE в orders + INSERT в positions/events) = минимум 2 fsync, плюс 1 индексированный SELECT на идемпотентность — последовательно, на том же единственном max_connections(1)-соединении (§4.3). Латентность самого fsync [НЕ ИЗМЕРЕНО].

5.6 Фильтр «замороженных рынков» — фикс уже слит (память устарела) ​

Память проекта (project_user_ws_frozen_market_filter_2026-07-27.md) утверждала «ветка ждёт слияния» по состоянию на 2026-07-29. [Ф, проверено по HEAD]: фикс уже в main — UserSubscription::{AllMarkets, Markets(Vec<String>)} (pm-sdk/src/ws/user/subscription.rs:33), рост набора подписки перед КАЖДЫМ submit (ensure_market_subscription, client.rs:372-386,544), покрыт регрессионными тестами именно на этот инцидент (subscription_growth_tests.rs:351,480,521,540). Пункт закрыт, память следует пометить устаревшей.


6. Периодические задачи и блокировки ​

ЗадачаТочка запускаИнтервалЧто трогает из горячего пути
discovery_coordinatormain.rs:268960с + событийный fast-path листингатот же Arc<dyn PersistencePort>, MarketMetadataRegistry
resolution_sweepmain.rs:2833300с, батч 64тот же пул (до 64 транзакций за тик)
open_position_reconcilemain.rs:2872300с, батч 64тот же пул
redeem_drainmain.rs:280330стот же пул + RedemptionService
ambiguous_submit_sweepmain.rs:290730сOrderRouter → тот же пул
regime_evaluatormain.rs:2569не запускается—
circuit_breakermain.rs:2158событийный (подписка на изменения позиций)—
protective_resubmit_runnermain.rs:2957событийный, дедлайн PROTECTIVE_RESUBMIT_DEADLINE_MS—

[Ф] spawn_regime_evaluator — единственная точка вызова строго внутри if let Some(cfg) = regime_config (main.rs:2561-2569); при боевом config.regime=None задача не заводится вообще — не «работает вхолостую», а буквально отсутствует как процесс. Проверка подтверждает факт из CLAUDE.md, а не опровергает.

Центральная находка: не внутрипроцессные Mutex/RwLock (см. §5.2 — дисциплина выдержана всюду, где проверено, parking_lot::RwLock в MarketStateStore доступен только через синхронные функции, физически не может держаться через .await), а тот же единственный пул max_connections(1) (§4.3), который явно связывает ВСЕ периодические задачи с горячим путём: любая их DB-транзакция встаёт в очередь наравне с persist_order_event горячего пути. Позитивно: сетевые вызовы (Gamma/RPC) везде вынесены ЗА пределы транзакций (§4.3) — связывающий механизм именно acquire()-очередь на транзакциях, не удержание лока на время сети.

Порядок эффекта — [НЕ ИЗМЕРЕНО]. Нужен либо боевой замер распределения задержки persist_order_event в моменты, коррелирующие с тиками свипов, либо дешёвая инструментация (гистограмма времени acquire() на пуле — тем же паттерном, что structure_census, без изменения поведения).


7. Цикл оценки стратегии на один снимок (on_market_data/on_tick) ​

7.1 Условная находка: наблюдение entry-pattern считает LDI и клонирует книгу ДО всех гейтов ​

strategy.rs:10046, безусловный вызов внутри on_market_data (ДО entries_halted/exchange_degraded/warmup-гейта):

rust
let pattern_observation = self.observe_entry_pattern_snapshot(ctx, snap, now);

Комментарий кода объясняет, что это НЕ недосмотр: «граница популяции датасета — именно этот хук, до допуска» (entry-observation-stage, реализовано 2026-08-12 по памяти проекта). Функция гасится на раннем ?, если config.entry_pattern_recording не задан — тогда стоимость нулевая.

Если фича ВКЛЮЧЕНА, внутри безусловно:

  • strategy.rs:9882 — НАСТОЯЩЕЕ вычисление LDI (ctx.ldi_snapshot), независимо от того, что directional.regime в бою выключен — это отдельный, всегда активный компонент. Внутри (ldi/composite.rs:65-78) — клон event_history (до 500 записей на токен, все поля скалярные — дешёвый Vec-клон) и .clone() HashMap price_level_lifetimes (растёт до TTL, дороже — рехэш, не memcpy). Авторы сами называют это «тяжёлое/аллоцирующее» вычисление в комментарии.
  • strategy.rs:9849-9865 — ctx.book_snapshot ПОЛНЫЙ клон книги комплемент-токена (market_state_store.rs:2041-2052).
  • strategy.rs:9866-9881 — построение gamma_cohort, новый Vec.

Состояние на бою [НЕ ПРОВЕРЕНО] — это ключ config.db, читать который этому агенту запрещено. Если включено — это работа на КАЖДЫЙ снимок КАЖДОГО рынка, до remoteHalt-гейта (то есть и во время drain).

Важно: точку вызова двигать НЕЛЬЗЯ без согласования — это меняет семантику собираемого датасета (с «каждый доставленный снимок» на «каждый снимок, переживший допуск»), а не безобидная оптимизация. Правка без риска для контракта данных — сделать ленивыми ТОЛЬКО дорогие поля внутри (LDI/книга-комплемент/gamma_cohort) при неактивном режиме допуска; решение по семантике — за архитектором стратегии, не за этим документом.

Первый шаг — не код, а операторская проверка: включён ли directional.entry_pattern_recording на бою прямо сейчас.

7.2 Глобальный O(N) скан на каждый снимок ​

SignalRegistry::signals_for_outcome (signal_registry.rs:576-589) — полный проход по ГЛОБАЛЬНОЙ карте state_by_market с фильтром по (condition_id, token_id), при том что это ПРЕФИКС существующего составного ключа. Комментарий кода сам называет границу: «O(N) scan, N ≤ 50 активных сигналов в проде — по плану архитектора» (:572) — установленный факт из кода, не моя оценка.

Вызывается ДВАЖДЫ на снимок: strategy.rs:10373 (условно, но stop_loss обычно сконфигурирован) и strategy.rs:10425 (безусловно, для КАЖДОГО снимка КАЖДОГО рынка, включая рынки без единой открытой позиции).

Порядок эффекта (иллюстрация, не замер): при N≤50 и потолке throughput 64 снимка/с (установленный факт, §0.1 — актуальность которого сама зависит от версии боевого бинаря) — порядка 64×50×2 ≈ 6400 сравнений/с. Дёшево по CPU само по себе, но чистая трата для рынков без единой позиции в этой карте.

Устранение: ключ карты уже составной (ConditionId, TokenId, SignalKind) — BTreeMap + .range() по префиксу, либо перекладка в HashMap<(ConditionId, TokenId), Vec<...>>, даёт O(1)/O(log N). Риск умеренный — обязателен regression на детерминизм реплея (parity.yml).

7.3 retain() на пути ЧТЕНИЯ вместо раз-в-тик — у проблемы уже есть образец решения в этом же файле ​

Ровно два случая (из 16 .retain() в runtime_state.rs, остальные 14 — корректный lazy-prune-на-запись редких событий): note_entry_evaluation (:3405-3413) и entry_refusal_deferred (:3381-3390) чистят TTL-карту ВНУТРИ пути чтения, вызываемого на каждый снимок/кандидат.

Технический факт про std::collections::HashMap (устройство структуры данных, не догадка): .retain() проходит по ВСЕЙ занятой ёмкости backing- таблицы, а HashMap НИКОГДА не уменьшает capacity сам — если карта хоть раз выросла к потолку (ENTRY_EVAL_DEDUP_MAX_ENTRIES = 10_000, :126), стоимость КАЖДОГО последующего retain() навсегда поднимается до этой раздутой ёмкости, даже когда живых записей снова стало мало.

У проблемы уже есть верный образец решения в этом же файле — outcome_coverage (:2473-2475) с явным комментарием (:2460-2462): «зовётся раз в тик, поэтому обход коллекции стоит одного прохода в секунду, а не одного на снимок». Для outcome_visits этот перенос УЖЕ сделан; для entry_eval_dedup/entry_refusal_defer — нет.

Устранение: вынести retain() из обеих функций в отдельный метод, вызываемый раз в on_tick, оставив в путях чтения только .get() — прополка нужна только чтобы карта не росла, не для корректности решения (TTL и так проверяется на чтении). Риск низкий, есть прецедентный тест (the_map_is_bounded_and_eviction_leads_to_re_evaluation, :7114).

7.4 JSON-сериализация — правило sonic_rs соблюдается ​

[Ф] grep -rn "serde_json" по strategies/directional/ — все 3 совпадения внутри #[cfg(test)]. Нарушений на горячем пути не найдено. decision_trace_sink.rs по умолчанию инертен (main.rs:1741-1753, None без PM_ENGINE_ADMISSION_TRACE_CONDITION_ID); entry_pattern сериализует sonic_rs в ОТДЕЛЬНОЙ tokio-задаче (§4.1), не на пути хука — не находка. Единственная стоимость на пути хука — ПОДГОТОВКА данных к сериализации (клоны из §7.1), не сама сериализация.


8. Тракт рыночных данных: приём → стакан → рассылка ​

Эта область детально и авторитетно описана в docs/target/engine.md, раздел «Тракт доставки рыночных данных стратегии» (строки 569-905) — не переизлагается здесь, только сводка релевантного для этого документа:

  • Старый механический потолок «64 снимка/с» (MAX_MARKET_DATA_STREAK, предикат allow_market_data) удалён из кода (коммит 9c050740, openspec/changes/archive/2026-07-30-dispatcher-arm-priority-replaces-ceiling) — темп доставки задаёт теперь СТОИМОСТЬ хука on_market_data, а не константа диспетчера.
  • TD-MD-CEILING-2 (tech-debt-backlog.md) — стоимость хука ПОД РЕАЛЬНОЙ нагрузкой (с отправками ордеров) до сих пор не измерена, измерена только на холостом ходу. Находки §7 этого документа частично закрывают этот пробел СТАТИЧЕСКИМ чтением кода (что именно стоит дорого), но не заменяют живой замер.
  • Критично: TD-MD-CEILING-3 явно запрещает выкат этого фикса на торгующий инстанс до закрытия волны B — см. §0.1. Старый замер (64/с, ≈4с) может быть ещё в силе на бою прямо сейчас.

Собственная проверка (лёгкая, не такой же глубины, как остальные разделы): crates/pm-engine/src/adapters/orderbook/clob_ws.rs — форвардер использует CoalescingQueue (latest-wins на токен), permit.send(update) — согласуется с SSOT. mapping.rs::event_to_orderbook_updates строит новые Vec<PriceLevel> на каждое событие — обычная стоимость на кадр, не выявлено отклонений от ожидаемого поведения. Честная оговорка: аллокационный аудит этих двух файлов был поверхностным (без такой же глубины, как §3-§7) — если нужен такой же уровень строгости здесь, это отдельная задача.


9. «Парадокс» охвата вселенной — разрешён ​

Числа оператора (1254 рынка → 596мс, 726 → 908мс, 198 исходов → 1738мс) пересказывают поле mean_revisit_ms структуры OutcomeCoverage (runtime_state.rs:2473-2499, DirectionalRuntimeState::outcome_coverage):

rust
OutcomeCoverage {
    unique_outcomes: visits.len() as u64,
    subscribed_outcomes,               // ← пробрасывается СНАРУЖИ, формула его не знает
    mean_revisit_ms: (interval_count > 0).then(|| span_sum / interval_count as i64),
    ...
}

[Ф] Это НЕ проход/сканирование структуры данных и НЕ сетевой round-trip к Gamma — это средний интервал МЕЖДУ WS-снимками по уже подписанным исходам за скользящее 60-секундное окно (note_outcome_visit, вызывается на каждый доставленный MarketDataSnapshot, strategy.rs:10041). [Ф] subscribed_outcomes (число рынков) в саму формулу mean_revisit_ms НЕ входит — doc-comment структуры прямо это утверждает (runtime_state.rs:1809-1814): «пробрасывается вызывающим — коллекция его не знает и знать не должна». Это две структурно НЕЗАВИСИМЫЕ величины, показанные рядом на одной панели дашборда.

Отсюда «парадокс» — не парадокс: наблюдаемая отрицательная корреляция не противоречит никакой зависимости в коде, потому что зависимости нет вовсе. [Г, правдоподобная, не доказанная]: при 1254 подписанных исходах среди них, вероятно, было много ликвидных (например, недалёких от закрытия 5-минутных крипто-рынков), которые шлют WS-обновления почти непрерывно — отсюда короткий средний интервал; при 198 — вероятно, затишье/малоликвидные рынки. Подтвердить это можно только сопоставлением состава подписки в каждый момент с реальной частотой WS-кадров — [НЕ ИЗМЕРЕНО].

Отдельно стоит уточнить у оператора: два из трёх чисел названы «рынками», одно — «исходами», а метрика в коде и на дашборде всегда оперирует именно ИСХОДАМИ (coverage_subscribed_outcomes). Если два числа реально были сняты с ДРУГОЙ панели (число рынков, не исходов) — сопоставлялись вообще разные величины, и «парадокс» отчасти терминологический.


10. Неограниченный рост коллекций — сводка ​

Структураfile:lineКлассСтатус
stop_dip_contexts (SQLite)sqlite.rs:788-796, тест :8227-8236Неограничен, нет TTL/ResetНовая незарегистрированная задолженность (§4.5)
SignalRegistry.statesignal_registry.rs:119,151-155,543-548Неограничен, растёт с историей сигналовНамеренно, задокументировано дважды (комментарий структуры + метода); НЕ закреплено отдельным тестом-оракулом на удержание
PositionRegistry.positions_by_signal_db_idposition_registry.rs:302-311Неограничен, растёт с историей позицийТот же паттерн, задокументирован как намеренный
decision_trace_sink::RecordingDecisionSinkdecision_trace_sink.rs:182-186Неограниченный Vec, клонируется дважды на тикИспользуется ТОЛЬКО в реплей/тест-гарнессе; в бою — стейтлес TracingDecisionSink (main.rs:1749), ничего не хранит
MarketMetadataRegistrymarket_metadata_registry.rs, эвикшн main.rs:4405Был неограниченУже исправлено — активный evict_unless_held на каждое закрытие рынка
price_level_lifetimesmarket_state_store.rsБыл неограничен, исторический OOM-кореньУже исправлено ранее (не переисследовалось)
SignalRegistry.reversesignal_registry.rs:122-124,139Был неограничен («SIG-LEAK»)Уже исправлено, пруниться на terminal-переходах, покрыто тестами

Контрастный вывод: команда уже умеет чинить этот класс проблем (3 из 7 строк таблицы — уже исправленные прецеденты), просто stop_dip_contexts выпал из поля зрения, вероятно потому что это диск, а не память — предыдущие инциденты все были про OOM.


11. Расхождения источников — решение за оператором, не за этим документом ​

11.1 Лимит POST/DELETE /order — 5000/10с (код) vs 3500/10с (установленный факт) ​

[Ф, проверено лично] crates/pm-sdk/src/clob/pool/budget_table.rs:48-54:

rust
RateCategory::OrderSingle => CategoryBudget { burst_per_10s: 5000, sustained_per_10min: Some(48_000) }

Комментарий рядом (pool/mod.rs:301, worker.rs:747-748) ссылается на «per docs 2026-05-21». Установленный ранее (не мной, задачей) факт — 3500/10с. Источники расходятся — по правилу проекта («если источники расходятся — эскалация, не выбор в пользу одного») это не разрешается этим документом. Если верно 3500 — локальный лимитер разрешает БОЛЬШЕ, чем реально примет биржа, то есть не защищает от внешних 429 в диапазоне 3500-5000/10с. При текущей средней частоте заявок (§3.1) это не проявляется, но риск латентный. Нужна сверка с актуальной документацией CLOB, не с комментарием в коде 2026-05-21.

11.2 PRAGMA synchronous на engine.db ​

Не расхождение источников, но операторское риск-решение — см. §4.4. Документ предлагает НЕ понижать pragma по всей БД, а вынести evidence-таблицы на отдельный writer (§4.1) как способ получить выигрыш без риска для денежных данных; если оператор хочет рассмотреть именно понижение pragma — это отдельное явное решение с ясно проговорёнными потерянными семантиками (не коррупция, но возможная потеря последних транзакций при потере питания).


12. Сводная таблица находок по отношению эффект/цена ​

Ранжирование строится на одном реально измеренном ориентире (§3.3: DB-транзакция формы «2 оператора + commit» ≈ 195-236 мкс, нижняя оценка) и на принципе: меры, копирующие уже проверенный в этом же репозитории паттерн, дешевле и безопаснее мер, требующих нового дизайна.

№Находкаfile:lineЭффект (порядок)ЦенаРискПрецедент в репо
1Серии последовательных cancel в одном хуке (§3.2)strategy.rs:8866-8894, 6310-6325НЕ измерено; структурно доминирует над DB (сеть на 2-3 порядка дороже 195мкс)низкаянизкий (regression на parity)—
2Экспорт budget_saturations_total/acquire_timeouts_total (§3.4)pool/metrics.rs, worker.rs:942-954закрывает слепое пятно наблюдаемости независимо от исхода гипотезынизкаянулевой—
3Асинхронный writer для stop_dip_contexts/decision_liquidity_profiles (§4.1)sqlite.rs:2932-3017,2105-2242НЕ измерено; убирает ≥3 fsync-границы из хука на попытку входасредняясредний (возможна потеря строк при переполнении очереди — уже принятый в проекте компромисс для entry_pattern)entry_pattern/{sink,writer}.rs
4Retention для stop_dip_contexts (§4.5)sqlite.rs:2175-2238 (образец)останавливает неограниченный рост файла БДнизкаянизкийdecision_liquidity_profiles
5retain() раз в тик вместо на чтение (§7.3)runtime_state.rs:3405-3413,3381-3390НЕ измерено; устраняет деградацию HashMap-ёмкости после всплесканизкаянизкийoutcome_coverage (:2473-2475)
6Вторичный индекс позиций вместо O(n)-скана (§5.3)position_registry.rs:926-948 и др.НЕ измерено; n структурно мало для Harvesterсредняясредний (не забыть точку обновления индекса)positions_by_signal_db_id
7signals_for_outcome O(N)→O(1) (§7.2)signal_registry.rs:576-589НЕ измерено; N≤50 по кодусредняясредний (regression на parity)—
8Ленивые дорогие поля в observe_entry_pattern_snapshot (§7.1)strategy.rs:9882,9849-9865,9866-9881зависит от того, включена ли фича на бою — [НЕ ПРОВЕРЕНО]средняя (меняет контракт датасета — нужно согласование архитектора)средний—
9is_known() без клона (§5.4)registry.rs:274-280НЕ измерено, вероятно малыйтривиальнаянулевой—

Пункты §11 (расхождение лимита, pragma) намеренно НЕ включены в таблицу — это решения оператора, не инженерные меры с ценой/риском.


13. Первоочередные меры (для обсуждения с оператором) ​

  1. Установить, какой коммит реально исполняется на бою, и снять slow_hook- лог за характерный период торможения (§0.1, §2). Не код и не деплой — один факт (дата/SHA последнего engine-bot деплоя) и один grep по уже существующим логам. Приоритет №1, потому что от ответа зависит, актуальны ли находки §3-§9 вообще, или в силе всё ещё старый замер «64/с, 4с». Без этого шага любая мера ниже рискует чинить не ту причину.
  2. Параллелизировать независимые cancel в prepare_sell_handoff и exit-resize-цикле (§3.2, таблица №1). Самая дешёвая и наиболее прямо связанная с жалобой мера: 93% трафика заявок — отмены, паттерн срабатывает именно в момент активности (fill-triggered), а сетевой round-trip структурно на порядки дороже DB-части, которая уже измерена. Риск ограничен изолированным рефактором двух функций плюс regression на детерминизм.
  3. Подключить наблюдаемость бюджета CLOB-клиента и retention для stop_dip_contexts (§3.4, §4.5, таблицы №2 и №4). Обе — на уровне переиспользования готового кода/паттерна, нулевой-низкий риск, закрывают слепое пятно (нельзя опровергнуть или подтвердить гипотезу о насыщении лимитера без первой) и незарегистрированную задолженность без явного владельца.
  4. Проверить directional.entry_pattern_recording на бою (§7.1). Один операторский запрос к config.db дешевле любого кода — если фича включена, находка №8 переходит из «условной» в «активную» и её приоритет резко растёт (безусловный LDI-расчёт + клон книги на каждый снимок каждого рынка — это именно тот класс проблемы, который совпадает с жалобой «тормозит при торговле», так как торговая активность коррелирует с активностью книги).
  5. Асинхронный writer для evidence-таблиц (§4.1, таблица №3) — после пунктов 1-4, не вместо них. Крупнейшая по цене мера в списке (новая очередь + writer-задача), но убирает наибольшее число fsync-границ из горячего пути и имеет прямой, уже работающий в этом же крейте образец. Ставится позже остальных, потому что требует решения о семантике потери строк при переполнении очереди — операторский компромисс, не чисто техническая правка.

14. Что осталось неизмеренным (полный список) ​

  • Дата/SHA боевого бинаря pm-engine — определяет, действует ли ещё старый потолок 64 снимка/с, 4с задержки книги (§0.1).
  • Состояние ключа config.db → directional.entry_pattern_recording на бою (§7.1) — не проверялось, запрещено этому агенту.
  • Реальный сетевой round-trip VPS↔CLOB под нагрузкой — ни для submit, ни для cancel (§3.2, §3.3).
  • budget_saturations_total/acquire_timeouts_total на бою — сейчас в принципе не наблюдаемы (§3.4).
  • Латентность fsync/fdatasync на боевом VPS-диске (Hetzner FI) — локальный микробенч дал противоречивые числа и не публикуется как факт (§4.2, §4.4).
  • Реальный боевой размер n активных позиций в PositionRegistry.cache, нужный, чтобы оценить эффект находки §5.3.
  • Частота SignalRegistry::emit() в проде (сигналов/час) — внешняя величина, снимается только с наклона поля sig_state в structure_census за известный интервал (§2, §10).
  • Состав подписанных исходов в три момента замера оператора (1254/726/198) и реальная частота их WS-кадров — нужно для подтверждения гипотезы §9, а не только для «разрешения парадокса» на уровне механизма.
  • Аллокационный аудит clob_ws.rs/mapping.rs той же глубины, что у остальных разделов (§8) — выполнен только поверхностно.
  • Действующий лимит POST/DELETE /order на бирже прямо сейчас — 5000 или 3500/10с (§11.1) — нужна сверка с актуальной документацией CLOB.

Приложение: проверенные файлы (не исчерпывающий список, для навигации) ​

crates/pm-engine/src/engine/{strategy_driver.rs, order_router.rs, position_registry.rs, signal_registry.rs, discovery_coordinator.rs, resolution_sweep.rs, redeem_drain.rs, ambiguous_submit_sweep.rs, redemption_service.rs, market_metadata_registry.rs, market_state_store.rs, user_ws_accounting.rs, ldi/composite.rs}, crates/pm-engine/src/strategies/directional/{strategy.rs, runtime_state.rs, decision_trace_sink.rs, entry_pattern/{sink.rs,writer.rs}, liquidity_profile.rs}, crates/pm-engine/src/adapters/{clob/live/{client.rs, correlator.rs, registry.rs}, storage/sqlite.rs, orderbook/{clob_ws.rs, mapping.rs}}, crates/pm-sdk/src/clob/pool/{budget_table.rs, worker.rs, mod.rs}, crates/pm-sdk/src/{eip712/order.rs, auth/credentials_l1.rs, trading.rs}, docs/target/engine.md, docs/history/tech-debt-backlog-2026-08-31.md.

Рабочая копия: /home/me/RustroverProjects/polymarket-engine/.claude/worktrees/agent-a246861dbb989b4ca, HEAD cef6b94f. Код не менялся. VPS и config.db не трогались.