611a2695a9
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
133 lines
4.3 KiB
Python
133 lines
4.3 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
from cerbero_mcp.exchanges.ibkr.client import IBKRClient
|
|
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
|