Skip to content

Замер: учёт исполнения заявок (филлов) в pm-engine, 2026-09-15 ​

Оператор 2026-09-15 остановил точечную реализацию Confirmed-гейта — задачу 12 карточки durable-cancel-fill-resolution — со словами о целостном решении вместо очередной заплатки. Этот документ — раздел «Замер» будущей карточки целостного учёта исполнения заявок: он не предлагает решений и не сравнивает варианты, только фиксирует, что делает сегодняшний код, что требует документация и что показала неудавшаяся попытка задачи 12. Замер сделан чтением кода на main (65c95920), официальной документации Polymarket (через MCP polymarket-docs) и архива OpenSpec. Каждое утверждение — с файл:строка; денежные формулы процитированы дословно.

Оглавление ​

  1. Источники фактов исполнения (входы)
  2. Модель состояния заявки
  3. Потребители: от OrderEvent до денег
  4. Статусы сделки и их семантика
  5. Рестарт и восстановление
  6. Прецеденты и защиты
  7. Требования (действующие SHALL)
  8. Тесты, закрепляющие текущее поведение
  9. Факты, найденные при попытке реализовать Confirmed-гейт (задача 12, 2026-09-15)
  10. Открытые решения для design
  11. Противоречия и пробелы, найденные при замере

1. Источники фактов исполнения (входы) ​

