Skip to content

Commit cc6f523

Browse files
HenryHenry
authored andcommitted
fix: preserve recovered live order fills
1 parent 7e39531 commit cc6f523

2 files changed

Lines changed: 71 additions & 10 deletions

File tree

‎backend_api_python/app/services/pending_orders/sent_order_recovery.py‎

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
import json
6-
from typing import Any, Dict, Tuple
6+
from typing import Any, Dict, List, Tuple
77

88

99
def normalize_live_order_status(status: str) -> str:
@@ -36,27 +36,47 @@ def tracked_fill_baseline(
3636
previous_filled: float,
3737
previous_avg: float,
3838
) -> Tuple[float, float]:
39-
"""Return the cumulative baseline for the currently tracked exchange leg."""
40-
tracked_filled = max(0.0, float(previous_filled or 0.0))
41-
tracked_avg = max(0.0, float(previous_avg or 0.0))
39+
"""Return the freshest cumulative baseline for the tracked exchange leg.
40+
41+
``live_fill_sync`` is a progress snapshot, not an authoritative source: a
42+
sync can write zero before the executor records the fill in the row. Keep
43+
it as a candidate instead of allowing it to hide newer row or executor
44+
state after a worker restart.
45+
46+
When the executor summary names this exchange order, the pending-order row
47+
may aggregate multiple legs. In that case only the executor summary and
48+
sync marker compete; otherwise the row aggregate is a valid candidate.
49+
"""
50+
candidates: List[Tuple[float, float]] = []
51+
executor_baseline: Tuple[float, float] | None = None
4252
try:
4353
previous_response = json.loads(str(row.get("exchange_response_json") or "{}")) or {}
4454
sync_state = previous_response.get("live_fill_sync") or {}
4555
if isinstance(sync_state, dict) and "tracked_filled" in sync_state:
46-
return (
47-
max(0.0, float(sync_state.get("tracked_filled") or 0.0)),
48-
max(0.0, float(sync_state.get("tracked_avg_price") or 0.0)),
56+
candidates.append(
57+
(
58+
max(0.0, float(sync_state.get("tracked_filled") or 0.0)),
59+
max(0.0, float(sync_state.get("tracked_avg_price") or 0.0)),
60+
)
4961
)
5062
executor_raw = ((previous_response.get("phases") or {}).get("executor") or {})
5163
market_summary = executor_raw.get("market_summary") or {}
5264
if (
5365
isinstance(market_summary, dict)
5466
and str(market_summary.get("exchange_order_id") or "") == str(exchange_order_id or "")
5567
):
56-
return (
68+
executor_baseline = (
5769
max(0.0, float(market_summary.get("filled_qty") or 0.0)),
5870
max(0.0, float(market_summary.get("avg_price") or 0.0)),
5971
)
72+
candidates.append(executor_baseline)
6073
except Exception:
6174
pass
62-
return tracked_filled, tracked_avg
75+
if executor_baseline is None:
76+
candidates.append(
77+
(
78+
max(0.0, float(previous_filled or 0.0)),
79+
max(0.0, float(previous_avg or 0.0)),
80+
)
81+
)
82+
return max(candidates, key=lambda item: item[0])

‎backend_api_python/tests/test_pending_order_worker_live_sync.py‎

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,18 @@
22

33
import ast
44
import inspect
5+
import json
56
import textwrap
67

78
import pytest
89

910
from app.services import pending_order_worker as worker_module
1011
from app.services.live_trading.adapters import LiveOrderPhaseAdapter
11-
from app.services.pending_orders.sent_order_recovery import is_final_fill, normalize_live_order_status
12+
from app.services.pending_orders.sent_order_recovery import (
13+
is_final_fill,
14+
normalize_live_order_status,
15+
tracked_fill_baseline,
16+
)
1217

1318

1419
def test_live_order_adapter_call_uses_only_supported_constructor_keywords():
@@ -131,6 +136,42 @@ def test_live_sent_sync_finalizes_after_restart_without_duplicate_fill(monkeypat
131136
assert snapshots[0]["exchange_status"] == "filled"
132137

133138

139+
def test_stale_zero_sync_marker_cannot_hide_executor_fill_in_row():
140+
row = {
141+
"exchange_response_json": json.dumps(
142+
{"live_fill_sync": {"tracked_filled": 0.0, "tracked_avg_price": 0.0}}
143+
)
144+
}
145+
146+
filled, avg = tracked_fill_baseline(
147+
row,
148+
exchange_order_id="exchange-41",
149+
previous_filled=0.1,
150+
previous_avg=685.48,
151+
)
152+
153+
assert filled == pytest.approx(0.1)
154+
assert avg == pytest.approx(685.48)
155+
156+
157+
def test_live_sent_sync_does_not_rebook_fill_hidden_by_stale_marker(monkeypatch):
158+
row = _row(filled=0.1, avg_price=685.48)
159+
row["exchange_response_json"] = json.dumps(
160+
{"live_fill_sync": {"tracked_filled": 0.0, "tracked_avg_price": 0.0}}
161+
)
162+
worker, snapshots, persisted = _worker(
163+
monkeypatch,
164+
row,
165+
exchange_fill=(0.1, 685.48, "filled"),
166+
)
167+
168+
worker._sync_one_live_sent_order(row)
169+
170+
assert persisted == []
171+
assert snapshots[0]["status"] == "filled"
172+
assert snapshots[0]["filled"] == pytest.approx(0.1)
173+
174+
134175
def test_bitget_precision_normalized_fill_releases_residual_sent_order(monkeypatch):
135176
class Client:
136177
def normalize_base_order_size(self, *, symbol, product_type, base_size):

0 commit comments

Comments
 (0)