Skip to content

Why ​

Пропускная способность тракта рыночных данных ограничена не ресурсами машины, а одной последовательной конструкцией. Это фиксируется как факт, а не как гипотеза.

Ресурсы простаивают. Рантайм — голый #[tokio::main] (crates/pm-engine/src/main.rs), то есть многопоточный планировщик с числом рабочих потоков по числу ядер. На боевом инстансе восемь ядер, у контейнера pm-engine-harvester-engine не выставлено ни одного ограничителя процессора (NanoCpus=0, CpuQuota=0, CpuShares=0, CpusetCpus пуст — снято docker inspect 2026-07-27). Живое потребление в тот же момент — 4.19% в нормировке «процент одного ядра», при восьми доступных. Процессор ограничителем не является, и запас — примерно два порядка.

Горячий путь сужается дважды. Оба сужения последовательны по построению.

  1. Одна задача-продюсер (spawn_market_data_producer, crates/pm-engine/src/main.rs) сводит потоки всех рынков в один tokio_stream::StreamMap и ставит готовый снимок в канал на MARKET_DATA_CHANNEL_BUFFER = 256 мест (константа и создание канала — crates/pm-engine/src/main.rs) БЛОКИРУЮЩЕЙ отправкой.
  2. Один StrategyDriver (crates/pm-engine/src/engine/strategy_driver.rs) разбирает шесть ветвей biased tokio::select! и не начинает следующую итерацию, пока текущий хук не вернул управление.

Ограничитель MAX_MARKET_DATA_STREAK = 64 (константа и предикат allow_market_data в crates/pm-engine/src/engine/strategy_driver.rs) описан и нормирован отдельным изменением market-data-throughput-ceiling. Настоящее изменение опирается на тот документ и добавляет к нему ровно одно утверждение: снятие потолка 64 упрётся в продюсера, потому что продюсер — второе сужение, стоящее ВЫШЕ по потоку. Шардировать надо оба, иначе поднятие константы переносит очередь из канала в StreamMap и не увеличивает темп.

Чего это стоит сегодня. Величина, которую оператор читает как охват сканирования, измеряет потолок диспетчера; возраст книги на входе стратегии измеряется секундами. Обе величины, живые замеры и оговорки к ним — предмет market-data-throughput-ceiling; здесь они не переизмеряются. Решение по устаревшему стакану — это решение по цене, которой на бирже уже нет.

Почему нельзя просто распараллелить всё. Три причины, каждая проверена в коде.

  • OrderRouter::allocate (crates/pm-engine/src/engine/order_router.rs) — неатомарная проверка-и-действие. Лимиты читаются (sum_open_cost_for_market, sum_all_open_orders_cost, запросы к positions), а резервация вставляется под ОТДЕЛЬНЫМ более поздним захватом self.allocations.lock() (order_router.rs). Сегодня это безопасно только потому, что вызывающий один. Второй параллельный вызывающий превращает все три лимита риска в гонку.
  • Запрет двойной продажи одного исхода держится на чтении в памяти внутри стратегии: ctx.orders_for_signal(...) плюс предикат is_exit_sell_family_uid (crates/pm-engine/src/strategies/directional/strategy.rs). Резервного слоя в хранилище нет: таблица orders (DDL_ORDERS, crates/pm-engine/src/adapters/storage/sqlite.rs) не имеет колонки роли — роль закодирована ТОЛЬКО в строковом префиксе order_uid, а частичного уникального индекса на «живой выход по исходу» не существует. Последовательность драйвера объявлена в исходниках третьим слоем защиты; параллелизм убирает её как таковую.
  • Лимит одновременных корзин события (selection.max_concurrent_per_event, crates/pm-engine/src/strategies/directional/strategy.rs) считает занятых сиблингов через ctx.active_signals_for_market. Сосед появляется в реестре только ПОСЛЕ записи в базу (SignalRegistry::emit, crates/pm-engine/src/engine/signal_registry.rs — шаг 3 «post-commit: вставка в state_by_market»), а единственный уникальный индекс uq_signals_active_market (sqlite.rs) ключован (condition_id, token_id, kind), то есть ПО РЫНКУ. Индекса, ловящего гонку на уровне СОБЫТИЯ, не существует.