#ИсточникФайл:строкаЧто читаетЧто делаетМетка идемпотентности
1aWS Trade (статус MATCHED)crates/pm-engine/src/adapters/clob/live/correlator.rs:486-652 (apply_trade)t.status, t.taker_order_id/maker_orders[].order_id, t.size/price (по-кандидатно — taker в своей нотации, maker в СВОЕЙ, не в нотации taker'а), t.match_time_ms, t.trade_idСобирает кандидатов (наш taker и/или наши maker-ноги), эмитит OrderEvent::Fill/PartialFill через apply_fill_deltaregistry.mark_processed(trade_id, exchange_id) — сет (trade_id, exchange_id), строка 619
1bWS Trade (статус CONFIRMED)correlator.rs:666-713 (apply_trade_confirmed)t.transaction_hashНЕ книжит fill-delta (уже эмитнут на более раннем MATCHED); публикует OwnFillObservation::Confirmed (источник tx-хеша) и вызывает record_confirmed_coverage (замер задержки Matched→Confirmed, метрики задачи 11)нет отдельной — денежная сторона не тронута
1cWS Trade (RETRYING/MINED/иное)correlator.rs:499-518t.statusdebug!, (Vec::new(), Vec::new()) — не влияет на деньги—
1dWS Trade (FAILED)correlator.rs:503-510—warn!("trade FAILED status observed — rollback NOT implemented in P9b (backlog)") — никакого отката ранее забукированного MATCHED—
1eWS Order::Update (size_matched — кумулятивно)correlator.rs:829-880 (apply_order_update)o.size_matched, o.priceКумулятивная дельта через apply_fill_delta(FillDelta::Cumulative(...)), is_taker=false (resting = maker, комиссия 0)high-water max_update_size_matched внутри apply_fill_delta
1fWS Order::Placement/Cancellationcorrelator.rs:793-826, 882-915—Placement — echo, не денежный; Cancellation переводит LiveOrderStatus::Cancelled, эмитит OrderEvent::Cancelled—
1gREST-сверка, заявка присутствует (diff_present)reconcile.rs:408-475rest.size_matched (агрегат, без trade_id)Если rest_cumulative > local, эмитит OrderEvent::ReconciliationGap + книжит через apply_fill_delta(FillDelta::Cumulative(...)), is_taker=falsehigh-water (тот же механизм)
1hREST-сверка, заявка исчезла (diff_absent)reconcile.rs:495-630get_order_detail → RemoteOrderDetail.size_matched_shares/status/evidenceЕсли биржа подтвердила налив выше локального — книжит ДО терминализации тем же apply_fill_delta; при полном наливе → Filled, при частичном → Cancelled(Reconciled)high-water
1iREST-сверка, аудит по сделкам (audit_trades/apply_rest_trade)reconcile.rs:632-731get_trades → TradeResponse (есть trade_id)FillDelta::Trade(delta) через тот же apply_fill_delta, is_taker по кандидатуregistry.mark_processed(t.id, exchange_id)
1jПотерянный ack (lost_ack.rs)lost_ack.rs:75-254 (adopt_proven_order)ProvenOrder.booked_filled_shares (водяной знак), taker_fee_rate_bpsЗаводит LiveOrderState задним числом с водяным знаком = уже забукированному объёму, затем drain_and_apply_pending прогоняет буферизованные кадры ЧЕРЕЗ ТОТ ЖЕ apply_user_event/apply_fill_deltaтот же (trade_id, exchange_id)-сет реестра
1kРехидратация при старте (hydrate_registry_from_open_orders)crates/pm-engine/src/adapters/clob/live/client.rs:473-534orders.filled_shares, orders.taker_fee_rate_bps_actualЗатравливает max_update_size_matched/cumulative_filled_shares водяным знаком = durable filled_shares (клампован [0, size])сама рехидратация событий не эмитит
1lBootstrap 3-way diff (OrderFieldDivergence)lifecycle/bootstrap.rs:400-537remote_snap.size_matched_shares vs row.filled_sharesТолько НАБЛЮДЕНИЕ расхождения (Discrepancy::OrderFieldDivergence) — денег не книжит сам—
1mHeal-исполнение расхождения (apply_update_from_remote)crates/pm-engine/src/lifecycle/heal_executor.rs:424-509size_matched_sharesПатчит ТОЛЬКО orders.filled_shares/filled_value_micro_usd плоским OrderPatch — не создаёт и не растит positions, не проходит через record_economic_factidempotent-skip если строка уже terminal
1nВосстановление денежного факта при расхождении отмены (book_diverged_fill_row)crates/pm-engine/src/engine/order_router.rs:4916-5069Полный обход ленты get_recovered_trades (SDK get_trades_until_terminal)Патчит durable filled_shares через patch_order_with_recovered_fill (денежный писатель, R9-манифест) — позицию тоже НЕ создаёт: «её создаёт штатный путь букинга исполнения» (order_router.rs:4911-4915)ledger external_event_id = trade-{trade_id}
1oCoverage/holdings-сверкаlifecycle/holdings_reconcile.rs, engine/coverage_verification.rson-chain холдинги vs positionsПишет ТОЛЬКО измерение покрытия (record_coverage_measurement) — не мутирует size_shares/PnL—
1pOn-chain redeem (mark_redeemed)position_registry.rs:3481-3603подтверждение выкупаМеняет redeem_status/статус на Redeemed, не трогает size_shares/realized_pnl_micro_usd — денежный расчёт settlement уже проведён отдельно (book_resolution_pnl, :2622)CAS Submitted→Confirmed в durable-колонке
1qШестой производитель, найден при проектировании 2026-09-15 — extract_recovered_trades_for_order, питающий строку 1ncrates/pm-engine/src/adapters/clob/live/client.rs:1379-1436, фильтр is_settled_fill_status (client.rs:1307-1312)TradeResponse.status по каждой сделке полного обхода ленты (get_trades_until_terminal)Строит Vec<RecoveredTrade>, признавая «settled» уже статусы Matched/Mined, не только Confirmed (тест settled_fill_status_counts_only_settled, client.rs:5313-5325, прямо утверждает это как ожидаемое поведение) — результат кормит record_economic_fact через patch_order_with_recovered_fill/book_diverged_fill_row (строка 1n), то есть денежный факт способен возникнуть на Matched/Mined, минуя гейт Confirmed, тем же способом, что и пять названных выше производителейtrade_id (RecoveredTrade.trade_id)

2. Модель состояния заявки ​

LiveOrderState (registry.rs:65-115) ​

ПолеПишетЧитаетСемантика
cumulative_filled_sharesтолько apply_fill_delta (correlator.rs:1186)reconcile-снапшот, terminal-detection, дашбордmax(cum_from_trades, max_update_size_matched) на момент последней эмиссии
cum_from_tradesapply_fill_delta (:1066, ветка FillDelta::Trade)сам apply_fill_deltaСумма ДЕДУПЛИЦИРОВАННЫХ Trade-делт
max_update_size_matchedapply_fill_delta (:1067, ветка Cumulative)сам жеHigh-water кумулятивного size_matched из Order-UPDATE/REST
fills: BTreeMap<trade_id, FillRecord>:1072-1091record_confirmed_coverage (:734-744)Аудит per-trade; FillRecord.status мутируется Matched→Confirmed для замера задержки
taker_fee_rate_bps: Option<u32>with_taker_fee_rate_bps на submit / рехидратацииapply_fill_delta (гейт :1052-1064)None ≠ Some(0): None — неразрешённая ставка (legacy-строка), Some(0) — авторитетный ноль
processed_trade_order (в RegistryInner, не в LiveOrderState)mark_processed (registry.rs:329-342)is_processedДедуп-сет (trade_id, exchange_id); пустой trade_id в сет НЕ попадает (:330-337)

apply_fill_delta (correlator.rs:956-1236) — построчно ​

  1. :969-987 — ранний отказ на delta ≤ 0 (Trade) / < 0 (Cumulative), без мутации.
  2. :997-1015 — вычисление new_cum_trades/new_max_update; для Cumulative — stale-guard: c.0 < state.max_update_size_matched.0 → warn! + None, счётчики не трогаются.
  3. :1022-1038 — инвариант new_effective ≤ original_size_shares; нарушение → OrderEvent::InvariantViolation, счётчики НЕ мутируются (в error!-ветке return до присвоений).
  4. :1052-1064 — HOLD-гейт: is_taker && рост > 0 && taker_fee_rate_bps.is_none() → кадр целиком НЕ учитывается (ни fills, ни счётчики), warn! + pm_engine_fill_fee_rate_unresolved_total.
  5. :1066-1091 — мутация cum_from_trades/max_update_size_matched, запись FillRecord в state.fills (аудит-провенанс — ВСЕГДА, даже если emit_delta == 0).
  6. :1093-1141 — emit_delta = new_effective − old_effective; == 0 → без эмиссии, но с проверкой недобукированной taker-комиссии (pm_engine_fill_fee_unbooked_total).
  7. :1150-1161 — straddle-детект (frame_shares > emit_delta) → pm_engine_fill_straddle_total, warn!.
  8. :1163-1185 — value = fill_value(price_pip, emit_delta) (GROSS от emitted delta); fee = taker_fee_micro_usd(bps, price_pip, frame_shares) (от ПОЛНОГО объёма трейда, не от delta).
  9. :1186-1203 — state.cumulative_filled_shares = new_effective; cumulative_filled_value += value; anonymous-audit запись если trade_id.is_none().
  10. :1205-1233 — is_final = new_effective == original_size → LiveOrderStatus::Filled + OrderEvent::Fill, иначе PartialFill.

fill_value (correlator.rs:452-455) ​

rust
pub fn fill_value(price: PipPrice, shares: Shares) -> MicroUsd {
    let v = price.cost_micro_usd(shares);
    MicroUsd(v.clamp(i64::MIN as i128, i64::MAX as i128) as i64)
}

Durable-зеркало (колонки orders, docs/target/storage.md:57) ​

filled_shares, filled_value_micro_usd, fees_micro_usd, taker_fee_rate_bps_actual, entry_role, exit_role, статусы NEW/PLACED/PARTIAL/AMBIGUOUS_SUBMIT/AMBIGUOUS_CANCEL/FILLED/CANCELLED/EXPIRED/REJECTED. Пишет OrderRouter (order_router.rs:7016-7028 — apply_order_event, in-memory snapshot; persist_order_event/claim_fill_for_order_accounting — durable-патч, :7263-7345).


3. Потребители: от OrderEvent до денег ​

3.1 OrderRouter::apply_order_event (order_router.rs:6907-7040) ​

:7016-7029 — обновление in-memory снимка (snapshot.filled_shares = *cumulative_filled_shares, статус Partial/Filled). Это ТОЛЬКО кэш — деньги не задеты.

3.2 persist_order_event (:7263-7345) → claim_fill_for_order_accounting (:7198-7261) ​

rust
// order_router.rs:7231-7244
let identity = crate::engine::strategy_driver::fill_identity_of(exchange_trade_id, cumulative_filled_shares);
let fill_discriminator = identity.discriminator();
let accounted = tx.record_order_fill_accounting(OrderFillAccountingRow {
    order_uid: order_uid.clone(), fill_discriminator, fill_value_micro_usd, fee_micro_usd, accounted_at: ts,
}).await?;

Идемпотентность денежных агрегатов ОРДЕРА (filled_value_micro_usd, fees_micro_usd) — по метке в отдельной таблице order_fill_accounting, UNIQUE-ключ по fill_discriminator. Дубль → pm_engine_fill_duplicate_booking_suppressed_total{path="order_accounting"} (:7248-7251), деньги НЕ прибавляются повторно, но статус/first_fill_at/terminal_at — обновляются.

3.3 patch_late_fill_after_terminal (:7108-7196) — гонка cancel-vs-fill ​

Late-Fill/PartialFill по уже Cancelled/Expired строке: правит filled_shares/filled_value_micro_usd/fees_micro_usd, при полном наливе меняет статус на Filled и снимает reject_reason (комментарий :7165-7173: оставленный код отмены на налитой строке заставил бы форензику посчитать исполнение отменой).

3.4 fill_identity_of (strategy_driver.rs:131-139) → FillIdentity (position_registry.rs:713-793) ​

rust
FillIdentity::ExchangeTrade(tid)      // discriminator "t{tid}"
FillIdentity::CumulativeShares(cum)   // discriminator "c{cum}"

ledger_external_event_id() (:788-793) определён ТОЛЬКО для ExchangeTrade → "trade-{trade_id}" — используется для унификации идентичности денежного факта между живым букингом и восстановлением по ленте (book_diverged_fill_row), чтобы одна и та же сделка не задвоилась в economic_ledger_entries.

3.5 route_fill_to_registry (strategy_driver.rs:894-1050+) → PositionRegistry ​

  • BUY → apply_buy_fill (position_registry.rs:1601-1715): идемпотентный гейт (fill_already_booked, :1394-1418, по events.event_uid UNIQUE), затем open_new_position (:2740) или grow_existing_position (:3002) — оба вызывают buy_fill_ledger_fact (:983-1023) → tx.record_economic_fact(...) (:2890, :3125) + tx.resolve_deferred_basis(...) (:2895, :3129).
  • SELL → apply_sell_fill_inner (:1858-2014): тот же идемпотентный гейт ДО oversold-check (:1942-1950, комментарий — иначе повторная доставка на уменьшившейся позиции ловилась бы как oversold, а не как честный no-op); диспетч (is_terminal, effect) на apply_partial_sell (:2017) или apply_terminal_sell (:2251).

3.6 apply_terminal_sell (:2251-2622) — денежная формула ​

rust
// position_registry.rs:2355
let realized_pnl = MicroUsd(new_cum_exit_value.0 - state.entry_cost_micro_usd.0);

Остаток после терминального SELL классифицируется (classify_terminal_leftover, :845-853): None/StrandedDust(<лот CLOB)/Unexplained(≥лот). Unexplained → SellFillApplied::terminal_leftover, эскалируется в StrategyDriver::engage_accounting (drain + алярм, strategy_driver.rs:963-976) — AUDIT#9-инвариант: формула верна, только если fill_shares == state.size_shares.

3.7 Wire-событие (append_fill_in_tx, position_registry.rs:1318-1382) ​

rust
event_uid: fill_identity.event_uid(order_uid),   // "evt-fill-{order_uid}-{t|c}{...}"
order_id: Some(order_db_id),                     // колонка, не только в payload (правка 2026-08-21)

BotEvent::Fill строится через wire::bot_event_map::to_bot_event_fill; строка events и bus.publish — в ОДНОЙ транзакции/после commit() (см. engine/AGENTS.md §«События»).


4. Статусы сделки и их семантика ​

Наши перечисления ​

  • WS (crates/pm-sdk/src/ws/user/types.rs:42-49): Matched, Mined, Confirmed, Retrying, Failed, Unknown(String).
  • REST (polymarket-client-sdk-0.4.4/src/clob/types/mod.rs:310-324): те же пять + Unknown(String).
  • Разбор (crates/pm-sdk/src/ws/user/parse.rs::parse_trade_status) — точное регистрозависимое сравнение строк "MATCHED"|"MINED"|"CONFIRMED"|"RETRYING"|"FAILED", иначе Unknown(other).

Официальная документация (docs.polymarket.com/concepts/order-lifecycle.md, docs.polymarket.com/trading/realtime-order-updates.md, прочитано через MCP polymarket-docs) ​

СостояниеTerminalЗначение
Matched, not broadcast (MATCHED_NOT_BROADCASTED)НетМатч случился до отправки транзакции в сеть
MatchedНет«The orders matched and the transaction was submitted for execution»
MinedНетТранзакция замечена в сети, финальность не достигнута
ConfirmedДа«The trade reached finality successfully»
RetryingНет«Settlement failed temporarily and is being retried»
FailedДа«Settlement failed permanently»

Раздел «Order Lifecycle → Settlement/Confirmation» (concepts/order-lifecycle.mdx): «When orders match, the operator submits the trade to the blockchain. The Exchange contract: Verifies both signatures, Transfers tokens from seller to buyer, Transfers pUSD from buyer to seller… The trade achieves finality on Polygon [at Confirmed]. Your token balances update and the trade appears in your history» — то есть фактический перевод токенов/pUSD идёт как часть транзакции, отправленной ПОСЛЕ Matched, а баланс на бирже обновляется ТОЛЬКО к Confirmed.

Документированный 6-й статус MATCHED_NOT_BROADCASTED не поименован явно ни в одном из наших закрытых словарей — попадает в Unknown, что для WS-пути не даёт fill (correlator.rs:499-518, ветка «не Matched → debug!»), т.е. поведение корректно (нет исполнения — нет денег), но словарь не именует этот статус явно.

Обработка в коде ​

  • MATCHED → apply_trade (correlator.rs:486-652) книжит через apply_fill_delta — уже сегодня, до CONFIRMED.
  • CONFIRMED → apply_trade_confirmed (:666-713), денег НЕ книжит; record_confirmed_coverage (:728-756) переводит запись FillRecord.status из Matched в Confirmed и пишет гистограмму pm_engine_trade_matched_to_confirmed_seconds + счётчик pm_engine_trade_confirmed_total{prior="matched"|"none"} (задача 11 карточки 86).
  • FAILED → только warn!, задокументированный backlog («реальный rollback не реализован в P9b»), correlator.rs:503-510.
  • apply_trade_failed-функции в коде НЕТ (проверено rg) — FAILED идёт по общей ветке debug!/warn!, не по отдельному обработчику.

5. Рестарт и восстановление ​

5.1 hydrate_registry_from_open_orders (client.rs:473-534) ​

Затравливает max_update_size_matched = cumulative_filled_shares = clamp(orders.filled_shares, 0, size); cum_from_trades = 0 (пре-рестартные Trade-кадры теряются — доккомментарий :445-448: «пост-restart трейды суммируются с нуля; max-семантика гарантирует, что канал Cumulative добукирует ровно недостающие shares»). taker_fee_rate_bps — из row.taker_fee_rate_bps_actual (durable), None для legacy-строк.

5.2 lost_ack.rs::adopt_proven_order (:75-254) ​

Водяной знак = order.booked_filled_shares (клампован [0, size]); при !already_correlated заводит LiveOrderState с этим знаком, затем drain_and_apply_pending (correlator.rs:1252+) прогоняет буфер через ТОТ ЖЕ apply_fill_delta. AdoptionPolicy::OnlyIfSomethingWasBooked — если 0 событий, корреляция снимается обратно (withdraw_untouched, registry.rs:270-292) — но ТОЛЬКО нетронутая запись (три признака: владелец/кумулятив/нетерминальность совпадают).

5.3 resting_order_adoption.rs — корень прецедента Сеул ​

:1-50: соответствие «биржевой id → OrderUid» живёт ТОЛЬКО в памяти, ничто не восстанавливает его при старте кроме этого модуля. Использует ТОТ ЖЕ LostAckRecovery::adopt_proven_order (policy=KeepCorrelation), но усыновляет ТОЛЬКО заявки, которые биржа прямо назвала живыми — отсутствующие ведёт RuleResolveAbsentLocalOrder.

5.4 Bootstrap 3-way diff (bootstrap.rs:400-585) ​

OrderFieldDivergence (SizeMatched расхождение) — ТОЛЬКО наблюдение. Опознание неподтверждённой СВОЕЙ заявки по signing_hash (:553-571) — при совпадении оставляет строку как есть (не отменяет как «чужую»), НЕ создаёт события.

5.5 Heal-исполнение (heal_executor.rs) — между Matched и Confirmed при рестарте ​

apply_update_from_remote (:424-509) на найденном OrderFieldDivergence:

rust
// heal_executor.rs:482-487
let patch = OrderPatch {
    filled_shares: Some(filled),
    filled_value_micro_usd: Some(MicroUsd(filled_value)),
    first_fill_at,
    ..Default::default()
};

Патчит ТОЛЬКО строку orders через commit_domain_atomic — НЕ patch_order_with_recovered_fill, НЕ record_economic_fact, НЕ трогает positions. HealAction::BackfillPositionFromTrades/RecomputeRealizedPnl/DeferDueToDataLag — явные заглушки:

rust
// heal_executor.rs:610-640 (apply_deferred)
Err(ActionExecutionError::NotImplemented(kind))

Т.е. заявка, у которой между Matched и рестартом (или между рестартом и следующим Confirmed) вырос size_matched, но не была усыновлена resting_order_adoption/lost_ack — при обнаружении расхождением на bootstrap получает ПРАВДИВУЮ строку orders, но без соответствующей позиции/PnL/ledger-записи; закрывает дыру только внешняя сверка холдингов (docs/target/engine.md:531 косвенно, lifecycle/AGENTS.md §HoldingsReconciler).

5.6 WafStore/outbox events в учёте филла ​

events-таблица участвует как носитель идемпотентности (fill_already_booked по event_uid) и как аудит-провод (ReconciliationGap, order.field_divergence.reconciled) — сама филл-строка events пишется ТОЛЬКО PositionRegistry::append_fill_in_tx, не WafStore.


6. Прецеденты и защиты ​

ПрецедентДатаДеньгиКореньЗащита сейчасФайл:строка
Позиция 231 / «Сеул» — SELL 6@0.99 исполнился через 61 мин после старта нового процесса, кадр лёг в буфер и был вытеснен TTL, $5.94 выручки не учтены, строка списана как истёкшая с нулём2026-08-13$5.94Соответствие «биржевой id → OrderUid» не переживало рестарт (память, не durable)(а) корень — resting_order_adoption.rs (усыновление живых заявок на bootstrap); (б) симптом-компенсация — reconcile.rs::diff_absent (:495-630, книжит по агрегату size_matched, если биржа подтвердила больший налив)resting_order_adoption.rs:1-27, reconcile.rs:1124
Позиция 228 — SELL 5 двумя принтами в одну ms, порядок UPDATE(2)→Trade(2) посчитал 2 доли дважды → фантомный cum=4, реальный Trade(3) отброшен инвариантом, недосчёт2026-07-17доли (не сумма в $)Один общий high-water счётчик на два независимых канала (Trade / Order-UPDATE)Два раздельных счётчика cum_from_trades/max_update_size_matched, эффективный = max(...), эмиссия только на росте maxcorrelator.rs:15-25, регресс-тест incident_228_split_fill_one_ms_exactly_once (live_order_client_correlation.rs:908), incident_228_same_ms_split_sell_books_exactly_once (fill_booking_idempotency.rs:187)
Позиция 218 — «филл дважды»: orders.filled_value_micro_usd = 5 434 050 при фактически уплаченных 4 754 050 µUSD (завышение ровно на первый принт)замер 2026-08-16, закрыто 2026-08-21679 940 µUSD (~$0.68)filled_value_micro_usd/fees_micro_usd накапливались сложением БЕЗ идентичности филла (в отличие от filled_shares, идемпотентного к повтору)Таблица order_fill_accounting + FillIdentity-ключ, гейт в claim_fill_for_order_accountingdocs/history/openspec/changes/2026-08-21-fill-accounting-idempotency/proposal.md:1-16, код — order_router.rs:7221-7261, реестр PM-ENGINE-39
PM-ENGINE-38 — 5 холдингов без строки positions: BUY исполнился на бирже, движок записал filled=0, CANCELLED, on-chain выкуп вернул $27.00, но расход $22.14 и приход не видны книгам (+$4.86 отсутствует)2026-07-26+$4.86 (учётный разрыв, не потеря капитала)Незабукированный BUY-филл на CANCELLED-строкеЯвно назван backlog как OPEN на момент архивного среза; частично закрывается детектором незабукированных филлов (карточка unbooked-fill-detection-and-recovery, 2026-08-31)docs/history/tech-debt-backlog-2026-08-31.md (PM-ENGINE-38)
PM-ENGINE-40 — реплей считает филлы БЕЗ комиссии, бой — С комиссией; на позиции 237 SELL-комиссия $0.0487 = 3.3% выручки, реплей её не вычиталзамер 2026-08-163.3% выручки на taker-SELLreplay/mod.rs передаёт fee=MicroUsd(0) в apply_buy_fill/apply_sell_fillOPEN, решения оператора нет (архивный срез)docs/history/tech-debt-backlog-2026-08-31.md (PM-ENGINE-40)
exit-shortfall-deadlock — временное отставание учёта биржи записано в требование как свойство ПОЗИЦИИ; тест a_shortfall_below_the_min_lot_stops_attempting был зелёным; две позиции остались без выхода/стопа 14 минут2026-08-21риск без защиты 14 минОшибка САМОГО замысла требования (не диффа) — закреплена зелёным тестомКросс-модельное ревью замысла ДО кода (docs/operations/design-review-gate.md)CLAUDE.md (проект), правило 8; docs/operations/design-review-gate.md:1-9
exit-sizing-from-sellable-balance — 66 SELL отклонены биржей not enough balance, средняя задержка первой прошедшей продажи 129с, максимум 4915с; 12 из 66 — устойчивое расхождение (движок считает 7.00, осело 6.99)замер сессии 12-16.08риск без защиты до 4915с на позициюУчёт движка (забукированный размер позиции) НЕ равен settled-балансу на бирже — прямое, ИЗМЕРЕННОЕ следствие того, что позиция книжится (в т.ч. по MATCHED) раньше, чем биржа реально урегулировала расчётРазмер защитной SELL выводится из parse_balance_reject_breakdown (уточнение по телу отказа), backoff между попыткамиdocs/history/openspec/changes/2026-08-31-exit-sizing-from-sellable-balance/proposal.md:1-30
Задача 12 карточки durable-cancel-fill-resolution — Confirmed-гейт на всех трёх производителях денег (WS-Matched, Order-Update, REST-apply_rest_trade)предпосылка-замер (задача 11) выполнена, само внедрение ОСТАНОВЛЕНО оператором 2026-09-15нереализованное закрытие требования «деньги книжатся только по подтверждённой сделке»—tasks.md пункт 12 не отмечен [x]; design.md D9 «Уточнения перед задачей 12» зафиксированы, но код не тронутopenspec/changes/durable-cancel-fill-resolution/tasks.md:245-268, design.md:272-341
Карточка reconcile-fill-confirmation — заведена и снята оператором тем же днём, до коммита (2026-09-15; в git-истории записи о ней нет ни разу — git log --all -- 'openspec/changes/reconcile-fill-confirmation*' пуст), не заплатка поверх заплатки, а отменённое намерение отдельной работы. Факт, ради которого она заводилась, снятием карточки не отменён: diff_present/diff_absent продолжают книжить по агрегированному REST size_matched без trade_id и статуса сделки2026-09-15не измерено (частота срабатывания путей сверки в бою неизвестна)Признанный, названный остаток (вариант B решения оператора 2026-09-15) против требования «Деньги книжатся только по подтверждённой сделке»Проектирование не начато; ниже — две находки кросс-ревью Codex по её proposal.md, остающиеся фактами замера независимо от судьбы самой карточкиreconcile.rs:408-475 (diff_present), :495-630 (diff_absent), design.md:329-341
Wellington, 12-14.09 — 8.33@0.12 stop-fill, остаток 1.67 меньше лота, PnL −6.57438; forensics-инструмент исключает реальные BUY-филлы, если ордер не FILLED (позиции 251/254/263 — реальные входы отброшены, для Karachi подтверждён CANCELLED с strategy_entry_withdraw)14.09.2026−$6.57 на позицию + систематическое искажение EV-выборкиforensics.sh:248 требует status='FILLED' для учёта входа — не совпадает с фактической моделью данных (единица анализа обязана быть каждый реальный BUY-fill)Не устранено на момент отчёта; исправление в неслитом worktree fix/analytics-report-defectsdocs/analysis/harvester-engine-session-2026-09-14-postmortem.md:83-163
Mexico, остаток redeem — закрытая позиция 263, остаток 8773 micro-shares, redeem_status=FAILED, ClickHouse-витрина показывает inventory=014.09.2026расхождение realized: ledger −4.491367 vs positions −4.499640 (0.008273, объяснено residual basis)marts.rs:165-188 безусловно обнуляет inventory при closed_at != NULL, не проверяя завершённость redeemНе устранено; констатируется как дефект витрины, не доказанная потеря денегdocs/analysis/harvester-engine-session-2026-09-14-postmortem.md:202-210, 240-260

Находки кросс-ревью Codex (gpt-5.6-luna) по proposal.md карточки reconcile-fill-confirmation, зафиксированные до её снятия и остающиеся фактами замера независимо от судьбы самой карточки:

(а) diff_absent терминализирует заявку по агрегату, не проверяя покрытие сделками. Ветвление в конце функции (reconcile.rs:596-629) сравнивает effective_filled = max(detail.size_matched_shares, snap.cumulative_filled_shares) с original_size_shares: при полном покрытии — mark_terminal(..., Filled) (:602-611) без единого прочитанного trade_id, при неполном — синтетический Cancelled(Reconciled) (:614-629). Обе ветви терминализируют запись реестра (mark_terminal) СРАЗУ, не читая ленту сделок этой заявки. При неполном или искажённом ответе REST по конкретной сделке заявка закрывается по агрегированному size_matched, а не по факту покрытия сделками, и терминальный статус реестра делает повторную сверку этой заявки невозможной — расхождение либо уже вписано в счётчики, либо потеряно навсегда.

(б) Частота срабатывания путей сверки в бою не измерена. rg -n 'ReconciliationGap' crates/pm-engine/src показывает: событие создаётся в двух местах — diff_present (reconcile.rs:446) и diff_absent (reconcile.rs:570); потребляется OrderRouter::apply_order_event/persist_order_event как чистый no-op (order_router.rs:6929, :7270 — return/Ok(()) без мутации снимка и без записи строки) и DirectionalStrategy::on_order_event (strategy.rs:19642-19652) — единственный печатаемый warn!, общий для ОБОИХ путей, без поля, различающего источник. diff_absent дополнительно несёт СОБСТВЕННУЮ описательную строку warn! непосредственно в месте создания события (reconcile.rs:562-569, поля remote_matched/local_cumulative/remote_status); diff_present такой строки не несёт вовсе (reconcile.rs:445-451 — событие пушится молча). Ни metrics::counter!, ни metrics::histogram! для ReconciliationGap в крейте нет (тот же rg — 0 совпадений рядом с метриками). Итог: diff_absent виден по собственной строке лога, diff_present — только через общий обезличенный warn! ниже по маршруту; ни один путь не даёт числа для оценки частоты срабатывания в бою.


7. Требования (действующие SHALL) ​

openspec/changes/durable-cancel-fill-resolution/specs/durable-cancel-fill-resolution/spec.md ​

  • «Деньги книжатся только по подтверждённой сделке» (:83-109): «Позиция, финансовый результат и производная защитная заявка SHALL создаваться только после того, как конкретная сделка достигла окончательного подтверждённого расчётного статуса. Промежуточный… статус сделки SHALL оставлять денежный вердикт неразрешённым. Провал одной сделки SHALL закрывать ожидание только этой сделки и SHALL не освобождать удержание сам по себе». Сценарий "Промежуточный статус сделки не книжит деньги" прямо описывает то, что сегодня НЕ так: код книжит на Matched (§1a, §2, §3).
  • «Неразрешённая проверка исполнения не приравнивается к подтверждённому нулю» (:9-36).
  • «Удержанные ресурсы не освобождаются, пока исход не разрешён» (:38-64).
  • «Величина удержания соответствует подтверждённому исполнению, а не локальной записи» (:66-81).
  • «Отсутствие или неполнота ответа биржи не доказывает нулевое исполнение» (:111-127).
  • «Подтверждение отмены заявки требует признания биржей именно этой заявки» (:129-145).
  • «Единый способ вынесения вердикта на всех путях терминализации заявки» (:147-165).
  • «Неразрешённые исходы переживают перезапуск процесса» (:167-185).
  • «Разрушительный операторский сброс не стирает неразрешённые исходы» (:188-212).
  • «Комиссия выставляющей (мейкерской) стороны не блокирует учёт состоянием поля ставки» (:214-225) — соответствует коду: fee=MicroUsd(0) для maker безусловно (correlator.rs:1183-1185).
  • «Учёт принимающей стороны требует разрешённой единицы измерения комиссии» (:227-233) — соответствует HOLD-гейту apply_fill_delta:1052-1064.

docs/target/storage.md ​

  • I-3 (:81): filled_shares ≤ size_shares — реализовано инвариантом apply_fill_delta:1022-1038 (InvariantViolation, не silent-fix).
  • I-5 (:95): переход в CLOSED запрещён без полного покрытия выхода реальными SELL-филлами: «exit_order_uid ссылается на ПЕРВЫЙ ордер exit-цепочки и НЕ обязан быть FILLED… проверка "exit_order_uid → status='FILLED'" НЕ является валидным детектором филла».
  • :67 — прямо утверждает НЕИДЕМПОТЕНТНОСТЬ filled_value_micro_usd/fees_micro_usd как текущий факт (см. «Противоречия» ниже).

docs/target/risk-and-emergency.md / docs/target/engine.md ​

Гейт подтверждённого покрытия (engine/AGENTS.md, CoverageVerifier) — SHALL-формулировки не процитированы в этом замере отдельно (не относятся напрямую к учёту филла, а к сверке holdings уже ПОСЛЕ него).


8. Тесты, закрепляющие текущее поведение ​

ФайлТесты (имя без fn/async fn)
crates/pm-engine/src/adapters/clob/live/correlator.rs (inline mod tests, :1283+)apply_order_unknown_type_is_a_money_noop, matched_observation_delivered_via_pending_replay_path, confirmed_observation_delivered_via_pending_replay_path, foreign_confirmed_trade_does_not_emit_own_fill_observation, confirmed_before_http_ack_is_buffered_then_published_after_registration, pending_matched_frame_replayed_after_five_seconds_keeps_original_receipt_as_matched_at_ms, observation_queue_len_grows_while_resolver_stalled, observation_send_after_resolver_task_ends_counts_closed_and_faults_joinset, matched_then_confirmed_records_latency_and_flips_record_status, confirmed_without_prior_matched_counts_as_uncovered
crates/pm-engine/src/adapters/clob/live/reconcile.rs (:1011+)reconcile_no_active_orders_returns_empty, reconcile_rest_matches_local_no_events, reconcile_rest_ahead_emits_gap_and_partial_fill, reconcile_rest_ahead_to_full_emits_terminal_fill, reconcile_rest_absent_partial_emits_cancelled, absent_but_actually_filled_books_the_fill_and_never_cancels, absent_with_partial_remote_fill_books_delta_then_cancels, absent_with_degraded_verdict_defers_instead_of_cancelling, absent_with_unknown_fate_defers_instead_of_cancelling, reconcile_rest_absent_filled_marks_terminal_no_event, reconcile_get_open_orders_partial_failure_continues, a_trade_booked_by_the_user_channel_is_not_booked_again_by_reconcile, reconcile_get_trades_supplements_audit_and_is_idempotent, reconcile_get_trades_failure_emits_gap_without_trade_audit, reconcile_first_cycle_uses_now_minus_overlap_for_trades, reconcile_idempotent_when_rest_stays_ahead
crates/pm-engine/src/adapters/clob/live/registry.rs (:489+)register_placed_is_idempotent, mark_processed_dedup, empty_trade_id_excluded_from_dedup, lookup_by_uid_after_register, terminal_entries_evicted_after_retention
crates/pm-engine/src/adapters/clob/live/lost_ack.rs (:257+)a_frame_buffered_before_the_ack_is_replayed_after_it, a_later_live_frame_correlates_instead_of_buffering_again, the_watermark_blocks_double_booking_and_the_policy_decides_the_correlation, adopting_twice_does_not_create_or_withdraw_a_second_correlation, a_closed_event_channel_is_reported_as_loss, withdrawal_is_declined_for_a_record_that_was_touched, lost_ack_adoption_with_deferred_matched_delivers_observation
crates/pm-engine/src/engine/order_router_tests.rs (fill-релевантные)partial_fill_keeps_only_remaining_cap_based_fee_reserve, partial_fill_decrements_open_order_reservation, fill_count_bumped_on_fill_event, fill_written_during_cancel_transport_wins_the_outcome, a_non_fill_terminal_writer_supersedes_cancel_resolution, recovered_fill_books_each_unaccounted_trade_once, two_partial_recovered_fills_each_book_once, snapshot_filled_shares_and_status_updates_on_partial_fill, snapshot_status_updates_on_terminal_fill, recover_from_db_holds_terminal_buy_with_unbooked_fills, guarded_release_holds_buy_cancel_with_unbooked_fills, zero_fill_ambiguous_cancel_holds_silently_no_alarm, cancel_holds_buy_with_unbooked_fills, restart_ambiguous_cancel_reservation_amount_matches_persisted_filled_shares
crates/pm-engine/src/engine/position_registry_tests.rs (fill-релевантные)apply_fill_creates_new_position, live_fill_fee_reaches_the_wire_row_on_all_four_paths, fill_fee_is_booked_once_not_twice, second_fill_grows_existing_position, fill_with_zero_or_negative_shares_rejected, a_fill_event_carries_the_order_key_in_its_column, apply_sell_fill_terminal_closes_with_realized_pnl_positive/_negative, book_resolution_pnl_winner_matches_apply_sell_fill_at_dollar, apply_sell_fill_oversold_returns_invariant_error, apply_sell_fill_terminal_realized_pnl_via_multi_partial, shrunk_stop_terminal_fill_reduces_position_without_drain, sell_fill_against_zero_inventory_rejected_no_phantom_pnl, sell_fill_exceeding_inventory_rejected_no_negative_position, a_buy_fill_growing_the_position_makes_the_measurement_stale
tests/it/fill_booking_idempotency.rsincident_228_same_ms_split_sell_books_exactly_once, duplicate_fill_identity_is_idempotent_noop, fee_nets_accounting_but_does_not_distort_print_price
tests/it/order_fill_accounting_idempotency.rsredelivered_frame_with_trade_id_leaves_money_byte_identical, redelivered_frame_without_trade_id_leaves_money_byte_identical, two_distinct_prints_with_equal_value_are_both_counted, late_fill_after_terminal_accumulates_fee_and_survives_redelivery, live_defect_shape_of_position_218_is_not_reproduced, duplicate_frame_still_books_the_non_money_parts, audit_is_silent_on_clean_data_but_still_names_its_sample, audit_reports_the_discrepancy_and_leaves_the_money_untouched, orders_filled_before_the_fix_are_counted_outside_the_sample, bootstrap_audit_raises_the_counter_and_journals_the_drift, bootstrap_audit_is_silent_on_clean_data_but_names_the_sample
tests/it/live_order_client_correlation.rss1..s15 (полный жизненный цикл сценариев коррелятора), fill_record_audit_trail_preserved, maker_fill_priced_from_maker_order_not_taker_complement, taker_fill_keeps_trade_price, taker_fill_carries_exchange_fee, incident_228_split_fill_one_ms_exactly_once, update_then_trade_full_duplicate_single_fill, straddle_trade_partially_precounted_by_update, genuine_overfill_still_raises_invariant_violation, maker_fill_pays_no_fee_even_on_fee_market, канонизация precision (precedent_noisy_price_canonicalized_volume_booked_in_full, noisy_shares_canonicalized_volume_booked_in_full, out_of_range_price_still_quarantined, shares_beyond_noise_epsilon_quarantined, sub_half_pip_price_collapse_quarantined), restart_recovers_persisted_taker_fee_rate_and_books_held_fill, legacy_row_without_persisted_rate_stays_unresolved, maker_fill_books_zero_fee_regardless_of_prefetch_state
tests/it/cancel_fill_truthfulness.rsfully_filled_order_cancelled_by_operator_is_booked_filled_not_zero, partially_filled_order_keeps_the_exchange_fill_on_cancel, partial_divergence_still_emits_the_cancel_record, full_divergence_does_not_claim_the_order_was_cancelled, unavailable_exchange_answer_defers_the_verdict_without_blocking_cancel, degraded_evidence_is_not_treated_as_a_verdict, stale_exchange_answer_never_lowers_the_recorded_fill, unacked_order_is_cancelled_without_asking_the_exchange
tests/it/cancel_taxonomy_durable.rsan_exchange_cancel_and_a_reconciled_cancel_are_told_apart, a_late_fill_that_corrects_the_row_to_filled_drops_the_cancel_code, a_partial_late_fill_keeps_the_cancel_code, a_stale_order_probe_tells_apart_who_actually_cancelled
tests/it/fill_routing_hardening.rsterminal_sell_with_registry_err_closes_signal, terminal_sell_with_persistence_failure_halts_engine, fill_after_self_cancel_recovers_via_persistent_snapshot, fill_for_unknown_order_halts_engine, non_terminal_sell_booking_failure_engages_accounting_drain, terminal_sell_leftover_engages_accounting_drain, terminal_sell_sub_lot_residual_keeps_trading_and_stays_accounted, terminal_sell_exactly_one_lot_residual_engages_accounting_drain, a_late_buy_fill_after_cancel_still_gets_its_exit, a_shrunk_stop_snapshot_recovered_from_sqlite_reduces_the_position_on_terminal_fill

9. Факты, найденные при попытке реализовать Confirmed-гейт (задача 12, 2026-09-15) ​

Задача 12 карточки durable-cancel-fill-resolution осталась нереализованной (раздел 6, раздел 7) — попытка её начать вскрыла свойства сегодняшней модели, которые сама модель нигде не документирует явно.

Пять точек вызова apply_fill_delta. Все денежные и видимые писатели раздела 1 сходятся в одну функцию через ровно пять мест вызова: correlator.rs:636 (WS Trade со статусом Matched, apply_trade), correlator.rs:861 (WS Order::Update, apply_order_update), reconcile.rs:457 (diff_present), reconcile.rs:576 (diff_absent), reconcile.rs:713 (apply_rest_trade).

Единственная метка идентичности (trade_id, exchange_id) гейтует наблюдение и деньги одним вызовом — но только на пути WS Matched и на пути REST-аудита сделок. В apply_trade registry.mark_processed(...) (correlator.rs:619) вызывается ДО публикации OwnFillObservation::Matched (:627) и ДО вызова apply_fill_delta (:636) — дубль трейд-кадра обрывает оба цикла одним continue. В apply_rest_trade тот же приём на ТОМ ЖЕ сете реестра (reconcile.rs:709, доккомментарий :662-666 прямо называет его общим с WS-каналом). Для статуса Confirmed эта метка НЕ ставится ни разу: apply_trade_confirmed (correlator.rs:666-713, прочитана целиком) не вызывает mark_processed вообще — потому что сегодня у Confirmed нет отдельного денежного пути, который нужно было бы дедуплицировать. Перенос букинга денег на Confirmed требует НОВОЙ, отдельной от существующей метки, потому что существующая уже расходуется на более раннем Matched для той же пары (trade_id, exchange_id); design.md D9 «Уточнения перед задачей 12» решает именно так — заводит отдельную метку дедупликации наблюдения на Matched (design.md:310-314) и отдельный денежный high-water confirmed_cumulative_shares (design.md:316-320).

orders.filled_shares объединяет вклад всех пяти путей без различения источника. docs/target/storage.md:67 фиксирует эту колонку как кумулятивную и идемпотентную к повторной доставке — но это ОДНО число на строку заявки, без тега «от какого из пяти путей пришёл вклад». Следствие для рехидратации (раздел 5.1, client.rs:473-534): hydrate_registry_from_open_orders затравливает max_update_size_matched = cumulative_filled_shares из durable orders.filled_shares — если это значение выросло, например, целиком за счёт diff_absent (REST-агрегат без единой сделки с trade_id), рехидратация не может отличить этот случай от вклада apply_trade/apply_rest_trade (по реальным сделкам). Новый денежный знак confirmed_cumulative_shares при первом выкате столкнётся с ТЕМ ЖЕ ограничением: затравить его можно только тем же нераздельным filled_shares, а не суммой именно Confirmed-сделок — design.md D9 п.4 «Затравка знака при выкате — условие выката, не код» (design.md:322-327) обходит проблему не решением, а условием: выкат только при нуле открытых позиций и живых заявок, чтобы переходного случая не возникало вовсе.

Конфликт 1 — apply_rest_trade не читает t.status. TradeResponse.status: TradeStatusType заполнено на каждой записи REST-ленты сделок (polymarket-client-sdk-0.4.4/src/clob/types/response.rs:378), но тело apply_rest_trade (reconcile.rs:667-731, прочитано целиком) ни разу не обращается к этому полю — книгует через apply_fill_delta любую сделку, найденную в ответе get_trades, независимо от её статуса (Matched, Confirmed, Retrying, что угодно). Задача 12, пункт «г» (tasks.md:252-253), предполагала, что apply_rest_trade «книжит по ТОЙ ЖЕ Confirmed-гейт-проверке» — сегодняшний код не содержит вообще никакого чтения статуса сделки на этом пути, то есть недостающий фильтр — не пропущенная ветка, а полностью отсутствующее чтение нужного поля.

Конфликт 2 — независимый Confirmed-знак способен задвоить доучёт REST-сверки по сделкам. Сегодня тест reconcile_get_trades_supplements_audit_and_is_idempotent (reconcile.rs:1446-1515) доказывает нулевое задвоение ИМЕННО потому, что REST Trade-делта (apply_rest_trade, канал cum_from_trades) и REST Cumulative-делта (diff_present/diff_absent, канал max_update_size_matched) берут effective = max(cum_from_trades, max_update_size_matched) — ОДИН общий эффективный счётчик (раздел 2, correlator.rs:997-1015): сделка, чьи доли уже покрыты выросшим max_update_size_matched, поглощается нулевой Trade-дельтой. Design.md D9 п.5 (design.md:329-341, «вариант B») фиксирует, что diff_present/diff_absent остаются на прежнем max_update_size_matched, а денежный букинг переезжает на новый confirmed_cumulative_shares для apply_trade/apply_rest_trade — то есть после такого разделения REST Cumulative-путь и REST Trade-путь описывают одно и то же исполнение через ДВА разных знака, не участвующих в общем max(...). Различить, что они относятся к одной и той же сделке, текущая модель apply_fill_delta не может: единственный сегодняшний механизм разделения по источникам — max двух каналов, а не тегированная идентичность сделки. По сообщению реализатора, попытка задачи 12 воспроизвела двойной учёт на модифицированной версии этого теста (диф 50 долей + аудит 20 долей той же сделки под отдельным Confirmed-знаком дал 70 вместо 50) — эта модификация не была закоммичена и не сохранилась в дереве, независимо проверить точное число невозможно; проверяемым фактом остаётся сам механизм — общий max(...) сегодня единственный барьер против такого двойного учёта, и он перестаёт работать при разделении знака без соответствующей перестройки diff_present/diff_absent.

Различить два источника подъёма max_update_size_matched при принятом design.md-плане текущая модель не позволяет. Design.md D9 п.1 (design.md:304-308) объединяет apply_trade на Matched и apply_order_update в ОДНУ роль — оба двигают только видимые счётчики (cum_from_trades, max_update_size_matched, filled_shares), ни один не эмитит OrderEvent. Но по природе эти два источника разные: Order-Update структурно НИКОГДА не станет денежным событием (комиссию не платит и trade_id не несёт), а Matched-трейд структурно СТАНЕТ денежным событием позже, на Confirmed, через тот же trade_id. Общий max_update_size_matched после объединения не хранит, каким из двух источников поднят каждый вклад — при восстановлении/аудите после рестарта эта информация была бы нужна, чтобы отличить «объём, который никогда не подтвердится сделкой» от «объём, ожидающий подтверждения по конкретному trade_id», а сегодняшняя модель одного счётчика такого разделения не несёт.

Шестой производитель денег, найден при проектировании 2026-09-15. Чтение client.rs:1379-1436 (extract_recovered_trades_for_order) для design-раунда этой карточки показало денежный путь, не названный в proposal.md наряду с пятью производителями выше (раздел 1, строка 1q). Фильтр is_settled_fill_status (client.rs:1307-1312) используется ДВАЖДЫ на разных вопросах: sum_order_matched (client.rs:1268-1300) отвечает на вопрос «жива ли заявка» — не денежный; но ТОТ ЖЕ широкий фильтр (Matched\|Mined\|Confirmed) применяется и в extract_recovered_trades_for_order, результат которой кормит record_economic_fact через patch_order_with_recovered_fill/book_diverged_fill_row (order_router.rs:4916-5069, раздел 1 строка 1n) — денежный писатель. Тест settled_fill_status_counts_only_settled (client.rs:5313-5325) прямо утверждает признание Matched/Mined как «settled» ожидаемым поведением. Это тот же архитектурный дефект, что и у пяти названных в proposal.md производителей (букинг на нетерминальном статусе), обнаруженный не замером живого прецедента, а чтением кода при проектировании — источник факта: строки, названные выше, прочитаны заново для этого добавления.


10. Открытые решения для design ​

  1. Что служит единственным источником истины об исполнении заявки — кадр биржи конкретного статуса или агрегированный size_matched.
  2. Денежная роль статусов Matched и Confirmed — книжат оба, только Confirmed, или Matched условно с последующей коррекцией на Confirmed.
  3. Судьба путей сверки без сделок (diff_present/diff_absent) — признанный постоянный остаток или отдельное доказательство через список сделок заявки.
  4. Что считается достаточным доказательством отсутствия исполнения при терминализации заявки с непокрытым остатком (diff_absent, недоступная или деградированная лента).
  5. Нужен ли откат ранее забукированных денег при статусе FAILED, и если да — каким механизмом (в P9b не реализован).
  6. Затравка после рестарта — различать вклад в filled_shares/денежный знак по источнику или полагаться на условие плоского состояния при выкате.
  7. Единица идемпотентности денег против идемпотентности наблюдения — одна общая метка или две независимые при нескольких high-water-знаках.
  8. Как разрешить расхождение docs/target/storage.md:67 (утверждает накопление сложением) с фактическим кодом (order_fill_accounting, идемпотентно) — какой документ правится и когда.
  9. Вводить ли в словарь статусов сделки явный вариант для документированного MATCHED_NOT_BROADCASTED или оставлять его в Unknown.
  10. Реализовывать ли bootstrap-заглушки HealAction::BackfillPositionFromTrades/RecomputeRealizedPnl (heal_executor.rs), и в какой денежной модели.
  11. Заводить ли наблюдаемость путей сверки (ReconciliationGap — метрика или durable-запись) до целостного решения или вместе с ним.
  12. Судьба снятой карточки reconcile-fill-confirmation — переоткрывать отдельно или полностью поглотить целостным design.

11. Противоречия и пробелы, найденные при замере ​

  1. Действующее принятое требование нарушено сегодняшним кодом. Спека durable-cancel-fill-resolution (specs/durable-cancel-fill-resolution/spec.md:83-89) утверждает SHALL «Позиция, финансовый результат и производная защитная заявка создаются только после окончательного подтверждённого статуса сделки» — а живой код книжит позицию/PnL/защитную заявку уже на TradeStatus::Matched (correlator.rs:486-652, apply_fill_delta), который по официальной документации Polymarket НЕ терминален («Matched — the transaction was submitted for execution», не «завершена»). Задача 12, которая должна была устранить это расхождение, стоит невыполненной (tasks.md:245), а её реализация остановлена решением оператора 2026-09-15 — тем самым спека сейчас формулирует требование, заведомо не выполняемое кодом, и карточка это прямо признаёт (design.md:329-341, «осознанный остаток»).

  2. SoT-документ расходится с шипнутым кодом. docs/target/storage.md:67 утверждает как текущий факт: «filled_value_micro_usd и fees_micro_usd НАКАПЛИВАЮТСЯ сложением [без идентичности филла]. Повторно обработанный кадр завышает стоимость… расхождение не поднимает ни одной тревоги. Подтверждённый случай — PM-ENGINE-39». Но код (order_router.rs:7221-7261, claim_fill_for_order_accounting + таблица order_fill_accounting) реализует ИМЕННО идемпотентность по FillIdentity, введённую карточкой fill-accounting-idempotency (закрыта 2026-08-21) — прочитанный docs/history/openspec/changes/2026-08-21-fill-accounting-idempotency/proposal.md подтверждает закрытие. При этом тот же storage.md в другом месте (:259) знает о существовании таблицы order_fill_accounting. Документ не обновлён после того, как код изменился — прямое нарушение правила «документ, противоречащий коду, — дефект той же тяжести, что сломанный тест».

  3. Один и тот же прецедент назван и защищён дважды под разными именами, с разной полнотой. «Позиция 231, Сеул, $5.94» (reconcile.rs:1124) и «id не переживал рестарт» (resting_order_adoption.rs:21-27) — буквально ОДИН И ТОТ ЖЕ инцидент 2026-08-13. Первая защита (resting_order_adoption.rs) закрывает корень (соответствие заводится ДО того, как исполнение случится), вторая (reconcile.rs::diff_absent) — компенсирующий heal-путь на агрегате size_matched БЕЗ trade_id, который сама снятая карточка reconcile-fill-confirmation называла остатком против требования «деньги — только по подтверждённой сделке» и откладывала как отдельную, ещё не спроектированную работу, зависящую от задачи 12 (которая остановлена). То есть защита симптома временно перевешивает нереализованное закрытие корневого денежного требования.

  4. Bootstrap-обнаруженное расхождение исправляет ЗАПИСЬ ОРДЕРА, но не позицию/ledger. apply_update_from_remote (heal_executor.rs:424-509) патчит orders.filled_shares/filled_value_micro_usd плоским OrderPatch, минуя patch_order_with_recovered_fill/record_economic_fact; соответствующее действие для позиции (HealAction::BackfillPositionFromTrades) — явная заглушка Err(NotImplemented) (heal_executor.rs:216-225, :610-640). Это отдельный от п.1 класс: заявка получает правдивую durable-строку orders, но её исполнение НЕ создаёт и не растит positions, если ни resting_order_adoption, ни живой lost_ack-путь не подхватили её раньше. Закрывается только внешней (по времени — необязательно немедленной) сверкой холдингов.

  5. Числовой, измеренный побочный эффект той же архитектурной развилки. Карточка exit-sizing-from-sellable-balance документирует 66 отказов биржи not enough balance на защитных SELL за одну сессию (12 из них — устойчивые, не лечится ожиданием), среднюю задержку 129с и максимум 4915с БЕЗ выхода/стопа на рынке. Корень — учтённый в движке размер позиции (забукированный, в т.ч. по Matched) расходится с фактически осевшим на бирже балансом (который по документации Polymarket становится окончательным только к Confirmed). Это прямое количественное подтверждение цены вопроса, который должна была закрыть задача 12.

  6. Требование покрыто тестами, которые проверяют СЕГОДНЯШНЕЕ (Matched-based) поведение, а не будущее (Confirmed-gate) требование. Тесты matched_then_confirmed_records_latency_and_flips_record_status/confirmed_without_prior_matched_counts_as_uncovered (correlator.rs) закрепляют только ЗАМЕР задержки Matched→Confirmed, а не денежный вердикт по Confirmed. Ни один из просмотренных тестов не проверяет сценарий спеки «Промежуточный статус сделки не книжит деньги» — он структурно противоречил бы поведению текущего apply_trade, которое книжит именно на Matched.

  7. Документированный официальный статус вне закрытого словаря. Официальная документация называет MATCHED_NOT_BROADCASTED («Matched, not broadcast») отдельным нетерминальным состоянием сделки; ни SDK TradeStatus (WS), ни TradeStatusType (REST) не именуют его явно — оба закрытых enum матчат ровно пять строк и заворачивают всё остальное в Unknown. Наблюдаемого денежного риска это не создаёт (для Unknown фильм не книжится), но это расхождение словаря с документированным протоколом, не отражённое ни в одном doc-комментарии SDK.