diff --git a/pyproject.toml b/pyproject.toml index 165575e..2893b47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ [project.optional-dependencies] arctic = ["arcticdb>=6.0"] ccxt = ["ccxt>=4.0"] +ibkr = ["ib_async>=1.0"] [dependency-groups] dev = [ @@ -29,6 +30,7 @@ dev = [ "ruff>=0.8", "mypy>=1.13", "ccxt>=4.0", + "ib_async>=1.0", ] [build-system] diff --git a/src/sysls/execution/venues/ibkr.py b/src/sysls/execution/venues/ibkr.py new file mode 100644 index 0000000..66177f6 --- /dev/null +++ b/src/sysls/execution/venues/ibkr.py @@ -0,0 +1,536 @@ +"""Interactive Brokers venue adapter via ib_async. + +Wraps the ib_async library (successor to ib_insync) to provide connectivity +to Interactive Brokers through the VenueAdapter interface. Most ib_async calls +are synchronous and are offloaded to a thread via asyncio.to_thread() to avoid +blocking the event loop. +""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Any + +import structlog + +from sysls.core.events import OrderAccepted, OrderCancelled +from sysls.core.exceptions import ConnectionError as SyslsConnectionError +from sysls.core.exceptions import OrderError, VenueError +from sysls.core.types import ( + AssetClass, + Instrument, + OrderStatus, + OrderType, + Side, + Venue, +) +from sysls.execution.venues.base import VenueAdapter + +if TYPE_CHECKING: + from decimal import Decimal + + from ib_async import IB + + from sysls.core.bus import EventBus + from sysls.core.types import OrderRequest + +# Mapping from IB order status strings to sysls OrderStatus. +_IB_STATUS_MAP: dict[str, OrderStatus] = { + "PendingSubmit": OrderStatus.SUBMITTED, + "PendingCancel": OrderStatus.ACCEPTED, + "PreSubmitted": OrderStatus.ACCEPTED, + "Submitted": OrderStatus.ACCEPTED, + "Filled": OrderStatus.FILLED, + "Cancelled": OrderStatus.CANCELLED, + "Inactive": OrderStatus.REJECTED, + "ApiPending": OrderStatus.PENDING, + "ApiCancelled": OrderStatus.CANCELLED, +} + +logger = structlog.get_logger(__name__) + + +class IbkrAdapter(VenueAdapter): + """Venue adapter for Interactive Brokers via the ib_async library. + + Provides a thin translation layer between sysls's normalized order types + and the Interactive Brokers TWS/Gateway API. Supports equities, options, + futures, and forex. + + All ib_async calls that interact with TWS/Gateway are offloaded to a + thread via ``asyncio.to_thread()`` to avoid blocking the event loop. + + Args: + bus: EventBus for emitting order lifecycle events. + host: TWS/Gateway host address. + port: TWS/Gateway port (7497 for paper, 7496 for live). + client_id: Unique client ID for this connection. + account: Optional account identifier for multi-account setups. + """ + + def __init__( + self, + bus: EventBus, + host: str = "127.0.0.1", + port: int = 7497, + client_id: int = 1, + account: str | None = None, + ) -> None: + self._bus = bus + self._host = host + self._port = port + self._client_id = client_id + self._account = account + self._ib: IB | None = None + self._logger = logger.bind(venue="ibkr") + + # -- Lifecycle --------------------------------------------------------- + + async def connect(self) -> None: + """Connect to TWS/Gateway via ib_async. + + Raises: + SyslsConnectionError: If ib_async is not installed or + connection to TWS/Gateway fails. + """ + try: + from ib_async import IB + except ImportError as exc: + raise SyslsConnectionError( + "ib_async is not installed. Install it with: pip install 'sysls[ibkr]'", + venue=self.name, + ) from exc + + ib = IB() + try: + await ib.connectAsync( + self._host, + self._port, + clientId=self._client_id, + account=self._account or "", + ) + except Exception as exc: + raise SyslsConnectionError( + f"Failed to connect to TWS/Gateway at {self._host}:{self._port}: {exc}", + venue=self.name, + ) from exc + + self._ib = ib + self._logger.info( + "ibkr_connected", + host=self._host, + port=self._port, + client_id=self._client_id, + ) + + async def disconnect(self) -> None: + """Disconnect from TWS/Gateway. + + Safe to call multiple times. + """ + if self._ib is not None: + self._ib.disconnect() + self._ib = None + self._logger.info("ibkr_disconnected") + + # -- Properties -------------------------------------------------------- + + @property + def name(self) -> str: + """Human-readable venue name.""" + return "ibkr" + + @property + def is_connected(self) -> bool: + """Whether the adapter has an active connection to TWS/Gateway.""" + if self._ib is None: + return False + return bool(self._ib.isConnected()) + + @property + def supported_order_types(self) -> list[OrderType]: + """Order types supported by Interactive Brokers.""" + return [OrderType.MARKET, OrderType.LIMIT, OrderType.STOP, OrderType.STOP_LIMIT] + + # -- Order operations -------------------------------------------------- + + async def submit_order(self, order: OrderRequest) -> str: + """Submit an order to Interactive Brokers. + + Args: + order: Normalized order request. + + Returns: + Venue-assigned order ID. + + Raises: + OrderError: If the order cannot be submitted. + VenueError: If there is a connectivity issue. + """ + ib = self._require_ib() + contract = _to_ib_contract(order.instrument) + ib_order = _to_ib_order(order) + + self._logger.info( + "ibkr_order_submitting", + symbol=order.instrument.symbol, + side=order.side.value, + order_type=order.order_type.value, + quantity=str(order.quantity), + ) + + try: + trade = await asyncio.to_thread(ib.placeOrder, contract, ib_order) + except Exception as exc: + self._wrap_ib_error(exc, context=f"submit_order for {order.instrument.symbol}") + + venue_order_id = str(trade.order.orderId) + + self._logger.info( + "ibkr_order_submitted", + order_id=order.order_id, + venue_order_id=venue_order_id, + ) + + await self._bus.publish( + OrderAccepted( + order_id=order.order_id, + instrument=order.instrument, + venue_order_id=venue_order_id, + source=self.name, + ) + ) + + return venue_order_id + + async def cancel_order(self, venue_order_id: str, instrument: Instrument) -> None: + """Cancel an order on Interactive Brokers. + + Args: + venue_order_id: The venue's order identifier. + instrument: The instrument (needed for the cancel lookup). + + Raises: + OrderError: If the order cannot be cancelled. + """ + ib = self._require_ib() + order_id = int(venue_order_id) + + # Find the trade by order ID + trade = None + for t in ib.openTrades(): + if t.order.orderId == order_id: + trade = t + break + + if trade is None: + raise OrderError( + f"Order {venue_order_id} not found in open trades", + venue=self.name, + ) + + try: + await asyncio.to_thread(ib.cancelOrder, trade.order) + except Exception as exc: + self._wrap_ib_error(exc, context=f"cancel_order {venue_order_id}") + + self._logger.info( + "ibkr_order_cancelled", + venue_order_id=venue_order_id, + ) + + await self._bus.publish( + OrderCancelled( + order_id=venue_order_id, + instrument=instrument, + reason="Cancelled via IBKR", + source=self.name, + ) + ) + + async def get_order_status(self, venue_order_id: str, instrument: Instrument) -> OrderStatus: + """Query current status of an order at Interactive Brokers. + + Args: + venue_order_id: The venue's order identifier. + instrument: The instrument. + + Returns: + Current order status. + """ + ib = self._require_ib() + order_id = int(venue_order_id) + + for trade in ib.trades(): + if trade.order.orderId == order_id: + return _map_ib_status(trade.orderStatus.status) + + return OrderStatus.PENDING + + # -- Position / balance queries ---------------------------------------- + + async def get_positions(self) -> dict[Instrument, Decimal]: + """Get all current positions from Interactive Brokers. + + Returns: + Mapping from instrument to net quantity + (positive=long, negative=short). + + Raises: + VenueError: If positions cannot be fetched. + """ + from decimal import Decimal as Dec + + ib = self._require_ib() + + try: + raw_positions = await asyncio.to_thread(ib.positions) + except Exception as exc: + self._wrap_ib_error(exc, context="get_positions") + + positions: dict[Instrument, Dec] = {} + for pos in raw_positions: + quantity = Dec(str(pos.position)) + if quantity == Dec("0"): + continue + + instrument = _build_instrument_from_contract(pos.contract) + positions[instrument] = quantity + + return positions + + async def get_balances(self) -> dict[str, Decimal]: + """Get account balances from Interactive Brokers. + + Returns: + Mapping from currency code to available balance. + + Raises: + VenueError: If balances cannot be fetched. + """ + from decimal import Decimal as Dec + + ib = self._require_ib() + + try: + account_values = await asyncio.to_thread(ib.accountValues) + except Exception as exc: + self._wrap_ib_error(exc, context="get_balances") + + balances: dict[str, Dec] = {} + for av in account_values: + if av.tag == "CashBalance" and av.currency and av.currency != "BASE": + try: + amount = Dec(av.value) + except Exception: + continue + if amount != Dec("0"): + balances[av.currency] = amount + + return balances + + # -- Private helpers --------------------------------------------------- + + def _require_ib(self) -> IB: + """Return the IB instance or raise if not connected. + + Returns: + The ib_async IB instance. + + Raises: + VenueError: If not connected. + """ + if self._ib is None or not self._ib.isConnected(): + raise VenueError( + "Not connected. Call connect() first.", + venue=self.name, + ) + return self._ib + + def _wrap_ib_error(self, exc: Exception, context: str) -> None: + """Wrap an IB exception in a sysls exception and re-raise. + + Args: + exc: The exception to wrap. + context: Description of the operation that failed. + + Raises: + OrderError: For order-related errors. + SyslsConnectionError: For connection-related errors. + VenueError: For other errors. + """ + self._logger.error( + "ibkr_error", + context=context, + error_type=type(exc).__name__, + error=str(exc), + ) + + if isinstance(exc, (ConnectionError, OSError, TimeoutError)): + raise SyslsConnectionError( + f"{context}: {exc}", + venue=self.name, + ) from exc + + if isinstance(exc, ValueError): + raise OrderError( + f"{context}: {exc}", + venue=self.name, + ) from exc + + raise VenueError( + f"{context}: {exc}", + venue=self.name, + ) from exc + + +def _to_ib_contract(instrument: Instrument) -> Any: + """Map a sysls Instrument to an ib_async Contract. + + Args: + instrument: The sysls instrument. + + Returns: + An ib_async Contract subclass (Stock, Option, Future, Forex). + + Raises: + OrderError: If the asset class is not supported. + """ + from ib_async import Forex, Future, Option, Stock + + exchange = instrument.exchange or "SMART" + currency = instrument.currency + + if instrument.asset_class == AssetClass.EQUITY: + return Stock(instrument.symbol, exchange, currency) + + if instrument.asset_class == AssetClass.OPTION: + # Symbol format expected: "AAPL 20240315 150 C" or similar + # Parse option details from symbol metadata + parts = instrument.symbol.split() + if len(parts) >= 4: + underlying = parts[0] + expiry = parts[1] + strike = float(parts[2]) + right = parts[3] # "C" or "P" + return Option(underlying, expiry, strike, right, exchange, currency) + # Fallback: treat as plain symbol + return Option(instrument.symbol, exchange=exchange, currency=currency) + + if instrument.asset_class == AssetClass.FUTURE: + return Future(instrument.symbol, exchange=exchange, currency=currency) + + if instrument.asset_class == AssetClass.CRYPTO_SPOT: + return Forex( + symbol=instrument.symbol, + currency=currency, + exchange=exchange if exchange != "SMART" else "IDEALPRO", + ) + + raise OrderError( + f"Unsupported asset class for IBKR: {instrument.asset_class}", + venue="ibkr", + ) + + +def _to_ib_order(request: OrderRequest) -> Any: + """Map a sysls OrderRequest to an ib_async Order. + + Args: + request: The sysls order request. + + Returns: + An ib_async Order subclass (MarketOrder, LimitOrder, etc.). + + Raises: + OrderError: If the order type is not supported. + """ + from ib_async import LimitOrder, MarketOrder, StopLimitOrder, StopOrder + + action = "BUY" if request.side == Side.BUY else "SELL" + qty = float(request.quantity) + + if request.order_type == OrderType.MARKET: + return MarketOrder(action, qty) + + if request.order_type == OrderType.LIMIT: + if request.price is None: + raise OrderError( + "Limit order requires a price", + venue="ibkr", + ) + return LimitOrder(action, qty, float(request.price)) + + if request.order_type == OrderType.STOP: + if request.stop_price is None: + raise OrderError( + "Stop order requires a stop_price", + venue="ibkr", + ) + return StopOrder(action, qty, float(request.stop_price)) + + if request.order_type == OrderType.STOP_LIMIT: + if request.price is None or request.stop_price is None: + raise OrderError( + "Stop-limit order requires both price and stop_price", + venue="ibkr", + ) + return StopLimitOrder(action, qty, float(request.price), float(request.stop_price)) + + raise OrderError( + f"Unsupported order type for IBKR: {request.order_type}", + venue="ibkr", + ) + + +def _map_ib_status(ib_status: str) -> OrderStatus: + """Map an IB order status string to sysls OrderStatus. + + Args: + ib_status: Status string from ib_async (e.g. ``"Submitted"``, + ``"Filled"``). + + Returns: + Corresponding sysls OrderStatus. + """ + return _IB_STATUS_MAP.get(ib_status, OrderStatus.PENDING) + + +# Mapping from IB secType to sysls AssetClass. +_SEC_TYPE_MAP: dict[str, AssetClass] = { + "STK": AssetClass.EQUITY, + "OPT": AssetClass.OPTION, + "FUT": AssetClass.FUTURE, + "CASH": AssetClass.CRYPTO_SPOT, +} + + +def _build_instrument_from_contract(contract: Any) -> Instrument: + """Build a sysls Instrument from an ib_async Contract. + + Args: + contract: An ib_async Contract (from a Position object). + + Returns: + A sysls Instrument. + """ + from decimal import Decimal as Dec + + sec_type = getattr(contract, "secType", "STK") + asset_class = _SEC_TYPE_MAP.get(sec_type, AssetClass.EQUITY) + + symbol = getattr(contract, "symbol", "") + exchange = getattr(contract, "exchange", None) or "SMART" + currency = getattr(contract, "currency", "USD") + + multiplier_str = getattr(contract, "multiplier", "") + multiplier = Dec(multiplier_str) if multiplier_str else Dec("1") + + return Instrument( + symbol=symbol, + asset_class=asset_class, + venue=Venue.IBKR, + exchange=exchange, + currency=currency, + multiplier=multiplier, + ) diff --git a/tests/execution/test_ibkr.py b/tests/execution/test_ibkr.py new file mode 100644 index 0000000..aa5f82f --- /dev/null +++ b/tests/execution/test_ibkr.py @@ -0,0 +1,1029 @@ +"""Tests for the IbkrAdapter venue adapter. + +All tests use mocked ib_async -- no real TWS/Gateway connection is made. +""" + +from __future__ import annotations + +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from sysls.core.bus import EventBus +from sysls.core.events import OrderAccepted, OrderCancelled +from sysls.core.exceptions import ConnectionError as SyslsConnectionError +from sysls.core.exceptions import OrderError, VenueError +from sysls.core.types import ( + AssetClass, + Instrument, + OrderRequest, + OrderStatus, + OrderType, + Side, + TimeInForce, + Venue, +) +from sysls.execution.venues.ibkr import ( + IbkrAdapter, + _build_instrument_from_contract, + _map_ib_status, + _to_ib_contract, + _to_ib_order, +) + +# --------------------------------------------------------------------------- +# Test helpers +# --------------------------------------------------------------------------- + + +def _make_equity_instrument( + symbol: str = "AAPL", + currency: str = "USD", +) -> Instrument: + """Create a test equity instrument.""" + return Instrument( + symbol=symbol, + asset_class=AssetClass.EQUITY, + venue=Venue.IBKR, + currency=currency, + ) + + +def _make_order( + instrument: Instrument | None = None, + side: Side = Side.BUY, + order_type: OrderType = OrderType.MARKET, + quantity: Decimal = Decimal("100"), + price: Decimal | None = None, + stop_price: Decimal | None = None, +) -> OrderRequest: + """Create a test order request.""" + return OrderRequest( + instrument=instrument or _make_equity_instrument(), + side=side, + order_type=order_type, + quantity=quantity, + price=price, + stop_price=stop_price, + time_in_force=TimeInForce.GTC, + ) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def event_bus() -> EventBus: + """Create a fresh EventBus.""" + return EventBus() + + +# --------------------------------------------------------------------------- +# Properties and basic instantiation +# --------------------------------------------------------------------------- + + +def test_name_property(event_bus: EventBus) -> None: + """name should return 'ibkr'.""" + adapter = IbkrAdapter(bus=event_bus) + assert adapter.name == "ibkr" + + +def test_is_connected_false_when_not_connected(event_bus: EventBus) -> None: + """is_connected should be False before connect().""" + adapter = IbkrAdapter(bus=event_bus) + assert not adapter.is_connected + + +def test_supported_order_types(event_bus: EventBus) -> None: + """supported_order_types should include MARKET, LIMIT, STOP, STOP_LIMIT.""" + adapter = IbkrAdapter(bus=event_bus) + types = adapter.supported_order_types + assert OrderType.MARKET in types + assert OrderType.LIMIT in types + assert OrderType.STOP in types + assert OrderType.STOP_LIMIT in types + + +def test_require_ib_raises_when_not_connected(event_bus: EventBus) -> None: + """_require_ib should raise VenueError when not connected.""" + adapter = IbkrAdapter(bus=event_bus) + with pytest.raises(VenueError, match="Not connected"): + adapter._require_ib() + + +# --------------------------------------------------------------------------- +# Status mapping +# --------------------------------------------------------------------------- + + +def test_map_ib_status_submitted() -> None: + """'Submitted' should map to ACCEPTED.""" + assert _map_ib_status("Submitted") == OrderStatus.ACCEPTED + + +def test_map_ib_status_filled() -> None: + """'Filled' should map to FILLED.""" + assert _map_ib_status("Filled") == OrderStatus.FILLED + + +def test_map_ib_status_cancelled() -> None: + """'Cancelled' should map to CANCELLED.""" + assert _map_ib_status("Cancelled") == OrderStatus.CANCELLED + + +def test_map_ib_status_inactive() -> None: + """'Inactive' should map to REJECTED.""" + assert _map_ib_status("Inactive") == OrderStatus.REJECTED + + +def test_map_ib_status_api_pending() -> None: + """'ApiPending' should map to PENDING.""" + assert _map_ib_status("ApiPending") == OrderStatus.PENDING + + +def test_map_ib_status_api_cancelled() -> None: + """'ApiCancelled' should map to CANCELLED.""" + assert _map_ib_status("ApiCancelled") == OrderStatus.CANCELLED + + +def test_map_ib_status_pending_submit() -> None: + """'PendingSubmit' should map to SUBMITTED.""" + assert _map_ib_status("PendingSubmit") == OrderStatus.SUBMITTED + + +def test_map_ib_status_pending_cancel() -> None: + """'PendingCancel' should map to ACCEPTED.""" + assert _map_ib_status("PendingCancel") == OrderStatus.ACCEPTED + + +def test_map_ib_status_pre_submitted() -> None: + """'PreSubmitted' should map to ACCEPTED.""" + assert _map_ib_status("PreSubmitted") == OrderStatus.ACCEPTED + + +def test_map_ib_status_unknown() -> None: + """Unknown status should map to PENDING.""" + assert _map_ib_status("SomeUnknownStatus") == OrderStatus.PENDING + + +# --------------------------------------------------------------------------- +# Connect / disconnect tests +# --------------------------------------------------------------------------- + + +def _make_mock_ib(connected: bool = True) -> MagicMock: + """Create a mock IB instance with standard behavior.""" + mock_ib = MagicMock() + mock_ib.isConnected.return_value = connected + mock_ib.connectAsync = AsyncMock() + mock_ib.disconnect = MagicMock() + mock_ib.positions.return_value = [] + mock_ib.accountValues.return_value = [] + mock_ib.trades.return_value = [] + mock_ib.openTrades.return_value = [] + return mock_ib + + +@pytest.mark.asyncio +async def test_connect_success(event_bus: EventBus) -> None: + """connect() should create an IB instance and call connectAsync.""" + mock_ib = _make_mock_ib() + mock_ib_class = MagicMock(return_value=mock_ib) + with patch.dict("sys.modules", {"ib_async": MagicMock(IB=mock_ib_class)}): + adapter = IbkrAdapter(bus=event_bus, host="127.0.0.1", port=7497, client_id=1) + await adapter.connect() + + assert adapter.is_connected + mock_ib.connectAsync.assert_awaited_once_with("127.0.0.1", 7497, clientId=1, account="") + + +@pytest.mark.asyncio +async def test_connect_with_account(event_bus: EventBus) -> None: + """connect() should pass account to connectAsync.""" + mock_ib = _make_mock_ib() + mock_ib_class = MagicMock(return_value=mock_ib) + + with patch.dict("sys.modules", {"ib_async": MagicMock(IB=mock_ib_class)}): + adapter = IbkrAdapter( + bus=event_bus, host="10.0.0.1", port=7496, client_id=5, account="DU12345" + ) + await adapter.connect() + + mock_ib.connectAsync.assert_awaited_once_with( + "10.0.0.1", 7496, clientId=5, account="DU12345" + ) + + +@pytest.mark.asyncio +async def test_connect_import_error(event_bus: EventBus) -> None: + """connect() should raise SyslsConnectionError if ib_async is not installed.""" + import sys + + # Temporarily remove ib_async from sys.modules to force ImportError + saved = sys.modules.pop("ib_async", None) + try: + with patch.dict("sys.modules", {"ib_async": None}): + adapter = IbkrAdapter(bus=event_bus) + with pytest.raises(SyslsConnectionError, match="ib_async is not installed"): + await adapter.connect() + finally: + if saved is not None: + sys.modules["ib_async"] = saved + + +@pytest.mark.asyncio +async def test_connect_connection_failure(event_bus: EventBus) -> None: + """connect() should raise SyslsConnectionError if connectAsync fails.""" + mock_ib = _make_mock_ib(connected=False) + mock_ib.connectAsync = AsyncMock(side_effect=ConnectionRefusedError("Connection refused")) + mock_ib_class = MagicMock(return_value=mock_ib) + + with patch.dict("sys.modules", {"ib_async": MagicMock(IB=mock_ib_class)}): + adapter = IbkrAdapter(bus=event_bus) + with pytest.raises(SyslsConnectionError, match="Failed to connect"): + await adapter.connect() + + +@pytest.mark.asyncio +async def test_disconnect(event_bus: EventBus) -> None: + """disconnect() should call ib.disconnect() and clear state.""" + mock_ib = _make_mock_ib() + mock_ib_class = MagicMock(return_value=mock_ib) + + with patch.dict("sys.modules", {"ib_async": MagicMock(IB=mock_ib_class)}): + adapter = IbkrAdapter(bus=event_bus) + await adapter.connect() + assert adapter.is_connected + + await adapter.disconnect() + mock_ib.disconnect.assert_called_once() + assert not adapter.is_connected + + +@pytest.mark.asyncio +async def test_disconnect_when_not_connected(event_bus: EventBus) -> None: + """disconnect() should be safe to call when not connected.""" + adapter = IbkrAdapter(bus=event_bus) + await adapter.disconnect() # Should not raise + assert not adapter.is_connected + + +@pytest.mark.asyncio +async def test_context_manager(event_bus: EventBus) -> None: + """IbkrAdapter should support async context manager.""" + mock_ib = _make_mock_ib() + mock_ib_class = MagicMock(return_value=mock_ib) + + with patch.dict("sys.modules", {"ib_async": MagicMock(IB=mock_ib_class)}): + adapter = IbkrAdapter(bus=event_bus) + + async with adapter as a: + assert a is adapter + assert adapter.is_connected + + mock_ib.disconnect.assert_called_once() + assert not adapter.is_connected + + +# --------------------------------------------------------------------------- +# Contract building tests +# --------------------------------------------------------------------------- + + +def test_to_ib_contract_equity() -> None: + """Equity instrument should produce a Stock contract.""" + from ib_async import Stock + + instrument = _make_equity_instrument(symbol="AAPL") + contract = _to_ib_contract(instrument) + assert isinstance(contract, Stock) + assert contract.symbol == "AAPL" + assert contract.exchange == "SMART" + assert contract.currency == "USD" + + +def test_to_ib_contract_equity_with_exchange() -> None: + """Equity with explicit exchange should use that exchange.""" + from ib_async import Stock + + instrument = Instrument( + symbol="AAPL", + asset_class=AssetClass.EQUITY, + venue=Venue.IBKR, + exchange="NYSE", + currency="USD", + ) + contract = _to_ib_contract(instrument) + assert isinstance(contract, Stock) + assert contract.exchange == "NYSE" + + +def test_to_ib_contract_option_parsed() -> None: + """Option with space-separated details should be parsed correctly.""" + from ib_async import Option + + instrument = Instrument( + symbol="AAPL 20240315 150 C", + asset_class=AssetClass.OPTION, + venue=Venue.IBKR, + currency="USD", + ) + contract = _to_ib_contract(instrument) + assert isinstance(contract, Option) + assert contract.symbol == "AAPL" + assert contract.lastTradeDateOrContractMonth == "20240315" + assert contract.strike == 150.0 + assert contract.right == "C" + + +def test_to_ib_contract_option_put() -> None: + """Put option should set right to 'P'.""" + from ib_async import Option + + instrument = Instrument( + symbol="SPY 20240621 500 P", + asset_class=AssetClass.OPTION, + venue=Venue.IBKR, + currency="USD", + ) + contract = _to_ib_contract(instrument) + assert isinstance(contract, Option) + assert contract.right == "P" + assert contract.strike == 500.0 + + +def test_to_ib_contract_future() -> None: + """Future instrument should produce a Future contract.""" + from ib_async import Future + + instrument = Instrument( + symbol="ES", + asset_class=AssetClass.FUTURE, + venue=Venue.IBKR, + exchange="CME", + currency="USD", + ) + contract = _to_ib_contract(instrument) + assert isinstance(contract, Future) + assert contract.symbol == "ES" + assert contract.exchange == "CME" + + +def test_to_ib_contract_forex() -> None: + """Crypto spot instrument should produce a Forex contract.""" + from ib_async import Forex + + instrument = Instrument( + symbol="EUR", + asset_class=AssetClass.CRYPTO_SPOT, + venue=Venue.IBKR, + currency="USD", + ) + contract = _to_ib_contract(instrument) + assert isinstance(contract, Forex) + assert contract.symbol == "EUR" + assert contract.currency == "USD" + assert contract.exchange == "IDEALPRO" + + +def test_to_ib_contract_unsupported_raises() -> None: + """Unsupported asset class should raise OrderError.""" + instrument = Instrument( + symbol="SOME_EVENT", + asset_class=AssetClass.EVENT, + venue=Venue.IBKR, + currency="USD", + ) + with pytest.raises(OrderError, match="Unsupported asset class"): + _to_ib_contract(instrument) + + +# --------------------------------------------------------------------------- +# Order building tests +# --------------------------------------------------------------------------- + + +def test_to_ib_order_market_buy() -> None: + """Market buy should produce a MarketOrder with action BUY.""" + from ib_async import MarketOrder + + order = _make_order(side=Side.BUY, order_type=OrderType.MARKET, quantity=Decimal("100")) + ib_order = _to_ib_order(order) + assert isinstance(ib_order, MarketOrder) + assert ib_order.action == "BUY" + assert ib_order.totalQuantity == 100.0 + + +def test_to_ib_order_market_sell() -> None: + """Market sell should produce a MarketOrder with action SELL.""" + from ib_async import MarketOrder + + order = _make_order(side=Side.SELL, order_type=OrderType.MARKET, quantity=Decimal("50")) + ib_order = _to_ib_order(order) + assert isinstance(ib_order, MarketOrder) + assert ib_order.action == "SELL" + assert ib_order.totalQuantity == 50.0 + + +def test_to_ib_order_limit() -> None: + """Limit order should produce a LimitOrder with correct price.""" + from ib_async import LimitOrder + + order = _make_order( + side=Side.BUY, + order_type=OrderType.LIMIT, + quantity=Decimal("200"), + price=Decimal("150.50"), + ) + ib_order = _to_ib_order(order) + assert isinstance(ib_order, LimitOrder) + assert ib_order.action == "BUY" + assert ib_order.totalQuantity == 200.0 + assert ib_order.lmtPrice == 150.50 + + +def test_to_ib_order_limit_no_price_raises() -> None: + """Limit order without price should raise OrderError.""" + order = _make_order(side=Side.BUY, order_type=OrderType.LIMIT, price=None) + with pytest.raises(OrderError, match="Limit order requires a price"): + _to_ib_order(order) + + +def test_to_ib_order_stop() -> None: + """Stop order should produce a StopOrder with correct stop price.""" + from ib_async import StopOrder + + order = _make_order( + side=Side.SELL, + order_type=OrderType.STOP, + quantity=Decimal("75"), + stop_price=Decimal("140.00"), + ) + ib_order = _to_ib_order(order) + assert isinstance(ib_order, StopOrder) + assert ib_order.action == "SELL" + assert ib_order.totalQuantity == 75.0 + assert ib_order.auxPrice == 140.00 + + +def test_to_ib_order_stop_no_stop_price_raises() -> None: + """Stop order without stop_price should raise OrderError.""" + order = _make_order(side=Side.SELL, order_type=OrderType.STOP, stop_price=None) + with pytest.raises(OrderError, match="Stop order requires a stop_price"): + _to_ib_order(order) + + +def test_to_ib_order_stop_limit() -> None: + """Stop-limit order should produce a StopLimitOrder.""" + from ib_async import StopLimitOrder + + order = _make_order( + side=Side.BUY, + order_type=OrderType.STOP_LIMIT, + quantity=Decimal("30"), + price=Decimal("155.00"), + stop_price=Decimal("153.00"), + ) + ib_order = _to_ib_order(order) + assert isinstance(ib_order, StopLimitOrder) + assert ib_order.action == "BUY" + assert ib_order.totalQuantity == 30.0 + assert ib_order.lmtPrice == 155.00 + assert ib_order.auxPrice == 153.00 + + +def test_to_ib_order_stop_limit_missing_prices_raises() -> None: + """Stop-limit order missing price or stop_price should raise OrderError.""" + order = _make_order( + side=Side.BUY, + order_type=OrderType.STOP_LIMIT, + price=Decimal("155.00"), + stop_price=None, + ) + with pytest.raises(OrderError, match="Stop-limit order requires both"): + _to_ib_order(order) + + +# --------------------------------------------------------------------------- +# Submit order tests +# --------------------------------------------------------------------------- + + +def _make_mock_trade(order_id: int = 42, status: str = "Submitted") -> MagicMock: + """Create a mock ib_async Trade object.""" + trade = MagicMock() + trade.order.orderId = order_id + trade.orderStatus.status = status + return trade + + +@pytest.mark.asyncio +async def test_submit_market_order(event_bus: EventBus) -> None: + """submit_order should call placeOrder and emit OrderAccepted.""" + import asyncio + + accepted_events: list[OrderAccepted] = [] + + async def capture(event: OrderAccepted) -> None: + accepted_events.append(event) + + event_bus.subscribe(OrderAccepted, capture) + await event_bus.start() + + mock_ib = _make_mock_ib() + mock_trade = _make_mock_trade(order_id=42) + mock_ib.placeOrder.return_value = mock_trade + mock_ib_class = MagicMock(return_value=mock_ib) + + with patch.dict("sys.modules", {"ib_async": MagicMock(IB=mock_ib_class)}): + adapter = IbkrAdapter(bus=event_bus) + await adapter.connect() + + order = _make_order(side=Side.BUY, order_type=OrderType.MARKET, quantity=Decimal("100")) + venue_order_id = await adapter.submit_order(order) + + await asyncio.sleep(0.05) + await event_bus.stop() + + assert venue_order_id == "42" + mock_ib.placeOrder.assert_called_once() + + assert len(accepted_events) == 1 + assert accepted_events[0].venue_order_id == "42" + assert accepted_events[0].order_id == order.order_id + + +@pytest.mark.asyncio +async def test_submit_order_error_wrapping(event_bus: EventBus) -> None: + """submit_order should wrap IB errors as VenueError.""" + await event_bus.start() + + mock_ib = _make_mock_ib() + mock_ib.placeOrder.side_effect = RuntimeError("API not available") + mock_ib_class = MagicMock(return_value=mock_ib) + + with patch.dict("sys.modules", {"ib_async": MagicMock(IB=mock_ib_class)}): + adapter = IbkrAdapter(bus=event_bus) + await adapter.connect() + + with pytest.raises(VenueError, match="API not available"): + await adapter.submit_order(_make_order()) + + await event_bus.stop() + + +@pytest.mark.asyncio +async def test_submit_order_not_connected_raises(event_bus: EventBus) -> None: + """submit_order should raise VenueError when not connected.""" + adapter = IbkrAdapter(bus=event_bus) + with pytest.raises(VenueError, match="Not connected"): + await adapter.submit_order(_make_order()) + + +# --------------------------------------------------------------------------- +# Cancel order tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_cancel_order(event_bus: EventBus) -> None: + """cancel_order should find the trade and cancel it.""" + import asyncio + + cancelled_events: list[OrderCancelled] = [] + + async def capture(event: OrderCancelled) -> None: + cancelled_events.append(event) + + event_bus.subscribe(OrderCancelled, capture) + await event_bus.start() + + mock_ib = _make_mock_ib() + mock_trade = _make_mock_trade(order_id=42) + mock_ib.openTrades.return_value = [mock_trade] + mock_ib_class = MagicMock(return_value=mock_ib) + + with patch.dict("sys.modules", {"ib_async": MagicMock(IB=mock_ib_class)}): + adapter = IbkrAdapter(bus=event_bus) + await adapter.connect() + + instrument = _make_equity_instrument() + await adapter.cancel_order("42", instrument) + + await asyncio.sleep(0.05) + await event_bus.stop() + + mock_ib.cancelOrder.assert_called_once_with(mock_trade.order) + assert len(cancelled_events) == 1 + assert cancelled_events[0].reason == "Cancelled via IBKR" + + +@pytest.mark.asyncio +async def test_cancel_order_not_found_raises(event_bus: EventBus) -> None: + """cancel_order should raise OrderError if order not in open trades.""" + await event_bus.start() + + mock_ib = _make_mock_ib() + mock_ib.openTrades.return_value = [] + mock_ib_class = MagicMock(return_value=mock_ib) + + with patch.dict("sys.modules", {"ib_async": MagicMock(IB=mock_ib_class)}): + adapter = IbkrAdapter(bus=event_bus) + await adapter.connect() + + with pytest.raises(OrderError, match="not found in open trades"): + await adapter.cancel_order("999", _make_equity_instrument()) + + await event_bus.stop() + + +# --------------------------------------------------------------------------- +# Get order status tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_order_status_found(event_bus: EventBus) -> None: + """get_order_status should find the trade and return mapped status.""" + mock_ib = _make_mock_ib() + mock_trade = _make_mock_trade(order_id=42, status="Filled") + mock_ib.trades.return_value = [mock_trade] + mock_ib_class = MagicMock(return_value=mock_ib) + + with patch.dict("sys.modules", {"ib_async": MagicMock(IB=mock_ib_class)}): + adapter = IbkrAdapter(bus=event_bus) + await adapter.connect() + + status = await adapter.get_order_status("42", _make_equity_instrument()) + + assert status == OrderStatus.FILLED + + +@pytest.mark.asyncio +async def test_get_order_status_not_found(event_bus: EventBus) -> None: + """get_order_status should return PENDING if order not found.""" + mock_ib = _make_mock_ib() + mock_ib.trades.return_value = [] + mock_ib_class = MagicMock(return_value=mock_ib) + + with patch.dict("sys.modules", {"ib_async": MagicMock(IB=mock_ib_class)}): + adapter = IbkrAdapter(bus=event_bus) + await adapter.connect() + + status = await adapter.get_order_status("999", _make_equity_instrument()) + + assert status == OrderStatus.PENDING + + +# --------------------------------------------------------------------------- +# Build instrument from contract tests +# --------------------------------------------------------------------------- + + +def _make_mock_contract( + sec_type: str = "STK", + symbol: str = "AAPL", + exchange: str = "SMART", + currency: str = "USD", + multiplier: str = "", +) -> MagicMock: + """Create a mock ib_async Contract.""" + contract = MagicMock() + contract.secType = sec_type + contract.symbol = symbol + contract.exchange = exchange + contract.currency = currency + contract.multiplier = multiplier + return contract + + +def test_build_instrument_from_stock_contract() -> None: + """Stock contract should produce an EQUITY instrument.""" + contract = _make_mock_contract(sec_type="STK", symbol="AAPL") + instrument = _build_instrument_from_contract(contract) + assert instrument.symbol == "AAPL" + assert instrument.asset_class == AssetClass.EQUITY + assert instrument.venue == Venue.IBKR + assert instrument.exchange == "SMART" + assert instrument.currency == "USD" + assert instrument.multiplier == Decimal("1") + + +def test_build_instrument_from_future_contract() -> None: + """Future contract should produce a FUTURE instrument with multiplier.""" + contract = _make_mock_contract(sec_type="FUT", symbol="ES", exchange="CME", multiplier="50") + instrument = _build_instrument_from_contract(contract) + assert instrument.symbol == "ES" + assert instrument.asset_class == AssetClass.FUTURE + assert instrument.exchange == "CME" + assert instrument.multiplier == Decimal("50") + + +def test_build_instrument_from_option_contract() -> None: + """Option contract should produce an OPTION instrument.""" + contract = _make_mock_contract(sec_type="OPT", symbol="AAPL", multiplier="100") + instrument = _build_instrument_from_contract(contract) + assert instrument.asset_class == AssetClass.OPTION + assert instrument.multiplier == Decimal("100") + + +def test_build_instrument_from_forex_contract() -> None: + """Forex contract should produce a CRYPTO_SPOT instrument.""" + contract = _make_mock_contract( + sec_type="CASH", symbol="EUR", exchange="IDEALPRO", currency="USD" + ) + instrument = _build_instrument_from_contract(contract) + assert instrument.asset_class == AssetClass.CRYPTO_SPOT + assert instrument.symbol == "EUR" + + +def test_build_instrument_unknown_sec_type_defaults_to_equity() -> None: + """Unknown secType should default to EQUITY.""" + contract = _make_mock_contract(sec_type="UNKNOWN", symbol="XYZ") + instrument = _build_instrument_from_contract(contract) + assert instrument.asset_class == AssetClass.EQUITY + + +# --------------------------------------------------------------------------- +# Position tests +# --------------------------------------------------------------------------- + + +def _make_mock_position( + symbol: str = "AAPL", + sec_type: str = "STK", + position: float = 100.0, + avg_cost: float = 150.0, +) -> MagicMock: + """Create a mock IB Position namedtuple.""" + pos = MagicMock() + pos.contract = _make_mock_contract(sec_type=sec_type, symbol=symbol) + pos.position = position + pos.avgCost = avg_cost + pos.account = "DU12345" + return pos + + +@pytest.mark.asyncio +async def test_get_positions_empty(event_bus: EventBus) -> None: + """get_positions should return empty dict when no positions.""" + mock_ib = _make_mock_ib() + mock_ib.positions.return_value = [] + mock_ib_class = MagicMock(return_value=mock_ib) + + with patch.dict("sys.modules", {"ib_async": MagicMock(IB=mock_ib_class)}): + adapter = IbkrAdapter(bus=event_bus) + await adapter.connect() + + positions = await adapter.get_positions() + + assert positions == {} + + +@pytest.mark.asyncio +async def test_get_positions_long(event_bus: EventBus) -> None: + """get_positions should return positive quantity for long positions.""" + mock_ib = _make_mock_ib() + mock_ib.positions.return_value = [ + _make_mock_position(symbol="AAPL", position=100.0), + ] + mock_ib_class = MagicMock(return_value=mock_ib) + + with patch.dict("sys.modules", {"ib_async": MagicMock(IB=mock_ib_class)}): + adapter = IbkrAdapter(bus=event_bus) + await adapter.connect() + + positions = await adapter.get_positions() + + assert len(positions) == 1 + instrument = next(iter(positions.keys())) + assert instrument.symbol == "AAPL" + assert instrument.asset_class == AssetClass.EQUITY + assert positions[instrument] == Decimal("100.0") + + +@pytest.mark.asyncio +async def test_get_positions_short(event_bus: EventBus) -> None: + """get_positions should return negative quantity for short positions.""" + mock_ib = _make_mock_ib() + mock_ib.positions.return_value = [ + _make_mock_position(symbol="TSLA", position=-50.0), + ] + mock_ib_class = MagicMock(return_value=mock_ib) + + with patch.dict("sys.modules", {"ib_async": MagicMock(IB=mock_ib_class)}): + adapter = IbkrAdapter(bus=event_bus) + await adapter.connect() + + positions = await adapter.get_positions() + + assert len(positions) == 1 + qty = next(iter(positions.values())) + assert qty == Decimal("-50.0") + + +@pytest.mark.asyncio +async def test_get_positions_skips_zero(event_bus: EventBus) -> None: + """get_positions should skip positions with zero quantity.""" + mock_ib = _make_mock_ib() + mock_ib.positions.return_value = [ + _make_mock_position(symbol="AAPL", position=0.0), + _make_mock_position(symbol="MSFT", position=200.0), + ] + mock_ib_class = MagicMock(return_value=mock_ib) + + with patch.dict("sys.modules", {"ib_async": MagicMock(IB=mock_ib_class)}): + adapter = IbkrAdapter(bus=event_bus) + await adapter.connect() + + positions = await adapter.get_positions() + + assert len(positions) == 1 + instrument = next(iter(positions.keys())) + assert instrument.symbol == "MSFT" + + +# --------------------------------------------------------------------------- +# Balance tests +# --------------------------------------------------------------------------- + + +def _make_mock_account_value( + tag: str, value: str, currency: str = "USD", account: str = "DU12345" +) -> MagicMock: + """Create a mock IB AccountValue namedtuple.""" + av = MagicMock() + av.tag = tag + av.value = value + av.currency = currency + av.account = account + av.modelCode = "" + return av + + +@pytest.mark.asyncio +async def test_get_balances(event_bus: EventBus) -> None: + """get_balances should return CashBalance values by currency.""" + mock_ib = _make_mock_ib() + mock_ib.accountValues.return_value = [ + _make_mock_account_value("CashBalance", "50000.00", "USD"), + _make_mock_account_value("CashBalance", "10000.00", "EUR"), + _make_mock_account_value("NetLiquidation", "75000.00", "USD"), # Should be ignored + _make_mock_account_value("CashBalance", "0", "GBP"), # Zero, should be excluded + _make_mock_account_value("CashBalance", "5000.00", "BASE"), # BASE, should be excluded + ] + mock_ib_class = MagicMock(return_value=mock_ib) + + with patch.dict("sys.modules", {"ib_async": MagicMock(IB=mock_ib_class)}): + adapter = IbkrAdapter(bus=event_bus) + await adapter.connect() + + balances = await adapter.get_balances() + + assert balances["USD"] == Decimal("50000.00") + assert balances["EUR"] == Decimal("10000.00") + assert "GBP" not in balances + assert "BASE" not in balances + assert len(balances) == 2 + + +@pytest.mark.asyncio +async def test_get_balances_empty(event_bus: EventBus) -> None: + """get_balances should return empty dict when no account values.""" + mock_ib = _make_mock_ib() + mock_ib.accountValues.return_value = [] + mock_ib_class = MagicMock(return_value=mock_ib) + + with patch.dict("sys.modules", {"ib_async": MagicMock(IB=mock_ib_class)}): + adapter = IbkrAdapter(bus=event_bus) + await adapter.connect() + + balances = await adapter.get_balances() + + assert balances == {} + + +# --------------------------------------------------------------------------- +# Error wrapping tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_wrap_ib_error_connection_error(event_bus: EventBus) -> None: + """ConnectionError should be wrapped as SyslsConnectionError.""" + mock_ib = _make_mock_ib() + mock_ib.placeOrder.side_effect = ConnectionError("Lost connection") + mock_ib_class = MagicMock(return_value=mock_ib) + + with patch.dict("sys.modules", {"ib_async": MagicMock(IB=mock_ib_class)}): + adapter = IbkrAdapter(bus=event_bus) + await adapter.connect() + + with pytest.raises(SyslsConnectionError, match="Lost connection"): + await adapter.submit_order(_make_order()) + + +@pytest.mark.asyncio +async def test_wrap_ib_error_os_error(event_bus: EventBus) -> None: + """OSError should be wrapped as SyslsConnectionError.""" + mock_ib = _make_mock_ib() + mock_ib.placeOrder.side_effect = OSError("Network unreachable") + mock_ib_class = MagicMock(return_value=mock_ib) + + with patch.dict("sys.modules", {"ib_async": MagicMock(IB=mock_ib_class)}): + adapter = IbkrAdapter(bus=event_bus) + await adapter.connect() + + with pytest.raises(SyslsConnectionError, match="Network unreachable"): + await adapter.submit_order(_make_order()) + + +@pytest.mark.asyncio +async def test_wrap_ib_error_timeout(event_bus: EventBus) -> None: + """TimeoutError should be wrapped as SyslsConnectionError.""" + mock_ib = _make_mock_ib() + mock_ib.placeOrder.side_effect = TimeoutError("Request timed out") + mock_ib_class = MagicMock(return_value=mock_ib) + + with patch.dict("sys.modules", {"ib_async": MagicMock(IB=mock_ib_class)}): + adapter = IbkrAdapter(bus=event_bus) + await adapter.connect() + + with pytest.raises(SyslsConnectionError, match="Request timed out"): + await adapter.submit_order(_make_order()) + + +@pytest.mark.asyncio +async def test_wrap_ib_error_value_error(event_bus: EventBus) -> None: + """ValueError should be wrapped as OrderError.""" + mock_ib = _make_mock_ib() + mock_ib.placeOrder.side_effect = ValueError("Invalid quantity") + mock_ib_class = MagicMock(return_value=mock_ib) + + with patch.dict("sys.modules", {"ib_async": MagicMock(IB=mock_ib_class)}): + adapter = IbkrAdapter(bus=event_bus) + await adapter.connect() + + with pytest.raises(OrderError, match="Invalid quantity"): + await adapter.submit_order(_make_order()) + + +@pytest.mark.asyncio +async def test_wrap_ib_error_generic(event_bus: EventBus) -> None: + """Other exceptions should be wrapped as VenueError.""" + mock_ib = _make_mock_ib() + mock_ib.positions.side_effect = RuntimeError("Unexpected failure") + mock_ib_class = MagicMock(return_value=mock_ib) + + with patch.dict("sys.modules", {"ib_async": MagicMock(IB=mock_ib_class)}): + adapter = IbkrAdapter(bus=event_bus) + await adapter.connect() + + with pytest.raises(VenueError, match="Unexpected failure"): + await adapter.get_positions() + + +@pytest.mark.asyncio +async def test_get_balances_invalid_value_skipped(event_bus: EventBus) -> None: + """get_balances should skip account values that can't be parsed as Decimal.""" + mock_ib = _make_mock_ib() + mock_ib.accountValues.return_value = [ + _make_mock_account_value("CashBalance", "not_a_number", "USD"), + _make_mock_account_value("CashBalance", "25000.00", "EUR"), + ] + mock_ib_class = MagicMock(return_value=mock_ib) + + with patch.dict("sys.modules", {"ib_async": MagicMock(IB=mock_ib_class)}): + adapter = IbkrAdapter(bus=event_bus) + await adapter.connect() + + balances = await adapter.get_balances() + + assert "USD" not in balances + assert balances["EUR"] == Decimal("25000.00") + + +@pytest.mark.asyncio +async def test_get_positions_multiple_instruments(event_bus: EventBus) -> None: + """get_positions should handle multiple different instruments.""" + mock_ib = _make_mock_ib() + mock_ib.positions.return_value = [ + _make_mock_position(symbol="AAPL", sec_type="STK", position=100.0), + _make_mock_position(symbol="ES", sec_type="FUT", position=5.0), + _make_mock_position(symbol="SPY", sec_type="OPT", position=-10.0), + ] + mock_ib_class = MagicMock(return_value=mock_ib) + + with patch.dict("sys.modules", {"ib_async": MagicMock(IB=mock_ib_class)}): + adapter = IbkrAdapter(bus=event_bus) + await adapter.connect() + + positions = await adapter.get_positions() + + assert len(positions) == 3 + symbols = {i.symbol for i in positions} + assert symbols == {"AAPL", "ES", "SPY"} diff --git a/uv.lock b/uv.lock index 7b2b506..45c4cbb 100644 --- a/uv.lock +++ b/uv.lock @@ -10,6 +10,18 @@ resolution-markers = [ "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] +[[package]] +name = "aeventkit" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/8c/c08db1a1910f8d04ec6a524de522edd0bac181bdf94dbb01183f7685cd77/aeventkit-2.1.0.tar.gz", hash = "sha256:4e7d81bb0a67227121da50a23e19e5bbf13eded541a9f4857eeb6b7b857b738a", size = 24703, upload-time = "2025-06-22T15:54:03.961Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/8c/2a4b912b1afa201b25bdd0f5bccf96d5a8b5dccb6131316a8dd2d9cabcc1/aeventkit-2.1.0-py3-none-any.whl", hash = "sha256:962d43f79e731ac43527f2d0defeed118e6dbaa85f1487f5667540ebb8f00729", size = 26678, upload-time = "2025-06-22T15:54:02.141Z" }, +] + [[package]] name = "aiodns" version = "4.0.0" @@ -496,6 +508,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/f7/5cc291d701094754a1d327b44d80a44971e13962881d9a400235726171da/hypothesis-6.151.9-py3-none-any.whl", hash = "sha256:7b7220585c67759b1b1ef839b1e6e9e3d82ed468cfc1ece43c67184848d7edd9", size = 529307, upload-time = "2026-02-16T22:59:20.443Z" }, ] +[[package]] +name = "ib-async" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aeventkit" }, + { name = "nest-asyncio" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/4d/dfc1da8224c3ffcdcd668da7283c4e5f14239a07f83ea66af99700296fc3/ib_async-2.1.0.tar.gz", hash = "sha256:6a03a87d6c06acacb0217a5bea60a8a168ecd5b5a7e86e1c73678d5b48cbc796", size = 87678, upload-time = "2025-12-08T01:42:32.004Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/e7/8f33801788c66f15e9250957ff7f53a8000843f79af1a3ed7a96def0e96b/ib_async-2.1.0-py3-none-any.whl", hash = "sha256:f6d8b991bdbd6dd38e700c61b3dced06ebe0f14be4e5263e2ef10ba10b88d434", size = 88876, upload-time = "2025-12-08T01:42:30.883Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -713,6 +739,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + [[package]] name = "numpy" version = "2.4.2" @@ -1276,11 +1311,15 @@ arctic = [ ccxt = [ { name = "ccxt" }, ] +ibkr = [ + { name = "ib-async" }, +] [package.dev-dependencies] dev = [ { name = "ccxt" }, { name = "hypothesis" }, + { name = "ib-async" }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -1292,6 +1331,7 @@ dev = [ requires-dist = [ { name = "arcticdb", marker = "extra == 'arctic'", specifier = ">=6.0" }, { name = "ccxt", marker = "extra == 'ccxt'", specifier = ">=4.0" }, + { name = "ib-async", marker = "extra == 'ibkr'", specifier = ">=1.0" }, { name = "msgpack", specifier = ">=1.1" }, { name = "numpy", specifier = ">=2.4.2" }, { name = "pandas", specifier = ">=3.0.0" }, @@ -1301,12 +1341,13 @@ requires-dist = [ { name = "pyyaml", specifier = ">=6.0" }, { name = "structlog", specifier = ">=24.4" }, ] -provides-extras = ["arctic", "ccxt"] +provides-extras = ["arctic", "ccxt", "ibkr"] [package.metadata.requires-dev] dev = [ { name = "ccxt", specifier = ">=4.0" }, { name = "hypothesis", specifier = ">=6.115" }, + { name = "ib-async", specifier = ">=1.0" }, { name = "mypy", specifier = ">=1.13" }, { name = "pytest", specifier = ">=8.3" }, { name = "pytest-asyncio", specifier = ">=0.24" },