Что уже готово к параллельным писателям. Защита от дублей входа трёхслойная, и два нижних слоя рассчитаны на них по построению: карта состояния по рынкам под мьютексом в реестре сигналов (слой 2, signal_registry.rs) и частичный уникальный индекс в базе (слой 1, sqlite.rs), который прямо описан как ловушка окна гонки между слоем 2 и INSERT. Верхний слой — последовательность драйвера — единственный, который параллелизм отменяет.

What Changes ​

Архитектура — решение оператора; настоящее изменение её нормирует, а не выбирает.

  • Параллелится только приём и оценка рыночных данных. Шардирование ПО СОБЫТИЮ: все данные одного события попадают в один шард. Оба исхода одного рынка обязаны быть в одном шарде, иначе лимиты риска на рынок становятся гонкой; все корзины одного neg-risk события обязаны быть в одном шарде, иначе гонкой становится лимит одновременных корзин.
  • Все решения о деньгах — входы И выходы — проходят через ОДНУ последовательную ступень допуска ордеров. Она же остаётся единственным местом, где применяются лимиты риска, бюджет, режим остановки входа (drain) и аварийный выключатель (kill-switch).
  • Обратной связи шардам не требуется. Шард производит кандидатов; дедупликация происходит на ступени допуска.
  • Дедупликация — ЗАМЕЩЕНИЕ свежим, а не отбрасывание. Кандидат с новой ценой не дубликат, а актуализация. Ключ уникальности — исход плюс класс намерения (вход / выход по цели / стоп), поведение на совпадение РАЗНОЕ по классам. Стоп НИКОГДА не отбрасывается: убитая биржей попытка обязана повториться, иначе позиция остаётся без защиты молча.
  • Дедупликация НЕ является барьером против двойной продажи. Это разные предметы с разными режимами отказа, и спека их разводит явно: ступень допуска кандидата-стоп пропускает всегда, а решение «не отправлять вторую продажу» принимает существующий барьер на пути исполнения.
  • Воронка отказов сохраняется и делится по границе. Шард считает отказы, зависящие от его рынка; ступень допуска считает деньги, лимиты и дубликаты.
  • Повторяемость сборочного гейта сохраняется за счёт того, что параллельная зона чиста от общего изменяемого состояния решений, а ступень допуска потребляет кандидатов в ЯВНО ЗАДАННОМ полном порядке.
  • Два дефекта повторяемости, существующие уже сейчас, входят в объём. Идентификаторы сигналов и ордеров берутся из ulid::Ulid::new() (new_signal_uid / new_client_order_id, crates/pm-engine/src/strategies/directional/strategy.rs) — настенные часы плюс случайность мимо управляемого ClockPort. Порядковый номер филла в отпечатке сквозной по всем рынкам (fold_fill, crates/pm-engine/src/replay/mod.rs: ordinal = report.fills_simulated), поэтому любая перестановка сдвигает всё дальше.

Что НЕ входит. Выбор числа шардов как конкретного значения. Изменение MAX_MARKET_DATA_STREAK или MARKET_DATA_CHANNEL_BUFFER — это предмет market-data-throughput-ceiling и его продолжения. Замена единственного соединения к SQLite. Приоритетная дорожка для стопа и граница партии потребления, от которой зависит окно дедупликации, — открытые вопросы с обеими ценами в design.md (§7.1 и §7.4), без выбора.

Capabilities ​

New Capabilities ​

  • sharded-market-data-admission: параллельная обработка рыночных данных с последовательной ступенью допуска ордеров — ключ шардирования и его неизменность, граница между параллельной и последовательной зонами, полный порядок потребления кандидатов, модель дедупликации по классам намерения, разделение воронки отказов, обязательные предварительные инварианты (атомарность допуска, страховка от двойной продажи в хранилище, детерминированные идентификаторы, порядковый номер филла в пределах исхода) и честное именование единственного соединения к хранилищу как границы записи.

Modified Capabilities ​

Нет. Изменение оформлено одной новой capability с требованиями только вида ADDED.

