Тема
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) и дашборд эту колонку не читают — денежных решений дефект сегодня не меняет.