Skip to content

Why ​

Денежные агрегаты ордера накапливаются НЕидемпотентно, и расхождение не поднимает ни одной тревоги. В persist_order_event (engine/order_router.rs, ветви PartialFill/Fill) доли пишутся кумулятивным значением из самого события (filled_shares: Some(*cumulative_filled_shares)), а стоимость и комиссия — сложением (acct.filled_value_micro_usd + fill_value_micro_usd). Повторно обработанный кадр филла завышает деньги и оставляет доли верными, поэтому ни один инвариант «доли сходятся» такого не ловит.

Дефект не гипотетический: замер 2026-08-16 на боевом harvester-engine нашёл позицию 218 (0x9358beed…), где orders.filled_value_micro_usd = 5 434 050 при фактически уплаченных 4 754 050 µUSD — завышение ровно на стоимость первого принта. Первоисточник (публичная лента data-api /activity) даёт три BUY-филла 1.0 + 4.03125 + 1.96 доли по 0.68 на сумму 4.75405 USD, бит-в-бит равную positions.entry_cost_micro_usd. Конкурирующий heal-путь исключён замером: price_pip=6800 × 6 991 250 / 10^4 = 4 754 050, то есть эту величину он породить не мог. Реестр: PM-ENGINE-39.

What Changes ​

  • Накопление filled_value_micro_usd и fees_micro_usd в persist_order_event становится идемпотентным по идентичности филла. Механизм уже существует и используется позиционным путём — FillIdentity (ExchangeTrade(trade_id) | CumulativeShares) с канонической проекцией evt-fill-{order_uid}-{t|c}{…} в events.event_uid под UNIQUE-индексом. Событие несёт оба варианта (exchange_trade_id, cumulative_filled_shares), поэтому новых полей в контракт не добавляется.
  • Появляется наблюдаемая сверка: накопленный агрегат ордера сопоставляется с суммой его забукированных филлов, расхождение звучит счётчиком и записью в журнале вместо бесшумного дрейфа.
  • Существующие испорченные строки ВЫЯВЛЯЮТСЯ отчётом, а не правятся молча. Прецедент PM-ENGINE-38: ручная правка живой engine.db отклонена оператором как money-unsafe, и это решение здесь сохраняется — заявка даёт список строк с величиной расхождения, решение о проводке принимает оператор.
  • Найдено при чтении кода и втянуто в объём решением оператора: путь позднего филла по терминальной строке (patch_late_fill_after_terminal) накапливает стоимость и НЕ трогает комиссию вовсе, тогда как обе ветви основного пути накапливают обе величины. На гонке cancel-vs-fill комиссия недоучитывается молча. Комиссия начинает накапливаться на этом пути наравне со стоимостью и подчиняется той же идемпотентности.
  • Внешних контрактов не меняется: OrderEvent, wire-формат SSE и схема БД остаются как есть. BREAKING отсутствует.

Capabilities ​

New Capabilities ​

  • order-fill-value-idempotency: денежные агрегаты ордера (filled_value_micro_usd, fees_micro_usd) накапливаются по идентичности филла ровно один раз; повторная доставка того же кадра оставляет агрегат байт-в-байт прежним; расхождение агрегата с суммой забукированных филлов наблюдаемо и не тихое.

Modified Capabilities ​

Пусто: требования существующих спеков не меняются.

Ближайший по смыслу fill-monotonic-reconciliation НЕ трогается намеренно. Он покрывает bootstrap/heal-путь: «reconciliation никогда не уменьшает записанный учёт филлов» и «повторное применение того же terminal evidence даёт байт-в-байт стабильный результат». Настоящая заявка закрывает ДРУГОЙ путь — live-обработку fill-события в persist_order_event, для которой аналогичной гарантии не сформулировано вовсе. Ни одно из его требований не становится неверным, поэтому дельта-спек ему не нужен; редактировать его текст ради упоминания второго пути значило бы размыть границу двух способностей.

Impact ​

  • crates/pm-engine/src/engine/order_router.rs — persist_order_event, ветви PartialFill/Fill; там же гейт «строка открыта» и путь позднего филла patch_late_fill_after_terminal. Проверено по коду 2026-08-19: на повторной доставке путь позднего филла асимметрии НЕ несёт — его гейт монотонности cumulative <= filled_shares делает повторный кадр no-op'ом. Зато он вовсе не накапливает комиссию (см. пункт выше).
  • crates/pm-engine/src/adapters/storage/sqlite.rs — чтение накопителя (find_open_order_by_uid, find_order_any_status_with_accounting) и, если дедупликация опирается на журнал, запрос по events.event_uid.
  • crates/pm-engine/src/observability/metrics.rs — счётчик расхождения.
  • scripts/analysis/forensics.sh — подкоманда accounting-preconditions уже печатает такие строки (блок расхождения + дискриминатор price×shares); она служит внешней проверкой результата, менять её не требуется.
  • Потребители завышенного агрегата: heal_executor берёт max(price×shares, накопленное) и потому ЗАКРЕПЛЯЕТ завышение; форензический способ «разность entry_cost − filled_value» становится надёжным только после этой правки. Бюджет (OrderRouter::budget_snapshot) и дашборд эту колонку не читают — денежных решений дефект сегодня не меняет.