diff --git a/src/cerbero_mcp/exchanges/ibkr/client.py b/src/cerbero_mcp/exchanges/ibkr/client.py index 96c49b5..e76ff1e 100644 --- a/src/cerbero_mcp/exchanges/ibkr/client.py +++ b/src/cerbero_mcp/exchanges/ibkr/client.py @@ -25,6 +25,14 @@ class IBKRError(Exception): _TICKLE_INTERVAL_S = 240.0 # tickle if last call > 4min ago +# Mapping asset_class (cerbero/MCP convention) → IBKR secType. +_SEC_TYPE_MAP: dict[str, str] = { + "stocks": "STK", + "options": "OPT", + "futures": "FUT", + "forex": "CASH", +} + @dataclass class IBKRClient: @@ -148,7 +156,12 @@ class IBKRClient: ) if not result or not isinstance(result, list): raise IBKRError(f"IBKR_CONID_NOT_FOUND: {symbol}/{sec_type}") - conid = int(result[0]["conid"]) + first = result[0] + if not isinstance(first, dict) or "conid" not in first: + raise IBKRError( + f"IBKR_CONID_NOT_FOUND: {symbol}/{sec_type} (malformed response)" + ) + conid = int(first["conid"]) self._conid_cache[key] = conid if len(self._conid_cache) > self._CONID_CACHE_MAX: self._conid_cache.popitem(last=False) @@ -183,9 +196,7 @@ class IBKRClient: _SNAPSHOT_FIELDS = "31,84,86,7295,7296" # last,bid,ask,bid_size,ask_size async def get_ticker(self, symbol: str, asset_class: str = "stocks") -> dict: - sec_type = {"stocks": "STK", "options": "OPT", "futures": "FUT", "forex": "CASH"}.get( - asset_class.lower(), "STK" - ) + sec_type = _SEC_TYPE_MAP.get(asset_class.lower(), "STK") conid = await self.resolve_conid(symbol, sec_type) data = await self._request( "GET", "/iserver/marketdata/snapshot", @@ -216,9 +227,7 @@ class IBKRClient: self, symbol: str, asset_class: str = "stocks", period: str = "1d", bar: str = "5min", ) -> dict: - sec_type = {"stocks": "STK", "options": "OPT", "futures": "FUT"}.get( - asset_class.lower(), "STK" - ) + sec_type = _SEC_TYPE_MAP.get(asset_class.lower(), "STK") conid = await self.resolve_conid(symbol, sec_type) data = await self._request( "GET", "/iserver/marketdata/history", diff --git a/tests/unit/exchanges/ibkr/test_client.py b/tests/unit/exchanges/ibkr/test_client.py index b5bea4d..a73aba6 100644 --- a/tests/unit/exchanges/ibkr/test_client.py +++ b/tests/unit/exchanges/ibkr/test_client.py @@ -4,7 +4,7 @@ import re from unittest.mock import AsyncMock, MagicMock import pytest -from cerbero_mcp.exchanges.ibkr.client import IBKRClient +from cerbero_mcp.exchanges.ibkr.client import IBKRClient, IBKRError from pytest_httpx import HTTPXMock @@ -130,3 +130,25 @@ async def test_get_ticker_resolves_and_fetches(httpx_mock: HTTPXMock, client): assert snap["last_price"] == 150.5 assert snap["bid"] == 150.4 assert snap["ask"] == 150.6 + + +@pytest.mark.asyncio +async def test_resolve_conid_empty_response_raises(httpx_mock: HTTPXMock, client): + httpx_mock.add_response(url=re.compile(r".*/tickle"), json={}) + httpx_mock.add_response( + url=re.compile(r".*/trsrv/secdef/search.*symbol=NOPE"), + json=[], + ) + with pytest.raises(IBKRError, match="IBKR_CONID_NOT_FOUND"): + await client.resolve_conid("NOPE", "STK") + + +@pytest.mark.asyncio +async def test_resolve_conid_malformed_response_raises(httpx_mock: HTTPXMock, client): + httpx_mock.add_response(url=re.compile(r".*/tickle"), json={}) + httpx_mock.add_response( + url=re.compile(r".*/trsrv/secdef/search.*symbol=BAD"), + json=[{"symbol": "BAD"}], # missing conid key + ) + with pytest.raises(IBKRError, match="malformed"): + await client.resolve_conid("BAD", "STK")