Files
Cerbero-mcp/tests/unit/exchanges/ibkr/test_client.py
T
root b9c58a376f feat(V2): IBKR write methods + auto-confirm warning flow
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:34:43 +00:00

208 lines
7.0 KiB
Python

from __future__ import annotations
import re
from unittest.mock import AsyncMock, MagicMock
import pytest
from cerbero_mcp.exchanges.ibkr.client import IBKRClient, IBKRError
from pytest_httpx import HTTPXMock
@pytest.fixture
def fake_signer():
s = MagicMock()
s.consumer_key = "CK"
s.access_token = "AT"
s.get_live_session_token = AsyncMock(return_value="LSTBASE64==")
s.sign_with_lst = MagicMock(return_value="SIG==")
s.make_oauth_params = MagicMock(return_value={
"oauth_consumer_key": "CK", "oauth_token": "AT",
"oauth_nonce": "n", "oauth_timestamp": "1",
"oauth_signature_method": "HMAC-SHA256", "oauth_version": "1.0",
})
return s
@pytest.fixture
def client(fake_signer):
return IBKRClient(
signer=fake_signer,
account_id="DU1234",
paper=True,
base_url="https://api.ibkr.com/v1/api",
)
@pytest.mark.asyncio
async def test_health_no_network(client):
info = await client.health()
assert info["status"] == "ok"
assert info["paper"] is True
@pytest.mark.asyncio
async def test_get_account_summary(httpx_mock: HTTPXMock, client):
httpx_mock.add_response(
url=re.compile(r".*/portfolio/DU1234/summary"),
json={"netliquidation": {"amount": 10000, "currency": "USD"}, "totalcashvalue": {"amount": 8000}},
)
httpx_mock.add_response(
url=re.compile(r".*/tickle"),
json={"session": "abc"},
)
data = await client.get_account()
assert "netliquidation" in data
@pytest.mark.asyncio
async def test_request_retries_once_on_401(httpx_mock: HTTPXMock, client):
httpx_mock.add_response(url=re.compile(r".*/tickle"), json={})
# First call returns 401
httpx_mock.add_response(
url=re.compile(r".*/portfolio/DU1234/summary"),
status_code=401, text="session expired",
)
# Second call (after LST refresh) succeeds
httpx_mock.add_response(
url=re.compile(r".*/portfolio/DU1234/summary"),
json={"netliquidation": {"amount": 5000}},
)
data = await client.get_account()
assert data["netliquidation"]["amount"] == 5000
@pytest.mark.asyncio
async def test_request_raises_on_persistent_401(httpx_mock: HTTPXMock, client):
from cerbero_mcp.exchanges.ibkr.oauth import IBKRAuthError
httpx_mock.add_response(url=re.compile(r".*/tickle"), json={})
# Both attempts return 401
httpx_mock.add_response(
url=re.compile(r".*/portfolio/DU1234/summary"),
status_code=401, text="bad creds",
)
httpx_mock.add_response(
url=re.compile(r".*/portfolio/DU1234/summary"),
status_code=401, text="bad creds",
)
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
@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")
@pytest.mark.asyncio
async def test_place_order_auto_confirms_warning(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"),
json=[{"conid": 265598, "symbol": "AAPL", "secType": "STK"}],
)
httpx_mock.add_response(
url=re.compile(r".*/iserver/account/DU1234/orders$"),
method="POST",
json=[{"id": "msgid1", "message": ["outside RTH"]}],
)
httpx_mock.add_response(
url=re.compile(r".*/iserver/reply/msgid1"),
method="POST",
json=[{"order_id": "OID42", "order_status": "Submitted"}],
)
res = await client.place_order(
symbol="AAPL", side="buy", qty=1, order_type="market",
)
assert res["order_id"] == "OID42"
@pytest.mark.asyncio
async def test_place_order_rejects_critical_warning(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"),
json=[{"conid": 265598, "symbol": "AAPL", "secType": "STK"}],
)
httpx_mock.add_response(
url=re.compile(r".*/iserver/account/DU1234/orders$"),
method="POST",
json=[{"id": "msgid2", "message": ["Margin requirement exceeded"]}],
)
with pytest.raises(IBKRError, match="IBKR_ORDER_REJECTED_WARNING"):
await client.place_order(
symbol="AAPL", side="buy", qty=1000000, order_type="market",
)
@pytest.mark.asyncio
async def test_cancel_order(httpx_mock: HTTPXMock, client):
httpx_mock.add_response(url=re.compile(r".*/tickle"), json={})
httpx_mock.add_response(
url=re.compile(r".*/iserver/account/DU1234/order/OID42"),
method="DELETE",
json={"msg": "Request was submitted", "order_id": "OID42"},
)
res = await client.cancel_order("OID42")
assert res["canceled"] is True