Тема
Замер: учёт исполнения заявок (филлов) в pm-engine, 2026-09-15
Оператор 2026-09-15 остановил точечную реализацию Confirmed-гейта — задачу 12 карточки durable-cancel-fill-resolution — со словами о целостном решении вместо очередной заплатки. Этот документ — раздел «Замер» будущей карточки целостного учёта исполнения заявок: он не предлагает решений и не сравнивает варианты, только фиксирует, что делает сегодняшний код, что требует документация и что показала неудавшаяся попытка задачи 12. Замер сделан чтением кода на main (65c95920), официальной документации Polymarket (через MCP polymarket-docs) и архива OpenSpec. Каждое утверждение — с файл:строка; денежные формулы процитированы дословно.
Оглавление
- Источники фактов исполнения (входы)
- Модель состояния заявки
- Потребители: от
OrderEventдо денег - Статусы сделки и их семантика
- Рестарт и восстановление
- Прецеденты и защиты
- Требования (действующие SHALL)
- Тесты, закрепляющие текущее поведение
- Факты, найденные при попытке реализовать Confirmed-гейт (задача 12, 2026-09-15)
- Открытые решения для design
- Противоречия и пробелы, найденные при замере
1. Источники фактов исполнения (входы)
| # | Источник | Файл:строка | Что читает | Что делает | Метка идемпотентности |
|---|---|---|---|---|---|
| 1a | WS 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_delta | registry.mark_processed(trade_id, exchange_id) — сет (trade_id, exchange_id), строка 619 |
| 1b | WS Trade (статус CONFIRMED) | correlator.rs:666-713 (apply_trade_confirmed) | t.transaction_hash | НЕ книжит fill-delta (уже эмитнут на более раннем MATCHED); публикует OwnFillObservation::Confirmed (источник tx-хеша) и вызывает record_confirmed_coverage (замер задержки Matched→Confirmed, метрики задачи 11) | нет отдельной — денежная сторона не тронута |
| 1c | WS Trade (RETRYING/MINED/иное) | correlator.rs:499-518 | t.status | debug!, (Vec::new(), Vec::new()) — не влияет на деньги | — |
| 1d | WS Trade (FAILED) | correlator.rs:503-510 | — | warn!("trade FAILED status observed — rollback NOT implemented in P9b (backlog)") — никакого отката ранее забукированного MATCHED | — |
| 1e | WS 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 |
| 1f | WS Order::Placement/Cancellation | correlator.rs:793-826, 882-915 | — | Placement — echo, не денежный; Cancellation переводит LiveOrderStatus::Cancelled, эмитит OrderEvent::Cancelled | — |
| 1g | REST-сверка, заявка присутствует (diff_present) | reconcile.rs:408-475 | rest.size_matched (агрегат, без trade_id) | Если rest_cumulative > local, эмитит OrderEvent::ReconciliationGap + книжит через apply_fill_delta(FillDelta::Cumulative(...)), is_taker=false | high-water (тот же механизм) |
| 1h | REST-сверка, заявка исчезла (diff_absent) | reconcile.rs:495-630 | get_order_detail → RemoteOrderDetail.size_matched_shares/status/evidence | Если биржа подтвердила налив выше локального — книжит ДО терминализации тем же apply_fill_delta; при полном наливе → Filled, при частичном → Cancelled(Reconciled) | high-water |
| 1i | REST-сверка, аудит по сделкам (audit_trades/apply_rest_trade) | reconcile.rs:632-731 | get_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-534 | orders.filled_shares, orders.taker_fee_rate_bps_actual | Затравливает max_update_size_matched/cumulative_filled_shares водяным знаком = durable filled_shares (клампован [0, size]) | сама рехидратация событий не эмитит |
| 1l | Bootstrap 3-way diff (OrderFieldDivergence) | lifecycle/bootstrap.rs:400-537 | remote_snap.size_matched_shares vs row.filled_shares | Только НАБЛЮДЕНИЕ расхождения (Discrepancy::OrderFieldDivergence) — денег не книжит сам | — |
| 1m | Heal-исполнение расхождения (apply_update_from_remote) | crates/pm-engine/src/lifecycle/heal_executor.rs:424-509 | size_matched_shares | Патчит ТОЛЬКО orders.filled_shares/filled_value_micro_usd плоским OrderPatch — не создаёт и не растит positions, не проходит через record_economic_fact | idempotent-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} |
| 1o | Coverage/holdings-сверка | lifecycle/holdings_reconcile.rs, engine/coverage_verification.rs | on-chain холдинги vs positions | Пишет ТОЛЬКО измерение покрытия (record_coverage_measurement) — не мутирует size_shares/PnL | — |
| 1p | On-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, питающий строку 1n | crates/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_trades | apply_fill_delta (:1066, ветка FillDelta::Trade) | сам apply_fill_delta | Сумма ДЕДУПЛИЦИРОВАННЫХ Trade-делт |
max_update_size_matched | apply_fill_delta (:1067, ветка Cumulative) | сам же | High-water кумулятивного size_matched из Order-UPDATE/REST |
fills: BTreeMap<trade_id, FillRecord> | :1072-1091 | record_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) — построчно
:969-987— ранний отказ наdelta ≤ 0(Trade) /< 0(Cumulative), без мутации.:997-1015— вычислениеnew_cum_trades/new_max_update; для Cumulative — stale-guard:c.0 < state.max_update_size_matched.0→warn!+None, счётчики не трогаются.:1022-1038— инвариантnew_effective ≤ original_size_shares; нарушение →OrderEvent::InvariantViolation, счётчики НЕ мутируются (вerror!-веткеreturnдо присвоений).:1052-1064— HOLD-гейт:is_taker && рост > 0 && taker_fee_rate_bps.is_none()→ кадр целиком НЕ учитывается (ниfills, ни счётчики),warn!+pm_engine_fill_fee_rate_unresolved_total.:1066-1091— мутацияcum_from_trades/max_update_size_matched, записьFillRecordвstate.fills(аудит-провенанс — ВСЕГДА, даже еслиemit_delta == 0).:1093-1141—emit_delta = new_effective − old_effective;== 0→ без эмиссии, но с проверкой недобукированной taker-комиссии (pm_engine_fill_fee_unbooked_total).:1150-1161— straddle-детект (frame_shares > emit_delta) →pm_engine_fill_straddle_total,warn!.:1163-1185—value = fill_value(price_pip, emit_delta)(GROSS от emitted delta);fee = taker_fee_micro_usd(bps, price_pip, frame_shares)(от ПОЛНОГО объёма трейда, не от delta).:1186-1203—state.cumulative_filled_shares = new_effective;cumulative_filled_value += value; anonymous-audit запись еслиtrade_id.is_none().: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_uidUNIQUE), затем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(...), эмиссия только на росте max | correlator.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-21 | 679 940 µUSD (~$0.68) | filled_value_micro_usd/fees_micro_usd накапливались сложением БЕЗ идентичности филла (в отличие от filled_shares, идемпотентного к повтору) | Таблица order_fill_accounting + FillIdentity-ключ, гейт в claim_fill_for_order_accounting | docs/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-16 | 3.3% выручки на taker-SELL | replay/mod.rs передаёт fee=MicroUsd(0) в apply_buy_fill/apply_sell_fill | OPEN, решения оператора нет (архивный срез) | 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-defects | docs/analysis/harvester-engine-session-2026-09-14-postmortem.md:83-163 |
Mexico, остаток redeem — закрытая позиция 263, остаток 8773 micro-shares, redeem_status=FAILED, ClickHouse-витрина показывает inventory=0 | 14.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.rs | incident_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.rs | redelivered_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.rs | s1..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.rs | fully_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.rs | an_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.rs | terminal_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
- Что служит единственным источником истины об исполнении заявки — кадр биржи конкретного статуса или агрегированный
size_matched. - Денежная роль статусов
MatchedиConfirmed— книжат оба, толькоConfirmed, илиMatchedусловно с последующей коррекцией наConfirmed. - Судьба путей сверки без сделок (
diff_present/diff_absent) — признанный постоянный остаток или отдельное доказательство через список сделок заявки. - Что считается достаточным доказательством отсутствия исполнения при терминализации заявки с непокрытым остатком (
diff_absent, недоступная или деградированная лента). - Нужен ли откат ранее забукированных денег при статусе
FAILED, и если да — каким механизмом (в P9b не реализован). - Затравка после рестарта — различать вклад в
filled_shares/денежный знак по источнику или полагаться на условие плоского состояния при выкате. - Единица идемпотентности денег против идемпотентности наблюдения — одна общая метка или две независимые при нескольких high-water-знаках.
- Как разрешить расхождение
docs/target/storage.md:67(утверждает накопление сложением) с фактическим кодом (order_fill_accounting, идемпотентно) — какой документ правится и когда. - Вводить ли в словарь статусов сделки явный вариант для документированного
MATCHED_NOT_BROADCASTEDили оставлять его вUnknown. - Реализовывать ли bootstrap-заглушки
HealAction::BackfillPositionFromTrades/RecomputeRealizedPnl(heal_executor.rs), и в какой денежной модели. - Заводить ли наблюдаемость путей сверки (
ReconciliationGap— метрика или durable-запись) до целостного решения или вместе с ним. - Судьба снятой карточки
reconcile-fill-confirmation— переоткрывать отдельно или полностью поглотить целостным design.
11. Противоречия и пробелы, найденные при замере
Действующее принятое требование нарушено сегодняшним кодом. Спека
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, «осознанный остаток»).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. Документ не обновлён после того, как код изменился — прямое нарушение правила «документ, противоречащий коду, — дефект той же тяжести, что сломанный тест».Один и тот же прецедент назван и защищён дважды под разными именами, с разной полнотой. «Позиция 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 (которая остановлена). То есть защита симптома временно перевешивает нереализованное закрытие корневого денежного требования.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-путь не подхватили её раньше. Закрывается только внешней (по времени — необязательно немедленной) сверкой холдингов.Числовой, измеренный побочный эффект той же архитектурной развилки. Карточка
exit-sizing-from-sellable-balanceдокументирует 66 отказов биржиnot enough balanceна защитных SELL за одну сессию (12 из них — устойчивые, не лечится ожиданием), среднюю задержку 129с и максимум 4915с БЕЗ выхода/стопа на рынке. Корень — учтённый в движке размер позиции (забукированный, в т.ч. поMatched) расходится с фактически осевшим на бирже балансом (который по документации Polymarket становится окончательным только кConfirmed). Это прямое количественное подтверждение цены вопроса, который должна была закрыть задача 12.Требование покрыто тестами, которые проверяют СЕГОДНЯШНЕЕ (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.Документированный официальный статус вне закрытого словаря. Официальная документация называет
MATCHED_NOT_BROADCASTED(«Matched, not broadcast») отдельным нетерминальным состоянием сделки; ни SDKTradeStatus(WS), ниTradeStatusType(REST) не именуют его явно — оба закрытых enum матчат ровно пять строк и заворачивают всё остальное вUnknown. Наблюдаемого денежного риска это не создаёт (дляUnknownфильм не книжится), но это расхождение словаря с документированным протоколом, не отражённое ни в одном doc-комментарии SDK.