Skip to content

Commit 7225b40

Browse files
HenryHenry
authored andcommitted
fix: harden grid order and fill recovery
1 parent f172d18 commit 7225b40

4 files changed

Lines changed: 184 additions & 89 deletions

File tree

‎backend_api_python/app/services/grid/exchange_orders.py‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import secrets
56
from dataclasses import dataclass, field
67
from typing import Any, Dict, List, Optional, Tuple
78

@@ -182,7 +183,12 @@ def make_grid_initial_client_order_id(strategy_id: int, leg: str = "") -> str:
182183

183184

184185
def make_grid_client_order_id(strategy_id: int, cell_index: int, purpose: str) -> str:
185-
"""Short client oid (OKX max 32). purpose: e/l/x/s = entry long/exit/short entry."""
186+
"""Return a collision-resistant grid order id within OKX's 32-char limit.
187+
188+
Resting orders for the same cell can be replaced more than once in one
189+
second. A second-resolution suffix is therefore not unique enough and is
190+
rejected by exchanges such as Binance after an id has been used once.
191+
"""
186192
p = (purpose or "x")[:1].lower()
187193
if "long_entry" in purpose:
188194
p = "e"
@@ -192,8 +198,8 @@ def make_grid_client_order_id(strategy_id: int, cell_index: int, purpose: str) -
192198
p = "s"
193199
elif "short_exit" in purpose:
194200
p = "c"
195-
ts = int(__import__("time").time()) % 1000000
196-
return f"g{int(strategy_id) % 10000:04d}c{int(cell_index):03d}{p}{ts % 99999:05d}"[:32]
201+
nonce = secrets.token_hex(6)
202+
return f"g{int(strategy_id) % 10000:04d}c{int(cell_index):03d}{p}{nonce}"[:32]
197203

198204

