diff --git a/src/cerbero_mcp/exchanges/ibkr/client.py b/src/cerbero_mcp/exchanges/ibkr/client.py index 7a21012..96c49b5 100644 --- a/src/cerbero_mcp/exchanges/ibkr/client.py +++ b/src/cerbero_mcp/exchanges/ibkr/client.py @@ -134,3 +134,135 @@ class IBKRClient: async def get_account(self) -> dict: return await self._request("GET", f"/portfolio/{self.account_id}/summary") + + # ── Conid resolution ──────────────────────────────────────── + + async def resolve_conid(self, symbol: str, sec_type: str = "STK") -> int: + key = f"{sec_type}:{symbol}" + if key in self._conid_cache: + self._conid_cache.move_to_end(key) + return self._conid_cache[key] + result = await self._request( + "GET", "/trsrv/secdef/search", + params={"symbol": symbol, "secType": sec_type}, + ) + if not result or not isinstance(result, list): + raise IBKRError(f"IBKR_CONID_NOT_FOUND: {symbol}/{sec_type}") + conid = int(result[0]["conid"]) + self._conid_cache[key] = conid + if len(self._conid_cache) > self._CONID_CACHE_MAX: + self._conid_cache.popitem(last=False) + return conid + + # ── Positions / orders / activities ───────────────────────── + + async def get_positions(self, page: int = 0) -> list[dict]: + data = await self._request( + "GET", f"/portfolio/{self.account_id}/positions/{page}" + ) + return list(data) if isinstance(data, list) else [] + + async def get_open_orders(self) -> list[dict]: + data = await self._request( + "GET", "/iserver/account/orders", + params={"filters": "Submitted,PreSubmitted"}, + ) + if isinstance(data, dict): + return list(data.get("orders") or []) + return list(data) if isinstance(data, list) else [] + + async def get_activities(self, days: int = 7) -> list[dict]: + days = max(1, min(days, 90)) + data = await self._request( + "GET", "/iserver/account/trades", params={"days": days}, + ) + return list(data) if isinstance(data, list) else [] + + # ── Market data ───────────────────────────────────────────── + + _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" + ) + conid = await self.resolve_conid(symbol, sec_type) + data = await self._request( + "GET", "/iserver/marketdata/snapshot", + params={"conids": str(conid), "fields": self._SNAPSHOT_FIELDS}, + ) + if not data or not isinstance(data, list): + raise IBKRError("IBKR_NO_MARKET_DATA_SUBSCRIPTION") + row = data[0] + + def _f(k: str) -> float | None: + v = row.get(k) + try: + return float(v) if v not in (None, "") else None + except (TypeError, ValueError): + return None + + return { + "symbol": symbol, + "asset_class": asset_class, + "last_price": _f("31"), + "bid": _f("84"), + "ask": _f("86"), + "bid_size": _f("7295"), + "ask_size": _f("7296"), + } + + async def get_bars( + 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" + ) + conid = await self.resolve_conid(symbol, sec_type) + data = await self._request( + "GET", "/iserver/marketdata/history", + params={"conid": str(conid), "period": period, "bar": bar}, + ) + rows = (data or {}).get("data") or [] + return { + "symbol": symbol, + "asset_class": asset_class, + "interval": bar, + "bars": [ + { + "timestamp": r.get("t"), + "open": r.get("o"), + "high": r.get("h"), + "low": r.get("l"), + "close": r.get("c"), + "volume": r.get("v"), + } + for r in rows + ], + } + + async def get_option_chain( + self, underlying: str, expiry: str | None = None + ) -> dict: + conid = await self.resolve_conid(underlying, "STK") + params: dict[str, Any] = {"conid": str(conid), "secType": "OPT"} + if expiry: + params["month"] = expiry # IBKR format: "JAN26" + strikes = await self._request( + "GET", "/iserver/secdef/strikes", params=params, + ) + return { + "underlying": underlying, + "expiry": expiry, + "strikes": strikes, + } + + async def search_contracts( + self, symbol: str, sec_type: str = "STK" + ) -> list[dict]: + data = await self._request( + "GET", "/trsrv/secdef/search", + params={"symbol": symbol, "secType": sec_type}, + ) + return list(data) if isinstance(data, list) else [] diff --git a/tests/unit/exchanges/ibkr/test_client.py b/tests/unit/exchanges/ibkr/test_client.py index 3115aab..b5bea4d 100644 --- a/tests/unit/exchanges/ibkr/test_client.py +++ b/tests/unit/exchanges/ibkr/test_client.py @@ -86,3 +86,47 @@ async def test_request_raises_on_persistent_401(httpx_mock: HTTPXMock, client): ) with pytest.raises(IBKRAuthError, match="after retry"): await client.get_account() + + +@pytest.mark.asyncio +async def test_resolve_conid_caches(httpx_mock: HTTPXMock, client): + httpx_mock.add_response( + url=re.compile(r".*/tickle"), json={"session": "x"}, + ) + httpx_mock.add_response( + url=re.compile(r".*/trsrv/secdef/search.*symbol=AAPL"), + json=[{"conid": 265598, "symbol": "AAPL", "secType": "STK"}], + ) + cid = await client.resolve_conid("AAPL", "STK") + assert cid == 265598 + cid2 = await client.resolve_conid("AAPL", "STK") + assert cid2 == 265598 + + +@pytest.mark.asyncio +async def test_get_positions(httpx_mock: HTTPXMock, client): + httpx_mock.add_response(url=re.compile(r".*/tickle"), json={}) + httpx_mock.add_response( + url=re.compile(r".*/portfolio/DU1234/positions/0"), + json=[{"conid": 265598, "position": 10, "mktPrice": 150}], + ) + res = await client.get_positions() + assert isinstance(res, list) + assert res[0]["position"] == 10 + + +@pytest.mark.asyncio +async def test_get_ticker_resolves_and_fetches(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=AAPL"), + json=[{"conid": 265598, "symbol": "AAPL", "secType": "STK"}], + ) + httpx_mock.add_response( + url=re.compile(r".*/iserver/marketdata/snapshot"), + json=[{"31": "150.5", "84": "150.4", "86": "150.6", "conid": 265598}], + ) + snap = await client.get_ticker("AAPL", "stocks") + assert snap["last_price"] == 150.5 + assert snap["bid"] == 150.4 + assert snap["ask"] == 150.6