Существующие capability предмет не покрывают. market-ws-ingest нормирует корректность разбора и кэширования WS-событий, но не то, кто и в каком параллелизме их потребляет. live-submit-authorization-integrity и docs/target/risk-and-emergency.md нормируют сами лимиты, но не атомарность их применения при нескольких вызывающих. replay-decision-fidelity нормирует точность повтора решений, но не источник идентификаторов и не область действия порядкового номера филла. market-data-ingestion-capacity (изменение market-data-throughput-ceiling) нормирует ограничитель темпа и наблюдаемость насыщения, но сознательно не выбирает исправление. Дисциплина параллелизма — отдельный предмет, поэтому заводится своя capability.

Impact ​

Классы затрагиваемого кода. Перечислены как области, а не как список правок: конкретные файлы определяются при реализации по секциям tasks.md.

  • Композиционный корень и продюсер рыночных данных (crates/pm-engine/src/main.rs) — разведение одного продюсера на шарды по ключу события и очередь кандидатов вместо прямой связи «продюсер → драйвер».
  • Диспетчер (crates/pm-engine/src/engine/strategy_driver.rs) — разделение на шард-часть (оценка) и ступень допуска (деньги).
  • Маршрутизатор ордеров (crates/pm-engine/src/engine/order_router.rs) — атомарность проверки-и-резервации.
  • Хранилище (crates/pm-engine/src/adapters/storage/sqlite.rs) — durable-роль выходного ордера и частичный уникальный индекс «живой выход по исходу».
  • Стратегия (crates/pm-engine/src/strategies/directional/strategy.rs) — источник идентификаторов сигналов и ордеров.
  • Повтор (crates/pm-engine/src/replay/mod.rs) — область действия порядкового номера филла и, как следствие, значение закреплённого отпечатка.
  • Наблюдаемость воронки — разделение счётчиков по границе «шард / ступень допуска».

Побочный эффект, который нельзя пропустить. Перевод порядкового номера филла со сквозного на действующий в пределах исхода МЕНЯЕТ значение отпечатка. Закреплённый отпечаток 3db9d2bb7226372e23057c9d1bb18ac8de49398f1bf66f42d86388d7a357c4ac (replay/mod.rs, тест replay::rich_backtest_instrumentation::entry_exit_fills_with_frozen_digest_and_rich_data) и базовая линия сборочного гейта .github/workflows/parity.yml подлежат пересчёту в том же изменении. Это задача, а не сноска.

Очередь применения.

  1. Предварительные работы (tasks.md секция 1) — целиком, до любого параллелизма. Каждая из четырёх самодостаточна и имеет ценность без шардов: атомарность допуска, страховка от двойной продажи в хранилище, детерминированные идентификаторы, порядковый номер филла в пределах исхода.
  2. Ключ шардирования и его неизменность (секция 2) — до разведения продюсера.
  3. Разведение продюсера и очередь кандидатов (секция 3).
  4. Ступень допуска и дедупликация (секция 4).
  5. Разделение воронки и приёмка (секции 5–6).

Зависимость от смежных изменений. market-data-throughput-ceiling фиксирует механизм ограничителя и процедуру замера насыщения; настоящее изменение опирается на его описание как на нормативное и НЕ переопределяет его требований. Применять настоящее изменение до фиксации механизма нельзя: без описанной процедуры замера нечем будет показать, что шардирование дало эффект. Изменения directional-relative-exit-target и directional-dfav-deterioration-exit уже применены и заархивированы (openspec/changes/archive/2026-07-27-*, capability directional-exit-target и directional-dfav-deterioration-exit в openspec/specs/); их роли выходов уже присутствуют в ExitOrderRole (crates/pm-engine/src/ports/intent_role.rs) и учтены перечнем классов намерения. Дальнейшее расширение перечня ролей выхода перечень КЛАССОВ намерения не меняет и модель дедупликации не затрагивает: новая роль немедленного выхода попадает в существующий класс «стоп».

Что изменение НЕ делает. Не меняет торговую логику, пороги, лимиты риска и их арифметику. Не меняет набор операторских поверхностей. Не трогает config.db и не вводит ни одной новой настройки стратегии; число шардов — параметр инфраструктуры, и его источник определяется отдельно (design.md, открытый вопрос 3), а не молчаливым умолчанием.