refactor(V2): IBKR client read methods — defensive conid + sec_type DRY

Code review fixes (commit 611a269):
- resolve_conid validates conid key presence (was raw KeyError on malformed)
- _SEC_TYPE_MAP module constant — reused in get_ticker + get_bars
  (also fixes get_bars previously missing "forex": "CASH")
- New tests: empty response + malformed response error paths

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-05-03 20:32:50 +00:00
parent 611a2695a9
commit ded4414b32
2 changed files with 39 additions and 8 deletions
+16 -7
View File
@@ -25,6 +25,14 @@ class IBKRError(Exception):
_TICKLE_INTERVAL_S = 240.0 # tickle if last call > 4min ago _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 @dataclass
class IBKRClient: class IBKRClient:
@@ -148,7 +156,12 @@ class IBKRClient:
) )
if not result or not isinstance(result, list): if not result or not isinstance(result, list):
raise IBKRError(f"IBKR_CONID_NOT_FOUND: {symbol}/{sec_type}") 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 self._conid_cache[key] = conid
if len(self._conid_cache) > self._CONID_CACHE_MAX: if len(self._conid_cache) > self._CONID_CACHE_MAX:
self._conid_cache.popitem(last=False) 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 _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: async def get_ticker(self, symbol: str, asset_class: str = "stocks") -> dict:
sec_type = {"stocks": "STK", "options": "OPT", "futures": "FUT", "forex": "CASH"}.get( sec_type = _SEC_TYPE_MAP.get(asset_class.lower(), "STK")
asset_class.lower(), "STK"
)
conid = await self.resolve_conid(symbol, sec_type) conid = await self.resolve_conid(symbol, sec_type)
data = await self._request( data = await self._request(
"GET", "/iserver/marketdata/snapshot", "GET", "/iserver/marketdata/snapshot",
@@ -216,9 +227,7 @@ class IBKRClient:
self, symbol: str, asset_class: str = "stocks", self, symbol: str, asset_class: str = "stocks",
period: str = "1d", bar: str = "5min", period: str = "1d", bar: str = "5min",
) -> dict: ) -> dict:
sec_type = {"stocks": "STK", "options": "OPT", "futures": "FUT"}.get( sec_type = _SEC_TYPE_MAP.get(asset_class.lower(), "STK")
asset_class.lower(), "STK"
)
conid = await self.resolve_conid(symbol, sec_type) conid = await self.resolve_conid(symbol, sec_type)
data = await self._request( data = await self._request(
"GET", "/iserver/marketdata/history", "GET", "/iserver/marketdata/history",
+23 -1
View File
@@ -4,7 +4,7 @@ import re
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from cerbero_mcp.exchanges.ibkr.client import IBKRClient from cerbero_mcp.exchanges.ibkr.client import IBKRClient, IBKRError
from pytest_httpx import HTTPXMock 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["last_price"] == 150.5
assert snap["bid"] == 150.4 assert snap["bid"] == 150.4
assert snap["ask"] == 150.6 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")