Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added openbb_lab/__init__.py
Empty file.
Empty file.
172 changes: 172 additions & 0 deletions openbb_lab/execution/ibkr_client.py
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +167 to +170

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid overwriting IBKR orderId with client order id

When client_order_id is passed, the wrapper stores it in order.orderId. In the real IBKR API orderId must remain a monotonically increasing integer obtained from nextValidId; replacing it with an arbitrary string breaks order submission. IBKR exposes a separate clientOrderId field for caller-supplied identifiers that should be used instead, leaving orderId untouched. As implemented, any order using client_order_id will be rejected by the gateway.

Useful? React with 👍 / 👎.


return order
101 changes: 101 additions & 0 deletions tests/execution/test_ibkr_client.py
Original file line number Diff line number Diff line change
@@ -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