TradingAgents/tests/unit/execution/test_ibkr_broker.py

704 lines
20 KiB
Python

"""Tests for IBKR Broker module.
Issue #25: [EXEC-24] IBKR broker - futures, ASX equities
These tests use mocks to test the broker without requiring actual
IBKR connection or ib_insync SDK.
"""
import pytest
from datetime import datetime, timezone
from decimal import Decimal
from typing import Any, Dict, List, Optional
from unittest.mock import AsyncMock, MagicMock, patch
from tradingagents.execution import (
# Enums
AssetClass,
OrderSide,
OrderType,
TimeInForce,
OrderStatus,
PositionSide,
# Data Classes
OrderRequest,
Order,
Position,
AccountInfo,
Quote,
AssetInfo,
# Exceptions
BrokerError,
AuthenticationError,
ConnectionError,
OrderError,
InsufficientFundsError,
InvalidOrderError,
PositionError,
# IBKR
IBKRBroker,
IB_INSYNC_AVAILABLE,
FUTURES_SPECS,
)
# =============================================================================
# Mock IB_insync classes
# =============================================================================
class MockContract:
"""Mock IBKR Contract."""
def __init__(
self,
symbol: str = "AAPL",
secType: str = "STK",
exchange: str = "SMART",
currency: str = "USD",
):
self.symbol = symbol
self.secType = secType
self.exchange = exchange
self.currency = currency
self.localSymbol = symbol
class MockOrderStatus:
"""Mock IBKR OrderStatus."""
def __init__(
self,
status: str = "Submitted",
filled: float = 0,
avgFillPrice: float = 0,
):
self.status = status
self.filled = filled
self.avgFillPrice = avgFillPrice
class MockOrder:
"""Mock IBKR Order."""
def __init__(
self,
orderId: int = 1,
action: str = "BUY",
totalQuantity: float = 100,
orderType: str = "MKT",
lmtPrice: float = 0,
auxPrice: float = 0,
tif: str = "DAY",
):
self.orderId = orderId
self.action = action
self.totalQuantity = totalQuantity
self.orderType = orderType
self.lmtPrice = lmtPrice
self.auxPrice = auxPrice
self.tif = tif
class MockTrade:
"""Mock IBKR Trade."""
def __init__(
self,
order: MockOrder = None,
contract: MockContract = None,
orderStatus: MockOrderStatus = None,
):
self.order = order or MockOrder()
self.contract = contract or MockContract()
self.orderStatus = orderStatus or MockOrderStatus()
class MockPortfolioItem:
"""Mock IBKR PortfolioItem."""
def __init__(
self,
contract: MockContract = None,
position: float = 100,
marketPrice: float = 160.0,
marketValue: float = 16000.0,
averageCost: float = 150.0,
unrealizedPNL: float = 1000.0,
):
self.contract = contract or MockContract()
self.position = position
self.marketPrice = marketPrice
self.marketValue = marketValue
self.averageCost = averageCost
self.unrealizedPNL = unrealizedPNL
class MockAccountValue:
"""Mock IBKR AccountValue."""
def __init__(
self,
tag: str = "TotalCashValue",
value: str = "100000",
currency: str = "USD",
):
self.tag = tag
self.value = value
self.currency = currency
class MockTicker:
"""Mock IBKR Ticker."""
def __init__(
self,
bid: float = 159.95,
ask: float = 160.05,
last: float = 160.0,
bidSize: int = 100,
askSize: int = 100,
volume: int = 1000000,
):
self.bid = bid
self.ask = ask
self.last = last
self.bidSize = bidSize
self.askSize = askSize
self.volume = volume
class MockIB:
"""Mock IB class."""
def __init__(self):
self._connected = False
self._orders: Dict[int, MockTrade] = {}
self._order_counter = 0
self._portfolio: List[MockPortfolioItem] = []
self._account_values: List[MockAccountValue] = []
async def connectAsync(self, host: str, port: int, clientId: int) -> None:
self._connected = True
def isConnected(self) -> bool:
return self._connected
def disconnect(self) -> None:
self._connected = False
def managedAccounts(self) -> List[str]:
return ["DU1234567"]
def accountValues(self) -> List[MockAccountValue]:
return self._account_values or [
MockAccountValue("TotalCashValue", "100000"),
MockAccountValue("BuyingPower", "200000"),
MockAccountValue("NetLiquidation", "150000"),
MockAccountValue("MaintMarginReq", "5000"),
MockAccountValue("AvailableFunds", "195000"),
MockAccountValue("AccountType", "individual"),
]
def portfolio(self) -> List[MockPortfolioItem]:
return self._portfolio or [
MockPortfolioItem(
contract=MockContract("AAPL", "STK"),
position=100,
),
]
async def qualifyContractsAsync(self, contract: MockContract) -> List[MockContract]:
return [contract]
def placeOrder(self, contract: MockContract, order: Any) -> MockTrade:
self._order_counter += 1
mock_order = MockOrder(
orderId=self._order_counter,
action=order.action if hasattr(order, 'action') else "BUY",
totalQuantity=order.totalQuantity if hasattr(order, 'totalQuantity') else 100,
)
trade = MockTrade(order=mock_order, contract=contract)
self._orders[self._order_counter] = trade
return trade
def cancelOrder(self, order: MockOrder) -> None:
if order.orderId in self._orders:
self._orders[order.orderId].orderStatus.status = "Cancelled"
def reqMktData(self, contract: MockContract, *args) -> MockTicker:
return MockTicker()
def add_position(self, item: MockPortfolioItem) -> None:
"""Helper to add test positions."""
self._portfolio.append(item)
# =============================================================================
# IBKRBroker Tests - Initialization
# =============================================================================
class TestIBKRBrokerInit:
"""Tests for IBKRBroker initialization."""
def test_init_default(self):
"""Test default initialization."""
broker = IBKRBroker()
assert broker.name == "IBKR"
assert broker.is_paper_trading is True
assert broker.host == "127.0.0.1"
assert broker.port == 7497 # Paper trading port
assert AssetClass.FUTURE in broker.supported_asset_classes
assert AssetClass.EQUITY in broker.supported_asset_classes
def test_init_with_config(self):
"""Test initialization with custom config."""
broker = IBKRBroker(
host="192.168.1.100",
port=7496,
client_id=5,
paper_trading=False,
)
assert broker.host == "192.168.1.100"
assert broker.port == 7496
assert broker.client_id == 5
assert broker.is_paper_trading is False
def test_init_live_trading(self):
"""Test initialization for live trading."""
broker = IBKRBroker(paper_trading=False)
assert broker.is_paper_trading is False
assert broker.port == 7496 # Live port
# =============================================================================
# IBKRBroker Tests - Connection
# =============================================================================
class TestIBKRBrokerConnection:
"""Tests for connection management."""
@pytest.mark.asyncio
async def test_connect_without_sdk(self):
"""Test connect fails gracefully without SDK."""
from tradingagents.execution import ibkr_broker
# Save original value
original_available = ibkr_broker.IB_INSYNC_AVAILABLE
try:
# Mock SDK not available
ibkr_broker.IB_INSYNC_AVAILABLE = False
broker = ibkr_broker.IBKRBroker()
with pytest.raises(BrokerError, match="ib_insync is not installed"):
await broker.connect()
finally:
# Restore original value
ibkr_broker.IB_INSYNC_AVAILABLE = original_available
@pytest.mark.asyncio
async def test_disconnect(self):
"""Test disconnect."""
broker = IBKRBroker()
broker._connected = True
await broker.disconnect()
assert broker.is_connected is False
# =============================================================================
# IBKRBroker Tests - With Mocked SDK
# =============================================================================
class TestIBKRBrokerWithMockedSDK:
"""Tests using mocked ib_insync SDK."""
async def _create_connected_broker(self):
"""Create a broker with mocked SDK and connect it."""
broker = IBKRBroker(
host="127.0.0.1",
port=7497,
client_id=1,
paper_trading=True,
)
# Mock the IB connection
broker._ib = MockIB()
broker._ib._connected = True
broker._connected = True
return broker
@pytest.mark.asyncio
async def test_get_account(self):
"""Test getting account info."""
broker = await self._create_connected_broker()
account = await broker.get_account()
assert isinstance(account, AccountInfo)
assert account.account_id == "DU1234567"
assert account.cash == Decimal("100000")
@pytest.mark.asyncio
async def test_is_market_open(self):
"""Test checking market status."""
broker = await self._create_connected_broker()
is_open = await broker.is_market_open()
assert is_open is True
@pytest.mark.asyncio
async def test_get_positions(self):
"""Test getting positions."""
broker = await self._create_connected_broker()
positions = await broker.get_positions()
assert len(positions) >= 1
assert positions[0].symbol == "AAPL"
@pytest.mark.asyncio
async def test_get_position(self):
"""Test getting specific position."""
broker = await self._create_connected_broker()
position = await broker.get_position("AAPL")
assert position is not None
assert position.symbol == "AAPL"
@pytest.mark.asyncio
async def test_get_position_not_found(self):
"""Test getting non-existent position."""
broker = await self._create_connected_broker()
position = await broker.get_position("NONEXISTENT")
assert position is None
@pytest.mark.asyncio
async def test_get_quote(self):
"""Test getting quote."""
from tradingagents.execution import ibkr_broker
if not ibkr_broker.IB_INSYNC_AVAILABLE:
pytest.skip("ib_insync not installed")
broker = await self._create_connected_broker()
quote = await broker.get_quote("AAPL")
assert quote.symbol == "AAPL"
assert quote.bid_price is not None
assert quote.ask_price is not None
# =============================================================================
# IBKRBroker Tests - Order Validation
# =============================================================================
class TestIBKRBrokerOrderValidation:
"""Tests for order validation."""
@pytest.mark.asyncio
async def test_requires_connection(self):
"""Test operations fail without connection."""
broker = IBKRBroker()
with pytest.raises(ConnectionError, match="Not connected"):
await broker.get_account()
# =============================================================================
# IBKRBroker Tests - Contract Creation
# =============================================================================
class TestIBKRBrokerContractCreation:
"""Tests for contract creation."""
def test_create_contract_stock(self):
"""Test creating stock contract."""
from tradingagents.execution import ibkr_broker
if not ibkr_broker.IB_INSYNC_AVAILABLE:
pytest.skip("ib_insync not installed")
broker = IBKRBroker()
contract = broker._create_contract("AAPL")
assert contract is not None
def test_create_contract_futures(self):
"""Test creating futures contract."""
from tradingagents.execution import ibkr_broker
if not ibkr_broker.IB_INSYNC_AVAILABLE:
pytest.skip("ib_insync not installed")
broker = IBKRBroker()
contract = broker._create_contract("ES") # E-mini S&P
assert contract is not None
def test_create_contract_asx(self):
"""Test creating ASX stock contract."""
from tradingagents.execution import ibkr_broker
if not ibkr_broker.IB_INSYNC_AVAILABLE:
pytest.skip("ib_insync not installed")
broker = IBKRBroker()
contract = broker._create_contract("BHP.AX") # BHP on ASX
assert contract is not None
# =============================================================================
# IBKRBroker Tests - Asset Class Support
# =============================================================================
class TestIBKRBrokerAssetClasses:
"""Tests for asset class support."""
def test_supports_equity(self):
"""Test broker supports equity."""
broker = IBKRBroker()
assert broker.supports_asset_class(AssetClass.EQUITY) is True
def test_supports_futures(self):
"""Test broker supports futures."""
broker = IBKRBroker()
assert broker.supports_asset_class(AssetClass.FUTURE) is True
def test_supports_options(self):
"""Test broker supports options."""
broker = IBKRBroker()
assert broker.supports_asset_class(AssetClass.OPTION) is True
def test_supports_forex(self):
"""Test broker supports forex."""
broker = IBKRBroker()
assert broker.supports_asset_class(AssetClass.FOREX) is True
def test_does_not_support_crypto(self):
"""Test broker does not support crypto."""
broker = IBKRBroker()
assert broker.supports_asset_class(AssetClass.CRYPTO) is False
# =============================================================================
# IBKRBroker Tests - Futures Specs
# =============================================================================
class TestFuturesSpecs:
"""Tests for futures specifications."""
def test_es_futures_spec(self):
"""Test E-mini S&P 500 futures spec."""
assert "ES" in FUTURES_SPECS
assert FUTURES_SPECS["ES"]["exchange"] == "CME"
assert FUTURES_SPECS["ES"]["multiplier"] == 50
def test_nq_futures_spec(self):
"""Test E-mini NASDAQ futures spec."""
assert "NQ" in FUTURES_SPECS
assert FUTURES_SPECS["NQ"]["exchange"] == "CME"
assert FUTURES_SPECS["NQ"]["multiplier"] == 20
def test_cl_futures_spec(self):
"""Test Crude Oil futures spec."""
assert "CL" in FUTURES_SPECS
assert FUTURES_SPECS["CL"]["exchange"] == "NYMEX"
assert FUTURES_SPECS["CL"]["multiplier"] == 1000
def test_gc_futures_spec(self):
"""Test Gold futures spec."""
assert "GC" in FUTURES_SPECS
assert FUTURES_SPECS["GC"]["exchange"] == "COMEX"
assert FUTURES_SPECS["GC"]["multiplier"] == 100
# =============================================================================
# IBKRBroker Tests - Status Mapping
# =============================================================================
class TestIBKRBrokerStatusMapping:
"""Tests for status mapping."""
def test_map_submitted_status(self):
"""Test mapping Submitted status."""
broker = IBKRBroker()
status = broker._map_ibkr_status("Submitted")
assert status == OrderStatus.NEW
def test_map_filled_status(self):
"""Test mapping Filled status."""
broker = IBKRBroker()
status = broker._map_ibkr_status("Filled")
assert status == OrderStatus.FILLED
def test_map_cancelled_status(self):
"""Test mapping Cancelled status."""
broker = IBKRBroker()
status = broker._map_ibkr_status("Cancelled")
assert status == OrderStatus.CANCELLED
def test_map_pending_status(self):
"""Test mapping pending statuses."""
broker = IBKRBroker()
# PendingSubmit and PreSubmitted map to PENDING_NEW
assert broker._map_ibkr_status("PendingSubmit") == OrderStatus.PENDING_NEW
assert broker._map_ibkr_status("PreSubmitted") == OrderStatus.PENDING_NEW
# PendingCancel maps to PENDING_CANCEL
assert broker._map_ibkr_status("PendingCancel") == OrderStatus.PENDING_CANCEL
# =============================================================================
# IBKRBroker Tests - Time In Force Mapping
# =============================================================================
class TestIBKRBrokerTIFMapping:
"""Tests for time in force mapping."""
def test_map_day_tif(self):
"""Test mapping DAY time in force."""
broker = IBKRBroker()
tif = broker._map_time_in_force(TimeInForce.DAY)
assert tif == "DAY"
def test_map_gtc_tif(self):
"""Test mapping GTC time in force."""
broker = IBKRBroker()
tif = broker._map_time_in_force(TimeInForce.GTC)
assert tif == "GTC"
def test_map_ioc_tif(self):
"""Test mapping IOC time in force."""
broker = IBKRBroker()
tif = broker._map_time_in_force(TimeInForce.IOC)
assert tif == "IOC"
# =============================================================================
# IBKRBroker Tests - Order Side Mapping
# =============================================================================
class TestIBKRBrokerSideMapping:
"""Tests for order side mapping."""
def test_map_buy_side(self):
"""Test mapping BUY side."""
broker = IBKRBroker()
side = broker._map_order_side(OrderSide.BUY)
assert side == "BUY"
def test_map_sell_side(self):
"""Test mapping SELL side."""
broker = IBKRBroker()
side = broker._map_order_side(OrderSide.SELL)
assert side == "SELL"
# =============================================================================
# IBKRBroker Tests - Router Integration
# =============================================================================
class TestIBKRBrokerRouterIntegration:
"""Tests for IBKRBroker integration with BrokerRouter."""
def test_register_with_router(self):
"""Test registering IBKRBroker with router."""
from tradingagents.execution import IBKRBroker, BrokerRouter
router = BrokerRouter()
broker = IBKRBroker()
router.register(broker)
assert "IBKR" in router.registered_brokers
assert AssetClass.FUTURE in router.supported_asset_classes
assert AssetClass.OPTION in router.supported_asset_classes
def test_route_futures_to_ibkr(self):
"""Test futures routing goes to IBKR."""
from tradingagents.execution import IBKRBroker, BrokerRouter
router = BrokerRouter()
broker = IBKRBroker()
router.register(broker)
# ES is a known futures symbol
routed_broker, decision = router.route("ESZ24")
assert routed_broker.name == "IBKR"
assert decision.asset_class == AssetClass.FUTURE
# =============================================================================
# IBKRBroker Tests - URL Configuration
# =============================================================================
class TestIBKRBrokerPorts:
"""Tests for port configuration."""
def test_paper_port(self):
"""Test paper trading port."""
broker = IBKRBroker(paper_trading=True)
assert broker.port == 7497
def test_live_port(self):
"""Test live trading port."""
broker = IBKRBroker(paper_trading=False)
assert broker.port == 7496
def test_custom_port(self):
"""Test custom port."""
broker = IBKRBroker(port=4002)
assert broker.port == 4002