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
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# OpenBB Lab Execution Helpers

This repository exposes a small helper module under
`openbb_lab.execution` that enriches the [`ib_insync`](https://ib-insync.readthedocs.io)
client with OpenBB-friendly models. The `IBKRClient` entry points provide
normalised output via `OrderTicket` objects so that downstream consumers can
serialize the responses or store them without depending on the raw
`ib_insync` objects.

## Newly added helpers

| Helper | Description |
| --- | --- |
| `create_bracket` | Build and transmit a bracket order (entry, take-profit, stop-loss) and return normalised tickets. |
| `create_oca` | Submit a one-cancels-all order group with consistent identifiers. |
| `modify_order` | Apply updates to an existing trade/order and receive the updated ticket. |
| `ibkr_stream_trades` | Async iterator yielding normalised `OrderTicket` instances whenever IBKR notifies about a trade update. |
| `req_market_data` | Request streaming or snapshot market data for a contract and return a light-weight summary payload. |
| `ensure_permissions` | Simple guard that asserts the current connection exposes the requested managed accounts. |
| `set_client_ratelimits` | Apply pacing/ratelimit options to the underlying client connection. |
| `ibkr_health` | Provide a diagnostic snapshot suitable for readiness/health checks. |

Each helper is documented in `openbb_lab/execution/ibkr_client.py` with
usage details and parameter descriptions.

## Examples

The module level docstring of `openbb_lab.execution.ibkr_client` contains a
complete snippet for creating a bracket order. Additional stubs that can be
used as starting points for unit tests live in `tests/test_ibkr_client.py`.
5 changes: 5 additions & 0 deletions openbb_lab/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""OpenBB Lab execution package."""

from .execution.ibkr_client import IBKRClient

__all__ = ["IBKRClient"]
289 changes: 289 additions & 0 deletions openbb_lab/execution/ibkr_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,289 @@
"""Interactive Brokers (IBKR) execution helpers built on top of ``ib_insync``.

The :class:`IBKRClient` class provided here wraps an active ``ib_insync.IB``
connection and exposes high-level helpers that return the normalized models
from :mod:`openbb_lab.execution.models`. These helpers provide a consistent
schema for downstream services and notebooks while keeping the direct
``ib_insync`` dependency isolated in a single place.

Examples
--------
The following snippet illustrates how to place a bracket order using the
client. The example is also mirrored in :mod:`tests.test_ibkr_client` as a
usage stub.

.. code-block:: python

from ib_insync import IB, Stock
from openbb_lab.execution import IBKRClient

ib = IB().connect("127.0.0.1", 7497, clientId=1)
client = IBKRClient(ib)
contract = Stock("AAPL", "SMART", "USD")
tickets = client.create_bracket(
contract=contract,
action="BUY",
quantity=10,
limit_price=175.5,
take_profit_price=180.0,
stop_loss_price=170.0,
tif="GTC",
)
for ticket in tickets:
print(ticket.identifier, ticket.status)

"""

from __future__ import annotations

import asyncio
from typing import Any, AsyncIterator, Dict, List, Optional, Sequence

from ib_insync import Contract, IB, Order, Trade

from .models import ContractModel, OrderTicket, normalize_tif, normalize_trades

__all__ = ["IBKRClient"]


class IBKRClient:
"""A thin convenience wrapper around :class:`ib_insync.IB`.

Parameters
----------
ib:
An active ``ib_insync.IB`` connection.
"""

def __init__(self, ib: IB):
self._ib = ib

# ---------------------------------------------------------------------
# Internal utilities
# ------------------------------------------------------------------
def _ensure_connection(self) -> None:
if not self._ib.isConnected():
raise RuntimeError("IBKR client is not connected")

def _normalize_trade(self, trade: Trade) -> OrderTicket:
return OrderTicket.from_trade(trade)

def _apply_tif(self, order: Order, tif: Optional[str]) -> None:
if tif is None:
return
order.tif = normalize_tif(tif)

# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def create_bracket(
self,
contract: Contract,
*,
action: str,
quantity: float,
limit_price: float,
take_profit_price: Optional[float] = None,
stop_loss_price: Optional[float] = None,
tif: Optional[str] = None,
transmit: bool = True,
**kwargs: Any,
) -> List[OrderTicket]:
"""Submit a bracket order and return the resulting order tickets.

The method is a structured wrapper around
:meth:`ib_insync.IB.bracketOrder`. Returned orders are immediately
transmitted using :meth:`ib_insync.IB.placeOrder` and normalised into
:class:`OrderTicket` instances.
"""

self._ensure_connection()
parent, take_profit, stop_loss = self._ib.bracketOrder(
action=action,
quantity=quantity,
limitPrice=limit_price,
takeProfitPrice=take_profit_price,
stopLossPrice=stop_loss_price,
**kwargs,
)

for order in (parent, take_profit, stop_loss):
if order is None:
continue
self._apply_tif(order, tif)

active_orders = [o for o in (parent, take_profit, stop_loss) if o is not None]
tickets: List[OrderTicket] = []
for idx, order in enumerate(active_orders):
order.transmit = transmit and idx == len(active_orders) - 1
trade = self._ib.placeOrder(contract, order)
tickets.append(self._normalize_trade(trade))
return tickets

def create_oca(
self,
contract: Contract,
orders: Sequence[Order],
*,
group: str,
oca_type: int = 3,
tif: Optional[str] = None,
transmit: bool = True,
) -> List[OrderTicket]:
"""Submit an OCA (one-cancels-all) order set.

Parameters
----------
contract:
The qualified contract the orders apply to.
orders:
A sequence of pre-configured ``ib_insync.Order`` objects.
group:
The OCA group identifier that will be assigned to each order.
oca_type:
Matches the IB API ``ocaType`` flag. The default of ``3`` mirrors
the behaviour in TWS/IBKR mobile where the remaining orders are
cancelled once one order fills.
tif:
Optional time-in-force override applied to each order.
transmit:
When ``True`` (default) the final order will be transmitted.
"""

self._ensure_connection()
tickets: List[OrderTicket] = []
for index, order in enumerate(orders):
order.ocaGroup = group
order.ocaType = oca_type
order.transmit = transmit and index == len(orders) - 1
self._apply_tif(order, tif)
trade = self._ib.placeOrder(contract, order)
tickets.append(self._normalize_trade(trade))
return tickets

def modify_order(
self,
trade: Trade,
*,
tif: Optional[str] = None,
**updates: Any,
) -> OrderTicket:
"""Modify an active order and return the updated ticket."""

self._ensure_connection()
order = trade.order
for key, value in updates.items():
setattr(order, key, value)
self._apply_tif(order, tif)
self._ib.modifyOrder(order)
return self._normalize_trade(trade)

async def ibkr_stream_trades(self) -> AsyncIterator[OrderTicket]:
"""Yield trades in real-time using ``ib_insync`` trade events."""

self._ensure_connection()
queue: "asyncio.Queue[Trade]" = asyncio.Queue()

def _handler(trade: Trade, fill: Any) -> None: # pragma: no cover - event callback
queue.put_nowait(trade)

self._ib.tradeEvent += _handler
try:
while True:
trade = await queue.get()
yield self._normalize_trade(trade)
finally:
self._ib.tradeEvent -= _handler

def req_market_data(
self,
contract: Contract,
*,
generic_tick_list: str = "",
snapshot: bool = False,
regulatory_snapshot: bool = False,
mkt_data_options: Optional[Sequence[Any]] = None,
) -> Dict[str, Any]:
"""Request market data for the given contract."""

self._ensure_connection()
ticker = self._ib.reqMktData(
contract,
generic_tick_list,
snapshot=snapshot,
regulatorySnapshot=regulatory_snapshot,
mktDataOptions=mkt_data_options,
)
contract_model = (
ContractModel.from_ib(ticker.contract)
if getattr(ticker, "contract", None)
else ContractModel.from_ib(contract)
)
return {
"contract": contract_model.__dict__,
"last": getattr(ticker, "last", None),
Comment on lines +217 to +224

Choose a reason for hiding this comment

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

P1 Badge Avoid dict when returning market-data contract payloads

In req_market_data the returned mapping uses contract_model.__dict__, but ContractModel was declared with slots=True so it lacks a __dict__ attribute. This call path will raise AttributeError before any market data is returned. Use dataclasses.asdict or construct the contract dictionary field by field to avoid the exception.

Useful? React with 👍 / 👎.

"close": getattr(ticker, "close", None),
"bid": getattr(ticker, "bid", None),
"ask": getattr(ticker, "ask", None),
"volume": getattr(ticker, "volume", None),
}

def ensure_permissions(self, *, permissions: Optional[Sequence[str]] = None) -> None:
"""Verify that the current connection has the requested permissions."""

self._ensure_connection()
try:
accounts = set(self._ib.managedAccounts())
except Exception as error: # pragma: no cover - defensive guard
raise PermissionError("Unable to retrieve managed accounts") from error

if permissions:
missing = [perm for perm in permissions if perm not in accounts]
if missing:
raise PermissionError(
"Missing required IBKR permissions: " + ", ".join(missing)
)

def set_client_ratelimits(
self,
*,
pacing_api: bool = True,
max_requests_per_second: Optional[int] = None,
) -> None:
"""Configure pacing options for the underlying IB client."""

self._ensure_connection()
options: List[str] = []
if pacing_api:
options.append("PACEAPI")
if max_requests_per_second is not None:
options.append(f"MaxRequests={max_requests_per_second}")
if options:
self._ib.client.setConnectOptions(";".join(options))

def ibkr_health(self) -> Dict[str, Any]:
"""Return a diagnostic snapshot for health monitoring."""

connected = self._ib.isConnected()
summary: Dict[str, Any] = {
"connected": connected,
"client_id": getattr(self._ib.client, "clientId", None),
"server_version": getattr(self._ib.client, "serverVersion", None),
}
if connected:
summary["server_time"] = self._ib.reqCurrentTime()
summary["accounts"] = self._ib.managedAccounts()
return summary

# Convenience proxies -------------------------------------------------
def list_trades(self) -> List[OrderTicket]:
"""Return currently open trades as :class:`OrderTicket` objects."""

self._ensure_connection()
return normalize_trades(self._ib.trades())

def cancel_order(self, trade: Trade) -> None:
"""Cancel an order associated with the provided trade."""

self._ensure_connection()
self._ib.cancelOrder(trade.order)
Loading