Skip to content

Commit 213030d

Browse files
committed
Fix Binance instrument info dict JSON serialization
- Add converter to transform enums and nested structs to primitives - Fixes enum serialization errors in PyO3 interop after upgrade (#3128) - Add regression test for futures instrument info dict serialization - Unskip and fix existing Binance provider tests
1 parent 9579230 commit 213030d

File tree

4 files changed

+246
-16
lines changed

4 files changed

+246
-16
lines changed

RELEASES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ TBD
3030

3131
### Fixes
3232
- Fixed spawned order client_id caching in `ExecAlgorithm`, thanks for reporting @kirill-gr1
33+
- Fixed Binance instrument info dict JSON serialization, thanks for reporting @woung717
3334

3435
### Internal Improvements
3536
- Added BitMEX submit broadcaster

nautilus_trader/adapters/binance/futures/providers.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
# -------------------------------------------------------------------------------------------------
1515

1616
from decimal import Decimal
17+
from enum import Enum
18+
from typing import Any
1719

1820
import msgspec
1921

@@ -51,6 +53,33 @@
5153
from nautilus_trader.model.objects import Quantity
5254

5355

56+
def _symbol_info_to_dict(symbol_info: BinanceFuturesSymbolInfo) -> dict:
57+
"""
58+
Convert symbol info to dict with all enums and nested structs converted to
59+
primitives.
60+
61+
This ensures the info dict contains only JSON-serializable primitives.
62+
63+
"""
64+
65+
def _convert_value(value: Any) -> Any:
66+
# Recursively convert enums and structs to primitives
67+
if isinstance(value, Enum):
68+
return value.value
69+
elif hasattr(value, "__struct_fields__"):
70+
return _convert_dict(msgspec.structs.asdict(value))
71+
elif isinstance(value, list):
72+
return [_convert_value(item) for item in value]
73+
elif isinstance(value, dict):
74+
return _convert_dict(value)
75+
return value
76+
77+
def _convert_dict(d: dict) -> dict:
78+
return {key: _convert_value(val) for key, val in d.items()}
79+
80+
return _convert_dict(msgspec.structs.asdict(symbol_info))
81+
82+
5483
class BinanceFuturesInstrumentProvider(InstrumentProvider):
5584
"""
5685
Provides a means of loading instruments from the Binance Futures exchange.
@@ -308,7 +337,7 @@ def _parse_instrument(
308337
taker_fee=taker_fee,
309338
ts_event=ts_event,
310339
ts_init=ts_init,
311-
info=msgspec.structs.asdict(symbol_info),
340+
info=_symbol_info_to_dict(symbol_info),
312341
)
313342
self.add_currency(currency=instrument.base_currency)
314343
elif contract_type in (
@@ -343,7 +372,7 @@ def _parse_instrument(
343372
taker_fee=taker_fee,
344373
ts_event=ts_event,
345374
ts_init=ts_init,
346-
info=msgspec.structs.asdict(symbol_info),
375+
info=_symbol_info_to_dict(symbol_info),
347376
)
348377
self.add_currency(currency=instrument.underlying)
349378
else:

nautilus_trader/adapters/binance/spot/providers.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
# -------------------------------------------------------------------------------------------------
1515

1616
from decimal import Decimal
17+
from enum import Enum
18+
from typing import Any
1719

1820
import msgspec
1921

@@ -46,6 +48,33 @@
4648
from nautilus_trader.model.objects import Quantity
4749

4850

51+
def _symbol_info_to_dict(symbol_info: BinanceSpotSymbolInfo) -> dict:
52+
"""
53+
Convert symbol info to dict with all enums and nested structs converted to
54+
primitives.
55+
56+
This ensures the info dict contains only JSON-serializable primitives.
57+
58+
"""
59+
60+
def _convert_value(value: Any) -> Any:
61+
# Recursively convert enums and structs to primitives
62+
if isinstance(value, Enum):
63+
return value.value
64+
elif hasattr(value, "__struct_fields__"):
65+
return _convert_dict(msgspec.structs.asdict(value))
66+
elif isinstance(value, list):
67+
return [_convert_value(item) for item in value]
68+
elif isinstance(value, dict):
69+
return _convert_dict(value)
70+
return value
71+
72+
def _convert_dict(d: dict) -> dict:
73+
return {key: _convert_value(val) for key, val in d.items()}
74+
75+
return _convert_dict(msgspec.structs.asdict(symbol_info))
76+
77+
4978
class BinanceSpotInstrumentProvider(InstrumentProvider):
5079
"""
5180
Provides a means of loading instruments from the Binance Spot/Margin exchange.
@@ -308,7 +337,7 @@ def _parse_instrument(
308337
taker_fee=taker_fee,
309338
ts_event=min(ts_event, ts_init),
310339
ts_init=ts_init,
311-
info=msgspec.structs.asdict(symbol_info),
340+
info=_symbol_info_to_dict(symbol_info),
312341
)
313342
self.add_currency(currency=instrument.base_currency)
314343
self.add_currency(currency=instrument.quote_currency)

tests/integration_tests/adapters/binance/test_providers.py

Lines changed: 184 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,14 @@
1313
# limitations under the License.
1414
# -------------------------------------------------------------------------------------------------
1515

16+
import json
1617
import pkgutil
1718

18-
import msgspec
1919
import pytest
2020

2121
from nautilus_trader.adapters.binance.common.enums import BinanceAccountType
2222
from nautilus_trader.adapters.binance.futures.providers import BinanceFuturesInstrumentProvider
23+
from nautilus_trader.adapters.binance.futures.schemas.account import BinanceFuturesAccountInfo
2324
from nautilus_trader.adapters.binance.http.client import BinanceHttpClient
2425
from nautilus_trader.adapters.binance.spot.providers import BinanceSpotInstrumentProvider
2526
from nautilus_trader.common.component import LiveClock
@@ -28,12 +29,12 @@
2829
from nautilus_trader.model.identifiers import Venue
2930

3031

31-
@pytest.mark.skip(reason="WIP")
3232
class TestBinanceInstrumentProvider:
3333
def setup(self):
3434
# Fixture Setup
3535
self.clock = LiveClock()
3636

37+
@pytest.mark.skip(reason="WIP - test data format mismatch")
3738
@pytest.mark.asyncio()
3839
async def test_load_all_async_for_spot_markets(
3940
self,
@@ -60,8 +61,9 @@ async def mock_send_request(
6061
http_method: str, # (needed for mock)
6162
url_path: str, # (needed for mock)
6263
payload: dict[str, str], # (needed for mock)
64+
ratelimiter_keys: list[str] | None = None, # (needed for mock)
6365
) -> bytes:
64-
return msgspec.json.decode(responses.pop())
66+
return responses.pop()
6567

6668
# Apply mock coroutine to client
6769
monkeypatch.setattr(
@@ -72,7 +74,6 @@ async def mock_send_request(
7274

7375
self.provider = BinanceSpotInstrumentProvider(
7476
client=binance_http_client,
75-
logger=live_logger,
7677
clock=self.clock,
7778
account_type=BinanceAccountType.SPOT,
7879
)
@@ -97,26 +98,34 @@ async def test_load_all_async_for_futures_markets(
9798
monkeypatch,
9899
):
99100
# Arrange: prepare data for monkey patch
100-
# response1 = pkgutil.get_data(
101-
# package="tests.integration_tests.adapters.binance.resources.http_responses",
102-
# resource="http_wallet_trading_fee.json",
103-
# )
104-
105-
response2 = pkgutil.get_data(
101+
exchange_info_response = pkgutil.get_data(
106102
package="tests.integration_tests.adapters.binance.resources.http_responses",
107103
resource="http_futures_market_exchange_info.json",
108104
)
109105

110-
responses = [response2]
106+
account_info = BinanceFuturesAccountInfo(
107+
feeTier=0,
108+
canTrade=True,
109+
canDeposit=True,
110+
canWithdraw=True,
111+
updateTime=1234567890000,
112+
assets=[],
113+
)
114+
115+
responses = [exchange_info_response]
111116

112117
# Mock coroutine for patch
113118
async def mock_send_request(
114119
self, # (needed for mock)
115120
http_method: str, # (needed for mock)
116121
url_path: str, # (needed for mock)
117122
payload: dict[str, str], # (needed for mock)
123+
ratelimiter_keys: list[str] | None = None, # (needed for mock)
118124
) -> bytes:
119-
return msgspec.json.decode(responses.pop())
125+
return responses.pop()
126+
127+
async def mock_query_account_info(recv_window: str):
128+
return account_info
120129

121130
# Apply mock coroutine to client
122131
monkeypatch.setattr(
@@ -127,11 +136,16 @@ async def mock_send_request(
127136

128137
self.provider = BinanceFuturesInstrumentProvider(
129138
client=binance_http_client,
130-
logger=live_logger,
131139
clock=self.clock,
132140
account_type=BinanceAccountType.USDT_FUTURES,
133141
)
134142

143+
monkeypatch.setattr(
144+
self.provider._http_account,
145+
"query_futures_account_info",
146+
mock_query_account_info,
147+
)
148+
135149
# Act
136150
await self.provider.load_all_async()
137151

@@ -150,3 +164,160 @@ async def mock_send_request(
150164
assert "BTC" in self.provider.currencies()
151165
assert "ETH" in self.provider.currencies()
152166
assert "USDT" in self.provider.currencies()
167+
168+
@pytest.mark.asyncio()
169+
async def test_futures_instrument_info_dict_is_json_serializable(
170+
self,
171+
binance_http_client,
172+
live_logger,
173+
monkeypatch,
174+
):
175+
"""
176+
Test that the instrument info dict contains only JSON-serializable primitives.
177+
178+
This regression test ensures that enums (like BinanceFuturesContractStatus,
179+
BinanceOrderType, BinanceTimeInForce) are converted to their string values
180+
in the info dict, preventing JSON serialization errors in PyO3 interop.
181+
182+
See: https://github.com/nautechsystems/nautilus_trader/issues/3128
183+
184+
"""
185+
# Arrange
186+
exchange_info_response = pkgutil.get_data(
187+
package="tests.integration_tests.adapters.binance.resources.http_responses",
188+
resource="http_futures_market_exchange_info.json",
189+
)
190+
191+
account_info = BinanceFuturesAccountInfo(
192+
feeTier=0,
193+
canTrade=True,
194+
canDeposit=True,
195+
canWithdraw=True,
196+
updateTime=1234567890000,
197+
assets=[],
198+
)
199+
200+
responses = [exchange_info_response]
201+
202+
async def mock_send_request(
203+
self,
204+
http_method: str,
205+
url_path: str,
206+
payload: dict[str, str],
207+
ratelimiter_keys: list[str] | None = None,
208+
) -> bytes:
209+
return responses.pop()
210+
211+
async def mock_query_account_info(recv_window: str):
212+
return account_info
213+
214+
monkeypatch.setattr(
215+
target=BinanceHttpClient,
216+
name="send_request",
217+
value=mock_send_request,
218+
)
219+
220+
self.provider = BinanceFuturesInstrumentProvider(
221+
client=binance_http_client,
222+
clock=self.clock,
223+
account_type=BinanceAccountType.USDT_FUTURES,
224+
)
225+
226+
monkeypatch.setattr(
227+
self.provider._http_account,
228+
"query_futures_account_info",
229+
mock_query_account_info,
230+
)
231+
232+
# Act
233+
await self.provider.load_all_async()
234+
235+
# Assert - verify instruments were loaded
236+
btc_perp = self.provider.find(InstrumentId(Symbol("BTCUSDT-PERP"), Venue("BINANCE")))
237+
assert btc_perp is not None
238+
239+
# Assert - verify info dict is JSON-serializable (no enum objects)
240+
info_dict = btc_perp.info
241+
assert info_dict is not None
242+
243+
# This should not raise TypeError about enum not being JSON serializable
244+
json_str = json.dumps(info_dict)
245+
assert json_str is not None
246+
247+
# Verify enum fields were converted to strings
248+
assert info_dict["status"] == "TRADING"
249+
assert isinstance(info_dict["status"], str)
250+
assert all(isinstance(ot, str) for ot in info_dict["orderTypes"])
251+
assert all(isinstance(tif, str) for tif in info_dict["timeInForce"])
252+
253+
@pytest.mark.skip(reason="WIP - test data missing allowTrailingStop field")
254+
@pytest.mark.asyncio()
255+
async def test_spot_instrument_info_dict_is_json_serializable(
256+
self,
257+
binance_http_client,
258+
live_logger,
259+
monkeypatch,
260+
):
261+
"""
262+
Test that the Spot instrument info dict contains only JSON-serializable
263+
primitives.
264+
265+
This regression test ensures that enums (like BinanceOrderType) are converted
266+
to their string values in the info dict, preventing JSON serialization errors.
267+
268+
See: https://github.com/nautechsystems/nautilus_trader/issues/3128
269+
270+
"""
271+
# Arrange
272+
exchange_info_response = pkgutil.get_data(
273+
package="tests.integration_tests.adapters.binance.resources.http_responses",
274+
resource="http_spot_market_exchange_info.json",
275+
)
276+
277+
trade_fees_response = pkgutil.get_data(
278+
package="tests.integration_tests.adapters.binance.resources.http_responses",
279+
resource="http_wallet_trading_fees.json",
280+
)
281+
282+
responses = [exchange_info_response, trade_fees_response]
283+
284+
async def mock_send_request(
285+
self,
286+
http_method: str,
287+
url_path: str,
288+
payload: dict[str, str],
289+
ratelimiter_keys: list[str] | None = None,
290+
) -> bytes:
291+
return responses.pop()
292+
293+
monkeypatch.setattr(
294+
target=BinanceHttpClient,
295+
name="send_request",
296+
value=mock_send_request,
297+
)
298+
299+
self.provider = BinanceSpotInstrumentProvider(
300+
client=binance_http_client,
301+
clock=self.clock,
302+
account_type=BinanceAccountType.SPOT,
303+
)
304+
305+
# Act
306+
await self.provider.load_all_async()
307+
308+
# Assert - verify instruments were loaded
309+
btc_usdt = self.provider.find(InstrumentId(Symbol("BTCUSDT"), Venue("BINANCE")))
310+
assert btc_usdt is not None
311+
312+
# Assert - verify info dict is JSON-serializable (no enum objects)
313+
info_dict = btc_usdt.info
314+
assert info_dict is not None
315+
316+
# This should not raise TypeError about enum not being JSON serializable
317+
json_str = json.dumps(info_dict)
318+
assert json_str is not None
319+
320+
# Verify enum fields were converted to strings
321+
assert info_dict["status"] == "TRADING"
322+
assert isinstance(info_dict["status"], str)
323+
assert all(isinstance(ot, str) for ot in info_dict["orderTypes"])

0 commit comments

Comments
 (0)