From bbc947f9777e92dec17c12e6e6855f0c50a52442 Mon Sep 17 00:00:00 2001 From: AlstonLi007 <148654191+AlstonLi007@users.noreply.github.com> Date: Thu, 9 Oct 2025 09:34:32 -0500 Subject: [PATCH] Add IBKR client time-in-force and client order id support --- openbb_lab/__init__.py | 0 openbb_lab/execution/__init__.py | 0 openbb_lab/execution/ibkr_client.py | 172 ++++++++++++++++++++++++++++ tests/execution/test_ibkr_client.py | 101 ++++++++++++++++ 4 files changed, 273 insertions(+) create mode 100644 openbb_lab/__init__.py create mode 100644 openbb_lab/execution/__init__.py create mode 100644 openbb_lab/execution/ibkr_client.py create mode 100644 tests/execution/test_ibkr_client.py diff --git a/openbb_lab/__init__.py b/openbb_lab/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/openbb_lab/execution/__init__.py b/openbb_lab/execution/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/openbb_lab/execution/ibkr_client.py b/openbb_lab/execution/ibkr_client.py new file mode 100644 index 0000000..d6ff9e2 --- /dev/null +++ b/openbb_lab/execution/ibkr_client.py @@ -0,0 +1,172 @@ +"""Interactive Brokers client utilities.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, Optional + + +@dataclass +class Order: + """A tiny representation of an Interactive Brokers order. + + The real ``ib_insync`` order objects expose the attributes that are accessed + by the execution layer. For the purposes of unit testing we only model the + pieces that are manipulated by :class:`IBKRClient`. + """ + + action: str + totalQuantity: float + orderType: str + tif: Optional[str] = None + lmtPrice: Optional[float] = None + auxPrice: Optional[float] = None + orderId: Optional[str] = None + transmit: bool = True + + +_ALLOWED_TIME_IN_FORCE: Dict[str, set[str]] = { + "market": {"DAY", "GTC", "IOC", "FOK"}, + "limit": {"DAY", "GTC", "IOC", "FOK", "GTD"}, + "stop": {"DAY", "GTC", "GTD"}, + "stop_limit": {"DAY", "GTC", "GTD"}, + "market_if_touched": {"DAY", "GTC", "GTD"}, +} + + +class IBKRClient: + """Minimal Interactive Brokers client wrapper used in tests.""" + + def __init__(self, gateway: Any) -> None: + self._gateway = gateway + + def place_order( + self, + contract: Any, + order_type: str, + side: str, + quantity: float, + *, + limit_price: Optional[float] = None, + stop_price: Optional[float] = None, + time_in_force: Optional[str] = None, + client_order_id: Optional[str] = None, + ) -> Dict[str, Any]: + """Create and submit an order to the Interactive Brokers gateway. + + Parameters + ---------- + contract + ``ib_insync`` contract object used by the order. + order_type + Textual order type, e.g. ``"market"`` or ``"limit"``. + side + ``"buy"`` or ``"sell"``. + quantity + Quantity that should be ordered. + limit_price, stop_price + Prices that are required for certain order types. + time_in_force + Time-in-force flag that should be pushed to the order. + client_order_id + Optional order id supplied by the caller. + """ + + order = self._build_order( + order_type=order_type, + side=side, + quantity=quantity, + limit_price=limit_price, + stop_price=stop_price, + time_in_force=time_in_force, + client_order_id=client_order_id, + ) + + trade = self._gateway.placeOrder(contract, order) + + return { + "contract": contract, + "order": order, + "trade": trade, + "order_type": order.orderType, + "side": order.action, + "quantity": order.totalQuantity, + "time_in_force": order.tif, + "client_order_id": order.orderId, + } + + def _build_order( + self, + *, + order_type: str, + side: str, + quantity: float, + limit_price: Optional[float], + stop_price: Optional[float], + time_in_force: Optional[str], + client_order_id: Optional[str], + ) -> Order: + """Create an order instance for the provided input.""" + + normalized_type = order_type.lower() + if normalized_type not in _ALLOWED_TIME_IN_FORCE: + raise ValueError(f"Unsupported order type: {order_type}") + + tif = time_in_force.upper() if time_in_force else None + if tif and tif not in _ALLOWED_TIME_IN_FORCE[normalized_type]: + raise ValueError( + "Time-in-force '%s' not allowed for order type '%s'" + % (tif, normalized_type) + ) + + action = side.upper() + if action not in {"BUY", "SELL"}: + raise ValueError("Order side must be either 'buy' or 'sell'") + + if normalized_type == "market": + order = Order(action=action, totalQuantity=quantity, orderType="MKT") + elif normalized_type == "limit": + if limit_price is None: + raise ValueError("Limit orders require a limit_price") + order = Order( + action=action, + totalQuantity=quantity, + orderType="LMT", + lmtPrice=limit_price, + ) + elif normalized_type == "stop": + if stop_price is None: + raise ValueError("Stop orders require a stop_price") + order = Order( + action=action, + totalQuantity=quantity, + orderType="STP", + auxPrice=stop_price, + ) + elif normalized_type == "stop_limit": + if limit_price is None or stop_price is None: + raise ValueError("Stop limit orders require limit_price and stop_price") + order = Order( + action=action, + totalQuantity=quantity, + orderType="STP LMT", + lmtPrice=limit_price, + auxPrice=stop_price, + ) + elif normalized_type == "market_if_touched": + if stop_price is None: + raise ValueError("Market-if-touched orders require stop_price") + order = Order( + action=action, + totalQuantity=quantity, + orderType="MIT", + auxPrice=stop_price, + ) + else: + raise ValueError(f"Unsupported order type: {order_type}") + + order.tif = tif + if client_order_id is not None: + order.orderId = str(client_order_id) + order.transmit = True + + return order diff --git a/tests/execution/test_ibkr_client.py b/tests/execution/test_ibkr_client.py new file mode 100644 index 0000000..8449280 --- /dev/null +++ b/tests/execution/test_ibkr_client.py @@ -0,0 +1,101 @@ +"""Tests for the lightweight IBKR client wrapper.""" +from __future__ import annotations + +from dataclasses import dataclass +import pathlib +import sys + +import pytest + +ROOT = pathlib.Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from openbb_lab.execution.ibkr_client import IBKRClient, Order + + +@dataclass +class DummyContract: + symbol: str + + +class GatewayStub: + def __init__(self) -> None: + self.last_order: Order | None = None + self.last_contract: DummyContract | None = None + + def placeOrder(self, contract: DummyContract, order: Order): + self.last_order = order + self.last_contract = contract + return { + "orderType": order.orderType, + "action": order.action, + "time_in_force": order.tif, + "client_order_id": order.orderId, + } + + +@pytest.fixture() +def client() -> IBKRClient: + return IBKRClient(GatewayStub()) + + +def test_place_order_propagates_time_in_force_and_client_order_id(client: IBKRClient): + contract = DummyContract(symbol="AAPL") + + payload = client.place_order( + contract=contract, + order_type="limit", + side="buy", + quantity=10, + limit_price=123.45, + time_in_force="GTC", + client_order_id="ABC123", + ) + + assert payload["time_in_force"] == "GTC" + assert payload["client_order_id"] == "ABC123" + assert payload["order"].tif == "GTC" + assert payload["order"].orderId == "ABC123" + assert payload["order"].transmit is True + + +def test_build_order_rejects_unsupported_time_in_force(client: IBKRClient): + with pytest.raises(ValueError): + client._build_order( # type: ignore[protected-access] + order_type="market", + side="buy", + quantity=1, + limit_price=None, + stop_price=None, + time_in_force="GTD", + client_order_id=None, + ) + + +def test_stop_limit_requires_prices(client: IBKRClient): + with pytest.raises(ValueError): + client._build_order( # type: ignore[protected-access] + order_type="stop_limit", + side="sell", + quantity=2, + limit_price=None, + stop_price=100, + time_in_force="DAY", + client_order_id=None, + ) + + order = client._build_order( # type: ignore[protected-access] + order_type="stop_limit", + side="sell", + quantity=2, + limit_price=99.5, + stop_price=100, + time_in_force="DAY", + client_order_id="55", + ) + + assert order.tif == "DAY" + assert order.orderId == "55" + assert order.lmtPrice == 99.5 + assert order.auxPrice == 100