704 lines
20 KiB
Python
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
|