199205
def place_grid_limit_order(

‎backend_api_python/app/services/grid/fill_handler.py‎

Lines changed: 83 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -91,40 +91,39 @@ def apply_grid_fill_to_local_state(
9191
market_type=str(tc.get("market_type") or "swap"),
9292
fill_source="grid_poller",
9393
)
94-
try:
95-
profit, _pos, matched_entry = apply_fill_to_local_position(
96-
strategy_id=int(strategy_id),
97-
symbol=sym,
98-
signal_type=signal_type,
99-
filled=qty,
100-
avg_price=px,
101-
leg=leg,
102-
)
103-
if grid_profit is not None:
104-
profit = grid_profit
105-
matched_entry = grid_entry_price
106-
record_trade(
107-
strategy_id=int(strategy_id),
108-
symbol=sym,
109-
trade_type=signal_type,
110-
price=px,
111-
amount=qty,
112-
commission=float(commission or 0.0),
113-
commission_ccy=str(commission_ccy or ""),
114-
commission_quote=commission_quote,
115-
profit=profit,
116-
close_reason=purpose,
117-
matched_entry_price=matched_entry,
118-
grid_matched_profit=profit if purpose in ("long_exit", "short_exit") and profit is not None else None,
119-
leg=leg,
120-
exchange_fill_id=str(exchange_fill_id or ""),
121-
execution_event_id=int(execution_event_id or 0),
122-
grid_order_id=int(order.id or 0),
123-
fee_status=str(fee_status or "pending"),
124-
fee_source=str(fee_source or ""),
125-
)
126-
except Exception as e:
127-
logger.warning("apply_grid_fill sid=%s: %s", strategy_id, e)
94+
profit, _pos, matched_entry = apply_fill_to_local_position(
95+
strategy_id=int(strategy_id),
96+
symbol=sym,
97+
signal_type=signal_type,
98+
filled=qty,
99+
avg_price=px,
100+
leg=leg,
101+
)
102+
if grid_profit is not None:
103+
profit = grid_profit
104+
matched_entry = grid_entry_price
105+
record_trade(
106+
strategy_id=int(strategy_id),
107+
symbol=sym,
108+
trade_type=signal_type,
109+
price=px,
110+
amount=qty,
111+
commission=float(commission or 0.0),
112+
commission_ccy=str(commission_ccy or ""),
113+
commission_quote=commission_quote,
114+
profit=profit,
115+
close_reason=purpose,
116+
matched_entry_price=matched_entry,
117+
grid_matched_profit=(
118+
profit if purpose in ("long_exit", "short_exit") and profit is not None else None
119+
),
120+
leg=leg,
121+
exchange_fill_id=str(exchange_fill_id or ""),
122+
execution_event_id=int(execution_event_id or 0),
123+
grid_order_id=int(order.id or 0),
124+
fee_status=str(fee_status or "pending"),
125+
fee_source=str(fee_source or ""),
126+
)
128127

129128

130129
def record_grid_market_fill(
@@ -162,56 +161,54 @@ def record_grid_market_fill(
162161
market_type=str(tc.get("market_type") or "swap"),
163162
fill_source="grid_market",
164163
)
165-
try:
166-
profit, _pos, matched_entry = apply_fill_to_local_position(
167-
strategy_id=int(strategy_id),
168-
symbol=sym,
169-
signal_type=sig,
170-
filled=qty,
171-
avg_price=px,
172-
leg=leg,
173-
)
174-
trade_id = record_trade(
175-
strategy_id=int(strategy_id),
176-
symbol=sym,
177-
trade_type=sig,
178-
price=px,
179-
amount=qty,
180-
commission=float(commission or 0.0),
181-
commission_ccy=str(commission_ccy or ""),
182-
commission_quote=commission_quote,
183-
profit=profit,
184-
close_reason=str(reason or sig),
185-
matched_entry_price=matched_entry,
186-
leg=leg,
187-
fee_status=str(fee_status or "pending"),
188-
fee_source=str(fee_source or "rest"),
189-
)
190-
if trade_id and (exchange_order_id or client_order_id):
191-
try:
192-
from app.services.execution_streams.repository import ExecutionEventRepository
164+
profit, _pos, matched_entry = apply_fill_to_local_position(
165+
strategy_id=int(strategy_id),
166+
symbol=sym,
167+
signal_type=sig,
168+
filled=qty,
169+
avg_price=px,
170+
leg=leg,
171+
)
172+
trade_id = record_trade(
173+
strategy_id=int(strategy_id),
174+
symbol=sym,
175+
trade_type=sig,
176+
price=px,
177+
amount=qty,
178+
commission=float(commission or 0.0),
179+
commission_ccy=str(commission_ccy or ""),
180+
commission_quote=commission_quote,
181+
profit=profit,
182+
close_reason=str(reason or sig),
183+
matched_entry_price=matched_entry,
184+
leg=leg,
185+
fee_status=str(fee_status or "pending"),
186+
fee_source=str(fee_source or "rest"),
187+
)
188+
if trade_id and (exchange_order_id or client_order_id):
189+
try:
190+
from app.services.execution_streams.repository import ExecutionEventRepository
193191

194-
ExecutionEventRepository().register_binding(
195-
credential_id=int(leg.credential_id or 0),
196-
exchange_id=str(exchange_id or tc.get("exchange_id") or tc.get("exchange") or ""),
197-
market_type=leg.normalized_market_type(),
198-
owner_type="grid_market",
199-
owner_id=int(trade_id),
200-
user_id=int(user_id or 1),
201-
strategy_id=int(strategy_id),
202-
symbol=sym,
203-
signal_type=sig,
204-
client_order_id=str(client_order_id or ""),
205-
exchange_order_id=str(exchange_order_id or ""),
206-
observed_filled=qty,
207-
)
208-
except Exception:
209-
logger.debug(
210-
"grid market execution binding failed trade_id=%s",
211-
trade_id,
212-
exc_info=True,
213-
)
214-
return int(trade_id or 0)
215-
except Exception as e:
216-
logger.warning("record_grid_market_fill sid=%s: %s", strategy_id, e)
217-
return 0
192+
ExecutionEventRepository().register_binding(
193+
credential_id=int(leg.credential_id or 0),
194+
exchange_id=str(
195+
exchange_id or tc.get("exchange_id") or tc.get("exchange") or ""
196+
),
197+
market_type=leg.normalized_market_type(),
198+
owner_type="grid_market",
199+
owner_id=int(trade_id),
200+
user_id=int(user_id or 1),
201+
strategy_id=int(strategy_id),
202+
symbol=sym,
203+
signal_type=sig,
204+
client_order_id=str(client_order_id or ""),
205+
exchange_order_id=str(exchange_order_id or ""),
206+
observed_filled=qty,
207+
)
208+
except Exception:
209+
logger.debug(
210+
"grid market execution binding failed trade_id=%s",
211+
trade_id,
212+
exc_info=True,
213+
)
214+
return int(trade_id or 0)

‎backend_api_python/tests/test_grid_engine.py‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,15 @@ def test_initial_market_target_qty_100u_20pct_20x():
357357
assert make_grid_initial_client_order_id(42, leg="long") != make_grid_initial_client_order_id(42, leg="short")
358358

359359

360+
def test_grid_resting_client_order_ids_do_not_collide_within_same_second():
361+
from app.services.grid.exchange_orders import make_grid_client_order_id
362+
363+
order_ids = {make_grid_client_order_id(42, 58, "long_exit") for _ in range(100)}
364+
365+
assert len(order_ids) == 100
366+
assert all(len(order_id) <= 32 and order_id.isalnum() for order_id in order_ids)
367+
368+
360369
def test_grid_line_qty_uses_quote_amount_times_leverage():
361370
from app.services.grid.engine import GridEngine
362371

@@ -1578,3 +1587,58 @@ def list_cells(self, strategy_id, symbol=None):
15781587
assert captured["profit"] == pytest.approx(expected)
15791588
assert captured["grid_matched_profit"] == pytest.approx(expected)
15801589
assert captured["matched_entry_price"] == pytest.approx(669.3)
1590+
1591+
1592+
def test_grid_fill_ledger_failure_is_not_silently_marked_processed(monkeypatch):
1593+
from app.services.grid import fill_handler
1594+
from app.services.grid.resting_orders_repo import GridRestingOrder
1595+
1596+
monkeypatch.setattr(fill_handler, "resolve_leg_context", lambda **kwargs: None)
1597+
monkeypatch.setattr(
1598+
fill_handler,
1599+
"apply_fill_to_local_position",
1600+
lambda **kwargs: (_ for _ in ()).throw(RuntimeError("ledger unavailable")),
1601+
)
1602+
1603+
order = GridRestingOrder(
1604+
id=99,
1605+
strategy_id=1,
1606+
symbol="BNB/USDT",
1607+
cell_index=11,
1608+
purpose="long_entry",
1609+
side="buy",
1610+
pos_side="long",
1611+
price=676.7,
1612+
quantity=0.05,
1613+
)
1614+
1615+
with pytest.raises(RuntimeError, match="ledger unavailable"):
1616+
fill_handler.apply_grid_fill_to_local_state(
1617+
1,
1618+
"BNB/USDT",
1619+
order,
1620+
0.05,
1621+
676.7,
1622+
{"market_type": "swap"},
1623+
)
1624+
1625+
1626+
def test_grid_market_fill_ledger_failure_is_not_silently_accepted(monkeypatch):
1627+
from app.services.grid import fill_handler
1628+
1629+
monkeypatch.setattr(fill_handler, "resolve_leg_context", lambda **kwargs: None)
1630+
monkeypatch.setattr(
1631+
fill_handler,
1632+
"apply_fill_to_local_position",
1633+
lambda **kwargs: (_ for _ in ()).throw(RuntimeError("ledger unavailable")),
1634+
)
1635+
1636+
with pytest.raises(RuntimeError, match="ledger unavailable"):
1637+
fill_handler.record_grid_market_fill(
1638+
1,
1639+
"BNB/USDT",
1640+
"open_long",
1641+
0.05,
1642+
676.7,
1643+
{"market_type": "swap"},
1644+
)

‎backend_api_python/tests/test_grid_poller.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,34 @@ def test_poller_dispatches_partial_fill_immediately():
8181
assert update.call_args.kwargs["processed_fill_qty"] == pytest.approx(0.4)
8282

8383

84+
def test_poller_retries_fill_when_local_ledger_posting_fails():
85+
poller = GridFillPoller()
86+
runner = MagicMock()
87+
runner.engine.on_order_filled.side_effect = RuntimeError("ledger unavailable")
88+
order = GridRestingOrder(
89+
id=14,
90+
strategy_id=1,
91+
symbol="BTC/USDT",
92+
price=100.0,
93+
quantity=1.0,
94+
processed_fill_qty=0.0,
95+
filled_quantity=0.0,
96+
status="open",
97+
)
98+
99+
with patch(
100+
"app.services.grid.poller.query_grid_order_fill",
101+
return_value=(0.4, 99.5, "partial"),
102+
):
103+
with patch.object(poller._repo, "update_status") as update:
104+
poller._poll_order(runner, MagicMock(), order, "swap")
105+
106+
runner.engine.on_order_filled.assert_called_once_with(order, 0.4, 99.5)
107+
assert update.call_args.kwargs["status"] == "partial"
108+
assert update.call_args.kwargs["filled_quantity"] == pytest.approx(0.4)
109+
assert update.call_args.kwargs["processed_fill_qty"] == pytest.approx(0.0)
110+
111+
84112
def test_poller_reconciles_cancelled_partial_fill():
85113
poller = GridFillPoller()
86114
runner = MagicMock()

0 commit comments

Comments
 (0)