Skip to content

Commit 4d3b547

Browse files
authored
Fix IB ineligibility reason serialization (#4380)
Newer IB Gateway versions may return ineligibilityReasonList populated with ibapi.ineligibility_reason.IneligibilityReason objects in contractDetails. _serialize_for_json() only handled Decimal/Enum and passed other objects through unchanged, so persisting the instrument to a cache database (Redis/msgpack) raised: TypeError: Encoding objects of type <class 'ibapi.ineligibility_reason.IneligibilityReason'> is unsupported _serialize_for_json() now checks primitive types first, then converts arbitrary objects with __dict__ via vars() recursively (reusing the existing dict branch), falling back to str() for anything else. Adds a regression test that reproduces the original TypeError and verifies the fix, and a RELEASES.md entry.
1 parent fcc57d1 commit 4d3b547

3 files changed

Lines changed: 43 additions & 1 deletion

File tree

RELEASES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ releases as feedback arrives, before the final `2.0.0` release.
6565
- Fixed Tardis replay trades directory to `trades/` for catalog compatibility (#4373), thanks @AdvancedUno
6666
- Fixed Tardis replay bars directory to `bars/` for catalog compatibility (#4378), thanks @AdvancedUno
6767
- Fixed Hyperliquid `l2Book` resubscribe options and shared stream teardown (#4298)
68+
- Fixed Interactive Brokers `contract_details_to_dict` crash when `contractDetails.ineligibilityReasonList` contains non-serializable `IneligibilityReason` objects
6869

6970
### Internal Improvements
7071
- Improved core decimal deserialization to round fractional scales above 28 digits instead of erroring

nautilus_trader/adapters/interactive_brokers/parsing/instruments.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1016,10 +1016,19 @@ def contract_details_to_dict(contract_details: IBContractDetails) -> dict:
10161016
def _serialize_for_json(obj: object) -> object:
10171017
"""
10181018
Recursively convert Decimal objects and Enum objects to JSON-serializable types.
1019+
1020+
Falls back to converting arbitrary objects (such as ibapi response types like
1021+
``IneligibilityReason``) via ``vars()``, and to ``str()`` for anything else, so that
1022+
unexpected fields returned by newer IB Gateway versions do not break downstream
1023+
msgpack/JSON serialization (e.g. when persisting instruments to a cache database).
1024+
10191025
"""
10201026
if obj is None:
10211027
return None
10221028

1029+
if isinstance(obj, (str, int, float, bool)):
1030+
return obj
1031+
10231032
if isinstance(obj, Decimal):
10241033
return str(obj)
10251034

@@ -1032,7 +1041,10 @@ def _serialize_for_json(obj: object) -> object:
10321041
if isinstance(obj, (list, tuple)):
10331042
return [_serialize_for_json(item) for item in obj]
10341043

1035-
return obj
1044+
if hasattr(obj, "__dict__"):
1045+
return _serialize_for_json(vars(obj))
1046+
1047+
return str(obj)
10361048

10371049

10381050
def _tick_size_to_precision(tick_size: float | Decimal) -> int:

tests/integration_tests/adapters/interactive_brokers/test_parsing.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@
1616
import datetime
1717
from decimal import Decimal
1818

19+
import msgspec
1920
import pandas as pd
2021
import pytest
22+
from ibapi.ineligibility_reason import IneligibilityReason
2123

2224
from nautilus_trader.adapters.interactive_brokers.common import IBContract
2325
from nautilus_trader.adapters.interactive_brokers.common import IBContractDetails
@@ -36,6 +38,9 @@
3638
from nautilus_trader.adapters.interactive_brokers.parsing.instruments import VENUES_FUT
3739
from nautilus_trader.adapters.interactive_brokers.parsing.instruments import VENUES_OPT
3840
from nautilus_trader.adapters.interactive_brokers.parsing.instruments import _tick_size_to_precision
41+
from nautilus_trader.adapters.interactive_brokers.parsing.instruments import (
42+
contract_details_to_dict,
43+
)
3944
from nautilus_trader.adapters.interactive_brokers.parsing.instruments import (
4045
expiry_timestring_to_datetime,
4146
)
@@ -643,3 +648,27 @@ def test_valid_magnifier(
643648
@pytest.mark.parametrize("price_magnifier", [None, 0, -1])
644649
def test_invalid_magnifier_returns_original(self, price_magnifier: int | None) -> None:
645650
assert nautilus_price_to_ib_price(50.0, price_magnifier) == 50.0
651+
652+
653+
def test_contract_details_to_dict_serializes_ineligibility_reason_objects() -> None:
654+
# Arrange
655+
# Newer IB Gateway versions may populate `ineligibilityReasonList` with
656+
# `ibapi.ineligibility_reason.IneligibilityReason` objects, which are plain Python
657+
# objects (not Decimal/Enum/dict/list/tuple) and previously passed through
658+
# `_serialize_for_json` unchanged, breaking downstream msgpack/JSON serialization
659+
# (e.g. when persisting the instrument to a cache database).
660+
contract_details = IBTestContractStubs.aapl_equity_contract_details()
661+
contract_details.ineligibilityReasonList = [
662+
IneligibilityReason("200", "Not eligible for continuous trading"),
663+
]
664+
ib_contract_details = IBContractDetails.from_contract_details(contract_details)
665+
666+
# Act
667+
result = contract_details_to_dict(ib_contract_details)
668+
669+
# Assert
670+
encoded = msgspec.msgpack.encode(result)
671+
decoded = msgspec.msgpack.decode(encoded)
672+
assert decoded["ineligibilityReasonList"] == [
673+
{"id_": "200", "description": "Not eligible for continuous trading"},
674+
]

0 commit comments

Comments
 (0)