feat(V2): IBKR client read methods + conid LRU cache
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 []
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user