fix: prevent duplicate fill booking in live order reconciliation (stale live_fill_sync marker) - #232
Conversation
tracked_fill_baseline() short-circuited on the row-local live_fill_sync marker, which can go stale: a sync running between order submission and fill writes tracked_filled=0, and the executor's fill update does not rewrite the marker. After a worker restart, reconciliation re-claims the still-sent row, reads cumulative_filled from the exchange against the stale 0 baseline, and re-books the whole fill quantity. Observed live on Binance USD-M: the same exchange order (identical price and qty) recorded twice in strategy_order_fills, pending_orders.filled 0.1 -> 0.2, while the exchange reported executedQty 0.10 and positionAmt -0.10 throughout. The baseline now cross-checks every known source and takes the largest fill count (avg price travels with it): - executor phases.executor.market_summary when it names this exchange order — the row then tracks legs separately (e.g. a maker/limit leg booked next to the market leg), so the row aggregate must not compete; - otherwise the row aggregate (filled/avg_price), which for a single-leg order IS this leg's fill; - the live_fill_sync marker as a candidate only, never short-circuiting the fresher sources; - new: persisted_order_fill_baseline() sums strategy_order_fills by exchange_order_id — the ledger is authoritative whenever it is ahead of the row, so reconciliation can never re-book quantity the ledger already holds. On DB failure it degrades to the previous behavior. Adds a regression test reproducing the live incident plus unit tests for the baseline ranking; the existing restart and partial-fill tests remain green. Fixes OpenByteInc#231
|
Thanks for the detailed incident report, reproduction, and tests. We confirmed the root cause: a stale live_fill_sync.tracked_filled marker could override fresher fill data and cause the fill to be booked again after a worker restart. We’ve fixed this on main in commit cc6f523 by treating the sync marker as one candidate and selecting the freshest leg-aware baseline from the row and executor state. This keeps the fix focused and avoids adding a database query to every reconciliation cycle. Regression tests covering the stale-zero-marker scenario have also been added, and the full backend suite passes. Since the issue is now resolved on main, we won’t merge this PR. Thank you again for identifying the issue and providing such a thorough analysis. |
Fixes #231
What happened (live incident)
A Binance USD-M market order (0.1 @ 685.48) was booked twice into the platform ledger after a trading-worker restart: two
strategy_order_fillsrows with the sameexchange_order_id, identical price and qty;pending_orders.filledwent 0.1 → 0.2 and the UI position doubled. Binance's API reportedexecutedQty = 0.10/positionAmt = -0.10throughout — the exchange never had the doubled position; only the platform ledger did.Root cause
tracked_fill_baseline()inapp/services/pending_orders/sent_order_recovery.pyshort-circuits on the row-localexchange_response_json.live_fill_sync.tracked_filledmarker. A sync that runs between order submission and fill writestracked_filled = 0; the executor's fill update then bumps the row'sfilledcolumn but does not rewrite the marker. After a worker restart, reconciliation re-claims the still-sentrow, queries the exchange (cumulative_filled = 0.1), subtracts the stale baseline (0), and re-books the full quantity.The issue contains a deterministic minimal reproduction plus the live timeline.
Fix
tracked_fill_baseline()now cross-checks every known source and takes the largest fill count (avg price travels with it), preserving the existing multi-leg semantics:phases.executor.market_summarywhen it names this exchange order — the row then tracks legs separately (e.g. a maker/limit leg booked next to the market leg), so the row aggregate must NOT compete (keepstest_live_sent_sync_tracks_market_leg_without_overwriting_limit_fillgreen);filled/avg_price) — for a single-leg order it IS this leg's fill (keepstest_live_sent_sync_finalizes_after_restart_without_duplicate_fillgreen);live_fill_syncmarker — candidate only, never short-circuits the fresher sources;persisted_order_fill_baseline()(fill_records.py): sumsstrategy_order_fillsbyexchange_order_id— the ledger is authoritative whenever it is ahead of the row, so reconciliation can never re-book quantity the ledger already holds. On DB failure it degrades to the previous behavior.pending_order_worker.pypasses the ledger baseline into the sync before computing the delta; the delta math and downstream booking are unchanged.Tests
test_live_sent_sync_ignores_stale_zero_fill_sync_marker— regression reproducing the live incident (stale zero marker + executor fill + ledger fill ⇒ no re-booking, snapshot stays 0.1);tests/suite in the backend image: 1617 passed with this patch vs. 1614 passed onmain(same 8 pre-existing environmental failures in both runs, unrelated to this change) — i.e. +3 = the new tests, zero regressions.