Skip to content

Commit f417938

Browse files
author
yunjinqi
committed
fix: flatten raw exchange fill envelopes
1 parent eed586c commit f417938

2 files changed

Lines changed: 159 additions & 9 deletions

File tree

backtrader/brokers/btapibroker.py

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2399,17 +2399,47 @@ def _drain_store_updates(self):
23992399
return
24002400

24012401
while True:
2402-
update = self.store.poll_broker_update()
2403-
if update is None:
2402+
raw_update = self.store.poll_broker_update()
2403+
if raw_update is None:
24042404
break
24052405

2406-
kind = str(update.get("kind") or "").lower()
2407-
if kind == "order":
2408-
self._apply_order_update(update)
2409-
elif kind == "trade":
2410-
self._apply_trade_update(update)
2411-
elif kind == "error":
2412-
self._apply_error_update(update)
2406+
for update in self._iter_broker_update_rows(raw_update):
2407+
kind = str(update.get("kind") or "").lower()
2408+
if kind == "order":
2409+
self._apply_order_update(update)
2410+
elif kind == "trade":
2411+
self._apply_trade_update(update)
2412+
elif kind == "error":
2413+
self._apply_error_update(update)
2414+
2415+
@classmethod
2416+
def _iter_broker_update_rows(cls, update):
2417+
"""Yield flat broker updates from exchange envelopes such as WS data lists."""
2418+
if not isinstance(update, dict):
2419+
return
2420+
2421+
data = update.get("data")
2422+
if isinstance(data, dict):
2423+
rows = [data]
2424+
elif isinstance(data, (list, tuple)):
2425+
rows = [item for item in data if isinstance(item, dict)]
2426+
else:
2427+
yield update
2428+
return
2429+
2430+
if not rows:
2431+
yield update
2432+
return
2433+
2434+
envelope = {key: value for key, value in update.items() if key not in {"data", "id"}}
2435+
if update.get("id") not in (None, ""):
2436+
envelope["message_id"] = update.get("id")
2437+
for row in rows:
2438+
flat = dict(envelope)
2439+
flat.update(row)
2440+
if "kind" not in flat and update.get("kind") not in (None, ""):
2441+
flat["kind"] = update.get("kind")
2442+
yield flat
24132443

24142444
def _trade_dedupe_key(self, update, order=None):
24152445
"""Build a stable trade dedupe key when the provider exposes a fill id."""

tests/unit/brokers/test_btapibroker.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3566,6 +3566,63 @@ def test_remote_trade_update_accepts_raw_okx_trade_aliases_and_fee():
35663566
broker.stop()
35673567

35683568

3569+
def test_remote_trade_update_accepts_raw_okx_orders_envelope_rows():
3570+
"""Raw OKX private-channel envelopes must be flattened before fill booking."""
3571+
symbol = "BTC-USDT-SWAP"
3572+
client = FakeBtApiClient(
3573+
history={symbol: [make_bar(0, 100.0, 101.0, 99.0, 100.5)]},
3574+
)
3575+
store = make_store(api=client, provider="okx")
3576+
data = store.getdata(dataname=symbol)
3577+
broker = store.getbroker(account_refresh_interval=60.0, positions_refresh_interval=60.0)
3578+
3579+
data._start()
3580+
assert data.load() is True
3581+
broker.start()
3582+
broker.setcommission(
3583+
commission=10.0,
3584+
commtype=bt.CommInfoBase.COMM_FIXED,
3585+
)
3586+
try:
3587+
order = broker.buy(
3588+
owner=None,
3589+
data=data,
3590+
size=1,
3591+
price=101.0,
3592+
exectype=bt.Order.Limit,
3593+
)
3594+
3595+
client.push_broker_update(
3596+
{
3597+
"kind": "trade",
3598+
"arg": {"channel": "orders"},
3599+
"id": "okx-message-1",
3600+
"data": [
3601+
{
3602+
"ordId": "btapi-1",
3603+
"tradeId": "okx-trade-1",
3604+
"instId": symbol,
3605+
"side": "buy",
3606+
"fillSz": "1",
3607+
"fillPx": "101.5",
3608+
"fee": "-0.25",
3609+
"feeCcy": "USDT",
3610+
}
3611+
],
3612+
}
3613+
)
3614+
3615+
broker.next()
3616+
3617+
assert order.status == bt.Order.Completed
3618+
assert order.executed.size == pytest.approx(1.0)
3619+
assert order.executed.price == pytest.approx(101.5)
3620+
assert order.executed.comm == pytest.approx(0.25)
3621+
assert broker.positions[symbol].size == pytest.approx(1.0)
3622+
finally:
3623+
broker.stop()
3624+
3625+
35693626
def test_remote_trade_update_accepts_raw_bybit_v5_execution_aliases_and_fee():
35703627
"""Raw Bybit V5 execution events must use exchange fill fields and exact fees."""
35713628
symbol = "BTCUSDT"
@@ -3667,6 +3724,69 @@ def test_remote_trade_update_accepts_raw_bybit_v5_execution_aliases_and_fee():
36673724
broker.stop()
36683725

36693726

3727+
def test_remote_trade_update_accepts_raw_bybit_v5_execution_envelope_rows():
3728+
"""Raw Bybit V5 execution envelopes must be flattened and booked as fills."""
3729+
symbol = "BTCUSDT"
3730+
client = FakeBtApiClient(
3731+
history={symbol: [make_bar(0, 100.0, 101.0, 99.0, 100.5)]},
3732+
)
3733+
store = make_store(api=client, provider="bybit")
3734+
data = store.getdata(dataname=symbol)
3735+
broker = store.getbroker(account_refresh_interval=60.0, positions_refresh_interval=60.0)
3736+
3737+
data._start()
3738+
assert data.load() is True
3739+
broker.start()
3740+
broker.setcommission(
3741+
commission=10.0,
3742+
commtype=bt.CommInfoBase.COMM_FIXED,
3743+
)
3744+
try:
3745+
order = broker.buy(
3746+
owner=None,
3747+
data=data,
3748+
size=1,
3749+
price=101.0,
3750+
exectype=bt.Order.Limit,
3751+
)
3752+
3753+
client.push_broker_update(
3754+
{
3755+
"kind": "trade",
3756+
"topic": "execution",
3757+
"id": "bybit-message-1",
3758+
"creationTime": 1746270400355,
3759+
"data": [
3760+
{
3761+
"category": "linear",
3762+
"symbol": symbol,
3763+
"orderId": "btapi-1",
3764+
"orderLinkId": "btapi-1",
3765+
"side": "Buy",
3766+
"execQty": "1",
3767+
"execPrice": "101.5",
3768+
"execFee": "0.15",
3769+
"execId": "bybit-exec-1",
3770+
"execTime": "1746270400353",
3771+
"feeCurrency": "USDT",
3772+
"isMaker": False,
3773+
}
3774+
],
3775+
}
3776+
)
3777+
3778+
broker.next()
3779+
3780+
assert order.status == bt.Order.Completed
3781+
assert order.executed.size == pytest.approx(1.0)
3782+
assert order.executed.price == pytest.approx(101.5)
3783+
assert order.executed.comm == pytest.approx(0.15)
3784+
assert broker.positions[symbol].size == pytest.approx(1.0)
3785+
assert broker.positions[symbol].price == pytest.approx(101.5)
3786+
finally:
3787+
broker.stop()
3788+
3789+
36703790
def test_remote_trade_update_without_price_is_ignored_not_zero_filled():
36713791
"""Malformed fills must not execute locally at price zero."""
36723792
client = FakeBtApiClient(

0 commit comments

Comments
 (0)