feat(execution): add IBKR broker for futures and ASX equities - Issue #25 (38 tests)
This commit is contained in:
parent
593d59937c
commit
1e32c0e965
|
|
@ -0,0 +1,703 @@
|
||||||
|
"""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
|
||||||
|
|
@ -106,6 +106,12 @@ from .alpaca_broker import (
|
||||||
ALPACA_AVAILABLE,
|
ALPACA_AVAILABLE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from .ibkr_broker import (
|
||||||
|
IBKRBroker,
|
||||||
|
IB_INSYNC_AVAILABLE,
|
||||||
|
FUTURES_SPECS,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Enums
|
# Enums
|
||||||
"AssetClass",
|
"AssetClass",
|
||||||
|
|
@ -144,4 +150,8 @@ __all__ = [
|
||||||
# Alpaca Broker
|
# Alpaca Broker
|
||||||
"AlpacaBroker",
|
"AlpacaBroker",
|
||||||
"ALPACA_AVAILABLE",
|
"ALPACA_AVAILABLE",
|
||||||
|
# IBKR Broker
|
||||||
|
"IBKRBroker",
|
||||||
|
"IB_INSYNC_AVAILABLE",
|
||||||
|
"FUTURES_SPECS",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,823 @@
|
||||||
|
"""Interactive Brokers (IBKR) Broker implementation.
|
||||||
|
|
||||||
|
Issue #25: [EXEC-24] IBKR broker - futures, ASX equities
|
||||||
|
|
||||||
|
This module provides a concrete implementation of BrokerBase for Interactive
|
||||||
|
Brokers. IBKR supports:
|
||||||
|
- US and international equities (including ASX)
|
||||||
|
- Futures contracts
|
||||||
|
- Options contracts
|
||||||
|
- Forex
|
||||||
|
- Bonds
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
pip install ib_insync
|
||||||
|
|
||||||
|
Configuration:
|
||||||
|
IBKR_HOST: TWS/Gateway host (default: 127.0.0.1)
|
||||||
|
IBKR_PORT: TWS/Gateway port (7497 paper, 7496 live)
|
||||||
|
IBKR_CLIENT_ID: Client ID for connection
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> from tradingagents.execution import IBKRBroker, OrderRequest, OrderSide
|
||||||
|
>>>
|
||||||
|
>>> broker = IBKRBroker(
|
||||||
|
... host="127.0.0.1",
|
||||||
|
... port=7497, # Paper trading port
|
||||||
|
... client_id=1,
|
||||||
|
... )
|
||||||
|
>>>
|
||||||
|
>>> await broker.connect()
|
||||||
|
>>> order = await broker.submit_order(
|
||||||
|
... OrderRequest.market("ES", OrderSide.BUY, 1) # E-mini S&P 500
|
||||||
|
... )
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from .broker_base import (
|
||||||
|
AccountInfo,
|
||||||
|
AssetClass,
|
||||||
|
AssetInfo,
|
||||||
|
AuthenticationError,
|
||||||
|
BrokerBase,
|
||||||
|
BrokerError,
|
||||||
|
ConnectionError,
|
||||||
|
InsufficientFundsError,
|
||||||
|
InvalidOrderError,
|
||||||
|
Order,
|
||||||
|
OrderError,
|
||||||
|
OrderRequest,
|
||||||
|
OrderSide,
|
||||||
|
OrderStatus,
|
||||||
|
OrderType,
|
||||||
|
Position,
|
||||||
|
PositionError,
|
||||||
|
PositionSide,
|
||||||
|
Quote,
|
||||||
|
RateLimitError,
|
||||||
|
TimeInForce,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Try to import ib_insync, provide stubs for testing without it
|
||||||
|
try:
|
||||||
|
from ib_insync import (
|
||||||
|
IB,
|
||||||
|
Contract,
|
||||||
|
Stock,
|
||||||
|
Future,
|
||||||
|
Option,
|
||||||
|
Forex,
|
||||||
|
Index,
|
||||||
|
MarketOrder,
|
||||||
|
LimitOrder,
|
||||||
|
StopOrder,
|
||||||
|
StopLimitOrder,
|
||||||
|
Trade,
|
||||||
|
Position as IBPosition,
|
||||||
|
AccountValue,
|
||||||
|
PortfolioItem,
|
||||||
|
Ticker,
|
||||||
|
)
|
||||||
|
from ib_insync.order import Order as IBOrder
|
||||||
|
|
||||||
|
IB_INSYNC_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
IB_INSYNC_AVAILABLE = False
|
||||||
|
IB = None
|
||||||
|
Contract = None
|
||||||
|
Stock = None
|
||||||
|
Future = None
|
||||||
|
Option = None
|
||||||
|
Forex = None
|
||||||
|
Index = None
|
||||||
|
MarketOrder = None
|
||||||
|
LimitOrder = None
|
||||||
|
StopOrder = None
|
||||||
|
StopLimitOrder = None
|
||||||
|
|
||||||
|
|
||||||
|
# Common futures contract specifications
|
||||||
|
FUTURES_SPECS = {
|
||||||
|
# US Index Futures
|
||||||
|
"ES": {"exchange": "CME", "currency": "USD", "multiplier": 50}, # E-mini S&P 500
|
||||||
|
"NQ": {"exchange": "CME", "currency": "USD", "multiplier": 20}, # E-mini NASDAQ-100
|
||||||
|
"YM": {"exchange": "CBOT", "currency": "USD", "multiplier": 5}, # Mini Dow
|
||||||
|
"RTY": {"exchange": "CME", "currency": "USD", "multiplier": 50}, # E-mini Russell 2000
|
||||||
|
# Commodities
|
||||||
|
"CL": {"exchange": "NYMEX", "currency": "USD", "multiplier": 1000}, # Crude Oil
|
||||||
|
"GC": {"exchange": "COMEX", "currency": "USD", "multiplier": 100}, # Gold
|
||||||
|
"SI": {"exchange": "COMEX", "currency": "USD", "multiplier": 5000}, # Silver
|
||||||
|
"HG": {"exchange": "COMEX", "currency": "USD", "multiplier": 25000}, # Copper
|
||||||
|
# Agricultural
|
||||||
|
"ZC": {"exchange": "CBOT", "currency": "USD", "multiplier": 50}, # Corn
|
||||||
|
"ZS": {"exchange": "CBOT", "currency": "USD", "multiplier": 50}, # Soybeans
|
||||||
|
"ZW": {"exchange": "CBOT", "currency": "USD", "multiplier": 50}, # Wheat
|
||||||
|
# Interest Rates
|
||||||
|
"ZN": {"exchange": "CBOT", "currency": "USD", "multiplier": 1000}, # 10-Year T-Note
|
||||||
|
"ZB": {"exchange": "CBOT", "currency": "USD", "multiplier": 1000}, # 30-Year T-Bond
|
||||||
|
# Currency Futures
|
||||||
|
"6E": {"exchange": "CME", "currency": "USD", "multiplier": 125000}, # Euro FX
|
||||||
|
"6J": {"exchange": "CME", "currency": "USD", "multiplier": 12500000}, # Japanese Yen
|
||||||
|
"6A": {"exchange": "CME", "currency": "USD", "multiplier": 100000}, # Australian Dollar
|
||||||
|
}
|
||||||
|
|
||||||
|
# ASX (Australian) stock exchange
|
||||||
|
ASX_EXCHANGE = "ASX"
|
||||||
|
|
||||||
|
|
||||||
|
class IBKRBroker(BrokerBase):
|
||||||
|
"""Interactive Brokers broker implementation.
|
||||||
|
|
||||||
|
Supports US/international equities, futures, options, forex, and bonds
|
||||||
|
through the Interactive Brokers TWS or Gateway API.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
host: TWS/Gateway host address
|
||||||
|
port: TWS/Gateway port (7497 paper, 7496 live)
|
||||||
|
client_id: Client ID for connection
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> broker = IBKRBroker(
|
||||||
|
... host="127.0.0.1",
|
||||||
|
... port=7497, # Paper trading
|
||||||
|
... client_id=1,
|
||||||
|
... )
|
||||||
|
>>> await broker.connect()
|
||||||
|
>>> positions = await broker.get_positions()
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Default ports
|
||||||
|
PAPER_PORT = 7497
|
||||||
|
LIVE_PORT = 7496
|
||||||
|
GATEWAY_PAPER_PORT = 4002
|
||||||
|
GATEWAY_LIVE_PORT = 4001
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
host: Optional[str] = None,
|
||||||
|
port: Optional[int] = None,
|
||||||
|
client_id: Optional[int] = None,
|
||||||
|
paper_trading: bool = True,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize IBKR broker.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
host: TWS/Gateway host. Default: 127.0.0.1 or IBKR_HOST env.
|
||||||
|
port: TWS/Gateway port. Default: 7497 (paper) or 7496 (live).
|
||||||
|
client_id: Client ID. Default: 1 or IBKR_CLIENT_ID env.
|
||||||
|
paper_trading: If True, use paper trading account.
|
||||||
|
**kwargs: Additional arguments passed to BrokerBase.
|
||||||
|
"""
|
||||||
|
super().__init__(
|
||||||
|
name="IBKR",
|
||||||
|
supported_asset_classes=[
|
||||||
|
AssetClass.EQUITY,
|
||||||
|
AssetClass.ETF,
|
||||||
|
AssetClass.FUTURE,
|
||||||
|
AssetClass.OPTION,
|
||||||
|
AssetClass.FOREX,
|
||||||
|
AssetClass.BOND,
|
||||||
|
],
|
||||||
|
paper_trading=paper_trading,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._host = host or os.environ.get("IBKR_HOST", "127.0.0.1")
|
||||||
|
self._port = port or int(
|
||||||
|
os.environ.get(
|
||||||
|
"IBKR_PORT",
|
||||||
|
str(self.PAPER_PORT if paper_trading else self.LIVE_PORT)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._client_id = client_id or int(os.environ.get("IBKR_CLIENT_ID", "1"))
|
||||||
|
|
||||||
|
self._ib: Optional["IB"] = None
|
||||||
|
self._order_map: Dict[str, Tuple[Trade, Contract]] = {}
|
||||||
|
self._next_order_id = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def host(self) -> str:
|
||||||
|
"""Get host address."""
|
||||||
|
return self._host
|
||||||
|
|
||||||
|
@property
|
||||||
|
def port(self) -> int:
|
||||||
|
"""Get port number."""
|
||||||
|
return self._port
|
||||||
|
|
||||||
|
@property
|
||||||
|
def client_id(self) -> int:
|
||||||
|
"""Get client ID."""
|
||||||
|
return self._client_id
|
||||||
|
|
||||||
|
def _require_connection(self) -> None:
|
||||||
|
"""Require broker to be connected.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConnectionError: If not connected.
|
||||||
|
"""
|
||||||
|
if not self.is_connected:
|
||||||
|
raise ConnectionError("Not connected to IBKR. Call connect() first.")
|
||||||
|
|
||||||
|
def _check_ib_insync_available(self) -> None:
|
||||||
|
"""Check if ib_insync is installed."""
|
||||||
|
if not IB_INSYNC_AVAILABLE:
|
||||||
|
raise BrokerError(
|
||||||
|
"ib_insync is not installed. "
|
||||||
|
"Install it with: pip install ib_insync"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def connect(self) -> bool:
|
||||||
|
"""Connect to TWS/Gateway.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if connection successful.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConnectionError: If connection fails.
|
||||||
|
AuthenticationError: If authentication fails.
|
||||||
|
"""
|
||||||
|
self._check_ib_insync_available()
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._ib = IB()
|
||||||
|
|
||||||
|
# Connect to TWS/Gateway
|
||||||
|
await self._ib.connectAsync(
|
||||||
|
host=self._host,
|
||||||
|
port=self._port,
|
||||||
|
clientId=self._client_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self._ib.isConnected():
|
||||||
|
raise ConnectionError(
|
||||||
|
f"Failed to connect to IBKR at {self._host}:{self._port}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._connected = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = str(e).lower()
|
||||||
|
if "connect" in error_msg or "timeout" in error_msg:
|
||||||
|
raise ConnectionError(
|
||||||
|
f"Failed to connect to IBKR at {self._host}:{self._port}: {e}"
|
||||||
|
)
|
||||||
|
elif "auth" in error_msg or "permission" in error_msg:
|
||||||
|
raise AuthenticationError(f"IBKR authentication failed: {e}")
|
||||||
|
else:
|
||||||
|
raise BrokerError(f"IBKR connection error: {e}")
|
||||||
|
|
||||||
|
async def disconnect(self) -> None:
|
||||||
|
"""Disconnect from TWS/Gateway."""
|
||||||
|
if self._ib:
|
||||||
|
self._ib.disconnect()
|
||||||
|
self._ib = None
|
||||||
|
|
||||||
|
self._connected = False
|
||||||
|
self._order_map.clear()
|
||||||
|
|
||||||
|
async def is_market_open(self) -> bool:
|
||||||
|
"""Check if market is currently open.
|
||||||
|
|
||||||
|
Note: IBKR doesn't have a simple market open check.
|
||||||
|
This returns True if connected (simplified).
|
||||||
|
"""
|
||||||
|
return self.is_connected
|
||||||
|
|
||||||
|
async def get_account(self) -> AccountInfo:
|
||||||
|
"""Get account information.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AccountInfo with current account state.
|
||||||
|
"""
|
||||||
|
self._require_connection()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get account values
|
||||||
|
account_values = self._ib.accountValues()
|
||||||
|
|
||||||
|
# Build dict from account values
|
||||||
|
values = {}
|
||||||
|
for av in account_values:
|
||||||
|
if av.currency in ("USD", "BASE"):
|
||||||
|
values[av.tag] = av.value
|
||||||
|
|
||||||
|
# Get portfolio summary
|
||||||
|
portfolio = self._ib.portfolio()
|
||||||
|
portfolio_value = sum(
|
||||||
|
Decimal(str(item.marketValue or 0)) for item in portfolio
|
||||||
|
)
|
||||||
|
|
||||||
|
return AccountInfo(
|
||||||
|
account_id=self._ib.managedAccounts()[0] if self._ib.managedAccounts() else "UNKNOWN",
|
||||||
|
account_type=values.get("AccountType", "individual"),
|
||||||
|
status="active",
|
||||||
|
cash=Decimal(str(values.get("TotalCashValue", 0))),
|
||||||
|
portfolio_value=portfolio_value,
|
||||||
|
buying_power=Decimal(str(values.get("BuyingPower", 0))),
|
||||||
|
equity=Decimal(str(values.get("NetLiquidation", 0))),
|
||||||
|
margin_used=Decimal(str(values.get("MaintMarginReq", 0))),
|
||||||
|
margin_available=Decimal(str(values.get("AvailableFunds", 0))),
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise BrokerError(f"Failed to get account: {e}")
|
||||||
|
|
||||||
|
def _create_contract(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
asset_class: Optional[AssetClass] = None,
|
||||||
|
exchange: str = "SMART",
|
||||||
|
currency: str = "USD",
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> "Contract":
|
||||||
|
"""Create IBKR contract from symbol.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
symbol: Trading symbol
|
||||||
|
asset_class: Asset class type
|
||||||
|
exchange: Exchange (default: SMART routing)
|
||||||
|
currency: Currency (default: USD)
|
||||||
|
**kwargs: Additional contract parameters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
IBKR Contract object
|
||||||
|
"""
|
||||||
|
# Check if it's a known futures symbol
|
||||||
|
if symbol in FUTURES_SPECS:
|
||||||
|
spec = FUTURES_SPECS[symbol]
|
||||||
|
# Get expiry from kwargs or use front month
|
||||||
|
expiry = kwargs.get("expiry", "")
|
||||||
|
return Future(
|
||||||
|
symbol=symbol,
|
||||||
|
exchange=spec["exchange"],
|
||||||
|
currency=spec["currency"],
|
||||||
|
lastTradeDateOrContractMonth=expiry,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if ASX symbol (Australian)
|
||||||
|
if ".AX" in symbol.upper():
|
||||||
|
symbol_clean = symbol.replace(".AX", "").replace(".ax", "")
|
||||||
|
return Stock(
|
||||||
|
symbol=symbol_clean,
|
||||||
|
exchange=ASX_EXCHANGE,
|
||||||
|
currency="AUD",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check asset class hints
|
||||||
|
if asset_class == AssetClass.FUTURE:
|
||||||
|
return Future(
|
||||||
|
symbol=symbol,
|
||||||
|
exchange=exchange,
|
||||||
|
currency=currency,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
elif asset_class == AssetClass.OPTION:
|
||||||
|
return Option(
|
||||||
|
symbol=symbol,
|
||||||
|
exchange=exchange,
|
||||||
|
currency=currency,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
elif asset_class == AssetClass.FOREX:
|
||||||
|
return Forex(pair=symbol)
|
||||||
|
|
||||||
|
# Default to stock
|
||||||
|
return Stock(
|
||||||
|
symbol=symbol,
|
||||||
|
exchange=exchange,
|
||||||
|
currency=currency,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _map_order_side(self, side: OrderSide) -> str:
|
||||||
|
"""Map internal order side to IBKR action."""
|
||||||
|
return "BUY" if side == OrderSide.BUY else "SELL"
|
||||||
|
|
||||||
|
def _map_time_in_force(self, tif: TimeInForce) -> str:
|
||||||
|
"""Map internal time in force to IBKR tif."""
|
||||||
|
mapping = {
|
||||||
|
TimeInForce.DAY: "DAY",
|
||||||
|
TimeInForce.GTC: "GTC",
|
||||||
|
TimeInForce.IOC: "IOC",
|
||||||
|
TimeInForce.FOK: "FOK",
|
||||||
|
TimeInForce.OPG: "OPG",
|
||||||
|
TimeInForce.CLS: "CLS",
|
||||||
|
}
|
||||||
|
return mapping.get(tif, "DAY")
|
||||||
|
|
||||||
|
def _map_ibkr_status(self, status: str) -> OrderStatus:
|
||||||
|
"""Map IBKR order status to internal status."""
|
||||||
|
mapping = {
|
||||||
|
"PendingSubmit": OrderStatus.PENDING_NEW,
|
||||||
|
"PendingCancel": OrderStatus.PENDING_CANCEL,
|
||||||
|
"PreSubmitted": OrderStatus.PENDING_NEW,
|
||||||
|
"Submitted": OrderStatus.NEW,
|
||||||
|
"Filled": OrderStatus.FILLED,
|
||||||
|
"Cancelled": OrderStatus.CANCELLED,
|
||||||
|
"Inactive": OrderStatus.CANCELLED,
|
||||||
|
"ApiPending": OrderStatus.PENDING_NEW,
|
||||||
|
"ApiCancelled": OrderStatus.CANCELLED,
|
||||||
|
}
|
||||||
|
return mapping.get(status, OrderStatus.NEW)
|
||||||
|
|
||||||
|
def _convert_trade_to_order(self, trade: "Trade", contract: "Contract") -> Order:
|
||||||
|
"""Convert IBKR Trade to internal Order."""
|
||||||
|
ib_order = trade.order
|
||||||
|
order_status = trade.orderStatus
|
||||||
|
|
||||||
|
return Order(
|
||||||
|
broker_order_id=str(ib_order.orderId),
|
||||||
|
client_order_id=str(ib_order.orderId),
|
||||||
|
symbol=contract.symbol,
|
||||||
|
side=OrderSide.BUY if ib_order.action == "BUY" else OrderSide.SELL,
|
||||||
|
quantity=Decimal(str(abs(ib_order.totalQuantity))),
|
||||||
|
order_type=OrderType.MARKET if isinstance(ib_order, MarketOrder) else OrderType.LIMIT,
|
||||||
|
status=self._map_ibkr_status(order_status.status),
|
||||||
|
limit_price=Decimal(str(ib_order.lmtPrice)) if hasattr(ib_order, 'lmtPrice') and ib_order.lmtPrice else None,
|
||||||
|
stop_price=Decimal(str(ib_order.auxPrice)) if hasattr(ib_order, 'auxPrice') and ib_order.auxPrice else None,
|
||||||
|
time_in_force=TimeInForce.DAY,
|
||||||
|
filled_quantity=Decimal(str(order_status.filled or 0)),
|
||||||
|
avg_fill_price=Decimal(str(order_status.avgFillPrice)) if order_status.avgFillPrice else None,
|
||||||
|
created_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def submit_order(self, request: OrderRequest) -> Order:
|
||||||
|
"""Submit an order to IBKR.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Order request details.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Order with broker order ID.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
InvalidOrderError: If order parameters are invalid.
|
||||||
|
InsufficientFundsError: If insufficient buying power.
|
||||||
|
OrderError: If order submission fails.
|
||||||
|
"""
|
||||||
|
self._require_connection()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create contract
|
||||||
|
contract = self._create_contract(
|
||||||
|
symbol=request.symbol,
|
||||||
|
asset_class=request.asset_class,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Qualify contract
|
||||||
|
qualified = await self._ib.qualifyContractsAsync(contract)
|
||||||
|
if not qualified:
|
||||||
|
raise InvalidOrderError(
|
||||||
|
f"Failed to qualify contract for {request.symbol}"
|
||||||
|
)
|
||||||
|
contract = qualified[0]
|
||||||
|
|
||||||
|
# Create order based on type
|
||||||
|
action = self._map_order_side(request.side)
|
||||||
|
quantity = float(request.quantity)
|
||||||
|
tif = self._map_time_in_force(request.time_in_force)
|
||||||
|
|
||||||
|
if request.order_type == OrderType.MARKET:
|
||||||
|
ib_order = MarketOrder(action=action, totalQuantity=quantity, tif=tif)
|
||||||
|
|
||||||
|
elif request.order_type == OrderType.LIMIT:
|
||||||
|
if request.limit_price is None:
|
||||||
|
raise InvalidOrderError("Limit price required for limit orders")
|
||||||
|
ib_order = LimitOrder(
|
||||||
|
action=action,
|
||||||
|
totalQuantity=quantity,
|
||||||
|
lmtPrice=float(request.limit_price),
|
||||||
|
tif=tif,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif request.order_type == OrderType.STOP:
|
||||||
|
if request.stop_price is None:
|
||||||
|
raise InvalidOrderError("Stop price required for stop orders")
|
||||||
|
ib_order = StopOrder(
|
||||||
|
action=action,
|
||||||
|
totalQuantity=quantity,
|
||||||
|
stopPrice=float(request.stop_price),
|
||||||
|
tif=tif,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif request.order_type == OrderType.STOP_LIMIT:
|
||||||
|
if request.stop_price is None or request.limit_price is None:
|
||||||
|
raise InvalidOrderError(
|
||||||
|
"Stop and limit prices required for stop-limit orders"
|
||||||
|
)
|
||||||
|
ib_order = StopLimitOrder(
|
||||||
|
action=action,
|
||||||
|
totalQuantity=quantity,
|
||||||
|
stopPrice=float(request.stop_price),
|
||||||
|
lmtPrice=float(request.limit_price),
|
||||||
|
tif=tif,
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise InvalidOrderError(f"Unsupported order type: {request.order_type}")
|
||||||
|
|
||||||
|
# Submit order
|
||||||
|
trade = self._ib.placeOrder(contract, ib_order)
|
||||||
|
|
||||||
|
# Wait for order to be acknowledged
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
# Store mapping
|
||||||
|
self._order_map[str(trade.order.orderId)] = (trade, contract)
|
||||||
|
|
||||||
|
return self._convert_trade_to_order(trade, contract)
|
||||||
|
|
||||||
|
except InvalidOrderError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = str(e).lower()
|
||||||
|
if "margin" in error_msg or "buying power" in error_msg:
|
||||||
|
raise InsufficientFundsError(f"Insufficient funds: {e}")
|
||||||
|
elif "invalid" in error_msg:
|
||||||
|
raise InvalidOrderError(f"Invalid order: {e}")
|
||||||
|
else:
|
||||||
|
raise OrderError(f"Failed to submit order: {e}")
|
||||||
|
|
||||||
|
async def cancel_order(self, order_id: str) -> Order:
|
||||||
|
"""Cancel an order.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
order_id: Broker order ID to cancel.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated order with cancelled status.
|
||||||
|
"""
|
||||||
|
self._require_connection()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if order_id not in self._order_map:
|
||||||
|
raise OrderError(f"Order {order_id} not found")
|
||||||
|
|
||||||
|
trade, contract = self._order_map[order_id]
|
||||||
|
self._ib.cancelOrder(trade.order)
|
||||||
|
|
||||||
|
# Wait for cancellation
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
return self._convert_trade_to_order(trade, contract)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise OrderError(f"Failed to cancel order {order_id}: {e}")
|
||||||
|
|
||||||
|
async def replace_order(
|
||||||
|
self,
|
||||||
|
order_id: str,
|
||||||
|
quantity: Optional[Decimal] = None,
|
||||||
|
limit_price: Optional[Decimal] = None,
|
||||||
|
stop_price: Optional[Decimal] = None,
|
||||||
|
time_in_force: Optional[TimeInForce] = None,
|
||||||
|
) -> Order:
|
||||||
|
"""Replace/modify an existing order.
|
||||||
|
|
||||||
|
Note: IBKR modifies orders in place rather than creating new ones.
|
||||||
|
"""
|
||||||
|
self._require_connection()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if order_id not in self._order_map:
|
||||||
|
raise OrderError(f"Order {order_id} not found")
|
||||||
|
|
||||||
|
trade, contract = self._order_map[order_id]
|
||||||
|
ib_order = trade.order
|
||||||
|
|
||||||
|
# Modify order fields
|
||||||
|
if quantity is not None:
|
||||||
|
ib_order.totalQuantity = float(quantity)
|
||||||
|
if limit_price is not None and hasattr(ib_order, 'lmtPrice'):
|
||||||
|
ib_order.lmtPrice = float(limit_price)
|
||||||
|
if stop_price is not None and hasattr(ib_order, 'auxPrice'):
|
||||||
|
ib_order.auxPrice = float(stop_price)
|
||||||
|
if time_in_force is not None:
|
||||||
|
ib_order.tif = self._map_time_in_force(time_in_force)
|
||||||
|
|
||||||
|
# Submit modified order
|
||||||
|
trade = self._ib.placeOrder(contract, ib_order)
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
return self._convert_trade_to_order(trade, contract)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise OrderError(f"Failed to replace order {order_id}: {e}")
|
||||||
|
|
||||||
|
async def get_order(self, order_id: str) -> Order:
|
||||||
|
"""Get order by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
order_id: Broker order ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Order details.
|
||||||
|
"""
|
||||||
|
self._require_connection()
|
||||||
|
|
||||||
|
if order_id not in self._order_map:
|
||||||
|
raise OrderError(f"Order {order_id} not found")
|
||||||
|
|
||||||
|
trade, contract = self._order_map[order_id]
|
||||||
|
return self._convert_trade_to_order(trade, contract)
|
||||||
|
|
||||||
|
async def get_orders(
|
||||||
|
self,
|
||||||
|
status: Optional[OrderStatus] = None,
|
||||||
|
limit: int = 100,
|
||||||
|
symbols: Optional[List[str]] = None,
|
||||||
|
) -> List[Order]:
|
||||||
|
"""Get orders with optional filters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
status: Filter by order status.
|
||||||
|
limit: Maximum number of orders.
|
||||||
|
symbols: Filter by symbols.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of orders.
|
||||||
|
"""
|
||||||
|
self._require_connection()
|
||||||
|
|
||||||
|
try:
|
||||||
|
orders = []
|
||||||
|
for order_id, (trade, contract) in self._order_map.items():
|
||||||
|
order = self._convert_trade_to_order(trade, contract)
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
if status and order.status != status:
|
||||||
|
continue
|
||||||
|
if symbols and order.symbol not in symbols:
|
||||||
|
continue
|
||||||
|
|
||||||
|
orders.append(order)
|
||||||
|
|
||||||
|
if len(orders) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
return orders
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise BrokerError(f"Failed to get orders: {e}")
|
||||||
|
|
||||||
|
async def get_positions(self) -> List[Position]:
|
||||||
|
"""Get all positions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of current positions.
|
||||||
|
"""
|
||||||
|
self._require_connection()
|
||||||
|
|
||||||
|
try:
|
||||||
|
portfolio = self._ib.portfolio()
|
||||||
|
|
||||||
|
positions = []
|
||||||
|
for item in portfolio:
|
||||||
|
if item.position == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Determine asset class
|
||||||
|
contract = item.contract
|
||||||
|
if hasattr(contract, 'secType'):
|
||||||
|
if contract.secType == "FUT":
|
||||||
|
asset_class = AssetClass.FUTURE
|
||||||
|
elif contract.secType == "OPT":
|
||||||
|
asset_class = AssetClass.OPTION
|
||||||
|
elif contract.secType == "CASH":
|
||||||
|
asset_class = AssetClass.FOREX
|
||||||
|
else:
|
||||||
|
asset_class = AssetClass.EQUITY
|
||||||
|
else:
|
||||||
|
asset_class = AssetClass.EQUITY
|
||||||
|
|
||||||
|
position = Position(
|
||||||
|
symbol=contract.symbol,
|
||||||
|
quantity=Decimal(str(abs(item.position))),
|
||||||
|
side=PositionSide.LONG if item.position > 0 else PositionSide.SHORT,
|
||||||
|
avg_entry_price=Decimal(str(item.averageCost or 0)),
|
||||||
|
current_price=Decimal(str(item.marketPrice or 0)),
|
||||||
|
market_value=Decimal(str(item.marketValue or 0)),
|
||||||
|
cost_basis=Decimal(str(abs(item.position * (item.averageCost or 0)))),
|
||||||
|
unrealized_pnl=Decimal(str(item.unrealizedPNL or 0)),
|
||||||
|
unrealized_pnl_percent=Decimal("0"), # Would need to calculate
|
||||||
|
asset_class=asset_class,
|
||||||
|
)
|
||||||
|
positions.append(position)
|
||||||
|
|
||||||
|
return positions
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise PositionError(f"Failed to get positions: {e}")
|
||||||
|
|
||||||
|
async def get_position(self, symbol: str) -> Optional[Position]:
|
||||||
|
"""Get position for a specific symbol.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
symbol: Symbol to get position for.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Position if exists, None otherwise.
|
||||||
|
"""
|
||||||
|
positions = await self.get_positions()
|
||||||
|
for position in positions:
|
||||||
|
if position.symbol == symbol:
|
||||||
|
return position
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_quote(self, symbol: str) -> Quote:
|
||||||
|
"""Get current quote for a symbol.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
symbol: Symbol to get quote for.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Current quote data.
|
||||||
|
"""
|
||||||
|
self._require_connection()
|
||||||
|
|
||||||
|
try:
|
||||||
|
contract = self._create_contract(symbol)
|
||||||
|
|
||||||
|
# Qualify contract
|
||||||
|
qualified = await self._ib.qualifyContractsAsync(contract)
|
||||||
|
if not qualified:
|
||||||
|
raise BrokerError(f"Failed to qualify contract for {symbol}")
|
||||||
|
contract = qualified[0]
|
||||||
|
|
||||||
|
# Request market data
|
||||||
|
ticker = self._ib.reqMktData(contract, "", False, False)
|
||||||
|
await asyncio.sleep(1) # Wait for data
|
||||||
|
|
||||||
|
return Quote(
|
||||||
|
symbol=symbol,
|
||||||
|
bid_price=Decimal(str(ticker.bid)) if ticker.bid else None,
|
||||||
|
ask_price=Decimal(str(ticker.ask)) if ticker.ask else None,
|
||||||
|
last_price=Decimal(str(ticker.last)) if ticker.last else None,
|
||||||
|
bid_size=ticker.bidSize,
|
||||||
|
ask_size=ticker.askSize,
|
||||||
|
volume=ticker.volume,
|
||||||
|
timestamp=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise BrokerError(f"Failed to get quote for {symbol}: {e}")
|
||||||
|
|
||||||
|
async def get_asset(self, symbol: str) -> AssetInfo:
|
||||||
|
"""Get asset information.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
symbol: Symbol to get info for.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Asset information.
|
||||||
|
"""
|
||||||
|
self._require_connection()
|
||||||
|
|
||||||
|
try:
|
||||||
|
contract = self._create_contract(symbol)
|
||||||
|
|
||||||
|
# Qualify to get full details
|
||||||
|
qualified = await self._ib.qualifyContractsAsync(contract)
|
||||||
|
if not qualified:
|
||||||
|
raise BrokerError(f"Failed to qualify contract for {symbol}")
|
||||||
|
contract = qualified[0]
|
||||||
|
|
||||||
|
# Determine asset class
|
||||||
|
sec_type = getattr(contract, 'secType', 'STK')
|
||||||
|
if sec_type == "FUT":
|
||||||
|
asset_class = AssetClass.FUTURE
|
||||||
|
elif sec_type == "OPT":
|
||||||
|
asset_class = AssetClass.OPTION
|
||||||
|
elif sec_type == "CASH":
|
||||||
|
asset_class = AssetClass.FOREX
|
||||||
|
elif sec_type == "ETF":
|
||||||
|
asset_class = AssetClass.ETF
|
||||||
|
else:
|
||||||
|
asset_class = AssetClass.EQUITY
|
||||||
|
|
||||||
|
return AssetInfo(
|
||||||
|
symbol=symbol,
|
||||||
|
name=getattr(contract, 'localSymbol', symbol),
|
||||||
|
asset_class=asset_class,
|
||||||
|
exchange=contract.exchange,
|
||||||
|
tradable=True,
|
||||||
|
shortable=True, # Would need to check
|
||||||
|
marginable=True, # Would need to check
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise BrokerError(f"Failed to get asset info for {symbol}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# Export
|
||||||
|
__all__ = ["IBKRBroker", "IB_INSYNC_AVAILABLE", "FUTURES_SPECS"]
|
||||||
Loading…
Reference in New Issue