From ad9590b1774ba34e961609d212f337020fc901bc Mon Sep 17 00:00:00 2001 From: AlstonLi007 <148654191+AlstonLi007@users.noreply.github.com> Date: Wed, 8 Oct 2025 02:23:31 +0800 Subject: [PATCH 1/3] Add paper trading clients and CLI --- .env.example | 9 ++ .gitignore | 4 + README.md | 29 ++++ openbb_lab/__init__.py | 3 + openbb_lab/cli.py | 109 ++++++++++++++ openbb_lab/execution/__init__.py | 11 ++ openbb_lab/execution/ibkr_client.py | 172 +++++++++++++++++++++++ openbb_lab/execution/tradovate_client.py | 138 ++++++++++++++++++ openbb_lab/version.py | 5 + pyproject.toml | 25 ++++ 10 files changed, 505 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 openbb_lab/__init__.py create mode 100644 openbb_lab/cli.py create mode 100644 openbb_lab/execution/__init__.py create mode 100644 openbb_lab/execution/ibkr_client.py create mode 100644 openbb_lab/execution/tradovate_client.py create mode 100644 openbb_lab/version.py create mode 100644 pyproject.toml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..05f6506 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +IBKR_HOST=127.0.0.1 +IBKR_PORT=7497 +IBKR_CLIENT_ID=7 +TRADOVATE_BASE_URL=https://demo.tradovateapi.com/v1 +TRADOVATE_NAME=your_login_name +TRADOVATE_PASSWORD=your_password +TRADOVATE_APP_ID=YourApp +TRADOVATE_APP_VERSION=1.0 +TRADOVATE_DEVICE_ID=YourDevice diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d328f57 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +.env +.venv/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..ccbfe53 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# OpenBB Lab Broker Clients + +This package exposes lightweight paper-trading clients for Interactive Brokers +(IBKR) via [`ib_insync`](https://github.com/erdewit/ib_insync) and Tradovate's +demo REST API. A Typer-based CLI (`openbb-lab`) provides quick access to common +operations such as placing orders and fetching positions. + +## Installation + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -e . +pip install '.[brokers]' +cp .env.example .env # configure your credentials +``` + +## Usage + +```bash +# IBKR (requires TWS/Gateway in paper mode) +openbb-lab ibkr_order --symbol AAPL --side BUY --qty 1 + +# Tradovate demo REST +openbb-lab tradovate_order --symbol MNQZ5 --side BUY --qty 1 --account_id 123456 +``` + +Both clients expose additional commands for retrieving positions and account +summaries. Use `--help` on each command for the available options. diff --git a/openbb_lab/__init__.py b/openbb_lab/__init__.py new file mode 100644 index 0000000..f061b66 --- /dev/null +++ b/openbb_lab/__init__.py @@ -0,0 +1,3 @@ +"""OpenBB Lab execution toolkit.""" + +from .version import __version__ # noqa: F401 diff --git a/openbb_lab/cli.py b/openbb_lab/cli.py new file mode 100644 index 0000000..287d7e7 --- /dev/null +++ b/openbb_lab/cli.py @@ -0,0 +1,109 @@ +"""Command line interface for broker paper trading.""" + +from __future__ import annotations + +import json +import logging +import typer +from dotenv import load_dotenv + +from .execution import IBKRClient, TradovateClient + +app = typer.Typer(add_completion=False, help="Utilities for paper trading workflows.") + + +def _setup_logging(verbose: bool) -> None: + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig(level=level, format="%(levelname)s %(name)s - %(message)s") + + +@app.callback() +def main(ctx: typer.Context, verbose: bool = typer.Option(False, "--verbose", help="Enable debug logging.")) -> None: + """Entrypoint for the ``openbb-lab`` CLI.""" + + load_dotenv() # load .env if present + _setup_logging(verbose) + + +@app.command("ibkr_order") +def ibkr_order( + symbol: str = typer.Option(..., help="Ticker symbol to trade."), + side: str = typer.Option(..., help="BUY or SELL."), + qty: float = typer.Option(..., help="Order quantity."), + order_type: str = typer.Option("MKT", help="Order type (currently only MKT)."), + exchange: str = typer.Option("SMART", help="Routing exchange."), + currency: str = typer.Option("USD", help="Trading currency."), +) -> None: + """Submit an order to IBKR paper trading.""" + + client = IBKRClient() + result = client.place_order( + symbol=symbol, + side=side, + quantity=qty, + order_type=order_type, + exchange=exchange, + currency=currency, + ) + typer.echo(json.dumps(result, indent=2)) + + +@app.command("ibkr_positions") +def ibkr_positions() -> None: + """Display IBKR open positions.""" + + client = IBKRClient() + typer.echo(json.dumps(client.positions(), indent=2)) + + +@app.command("ibkr_account") +def ibkr_account() -> None: + """Display IBKR account summary.""" + + client = IBKRClient() + typer.echo(json.dumps(client.account_info(), indent=2)) + + +@app.command("tradovate_order") +def tradovate_order( + symbol: str = typer.Option(..., help="Tradovate product symbol."), + side: str = typer.Option(..., help="BUY or SELL."), + qty: float = typer.Option(..., help="Order quantity."), + account_id: int = typer.Option(..., help="Tradovate account identifier."), + order_type: str = typer.Option("Market", help="Order type."), +) -> None: + """Submit an order to the Tradovate demo environment.""" + + client = TradovateClient() + result = client.place_order( + symbol=symbol, + side=side, + quantity=qty, + account_id=account_id, + order_type=order_type, + ) + typer.echo(json.dumps(result, indent=2)) + + +@app.command("tradovate_positions") +def tradovate_positions() -> None: + """Display Tradovate positions.""" + + client = TradovateClient() + typer.echo(json.dumps(client.positions(), indent=2)) + + +@app.command("tradovate_account") +def tradovate_account() -> None: + """Display Tradovate account information.""" + + client = TradovateClient() + typer.echo(json.dumps(client.account_info(), indent=2)) + + +def run() -> None: + app() + + +if __name__ == "__main__": # pragma: no cover + run() diff --git a/openbb_lab/execution/__init__.py b/openbb_lab/execution/__init__.py new file mode 100644 index 0000000..3b32490 --- /dev/null +++ b/openbb_lab/execution/__init__.py @@ -0,0 +1,11 @@ +"""Execution clients for broker integrations.""" + +from .ibkr_client import IBKRClient, IBKRConfig # noqa: F401 +from .tradovate_client import TradovateClient, TradovateConfig # noqa: F401 + +__all__ = [ + "IBKRClient", + "IBKRConfig", + "TradovateClient", + "TradovateConfig", +] diff --git a/openbb_lab/execution/ibkr_client.py b/openbb_lab/execution/ibkr_client.py new file mode 100644 index 0000000..ba37a02 --- /dev/null +++ b/openbb_lab/execution/ibkr_client.py @@ -0,0 +1,172 @@ +"""Interactive Brokers (IBKR) paper trading client.""" + +from __future__ import annotations + +import logging +import os +from dataclasses import dataclass +from typing import Any, Dict, Iterable, List, Optional + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(slots=True) +class IBKRConfig: + """Configuration for connecting to the IBKR gateway or TWS.""" + + host: str = "127.0.0.1" + port: int = 7497 + client_id: int = 7 + + @classmethod + def from_env(cls) -> "IBKRConfig": + """Create a configuration object using environment variables.""" + + host = os.getenv("IBKR_HOST", cls.host) + port = int(os.getenv("IBKR_PORT", cls.port)) + client_id = int(os.getenv("IBKR_CLIENT_ID", cls.client_id)) + return cls(host=host, port=port, client_id=client_id) + + +class IBKRClient: + """Thin wrapper around :mod:`ib_insync` for paper trading workflows.""" + + def __init__(self, config: Optional[IBKRConfig] = None) -> None: + self.config = config or IBKRConfig.from_env() + self._ib = None + + def connect(self) -> Any: + """Connect to IBKR lazily. + + Returns + ------- + ib_insync.IB + An active IB connection. + """ + + if self._ib is not None: + return self._ib + + try: + from ib_insync import IB # type: ignore + except ModuleNotFoundError as exc: # pragma: no cover - import guard + raise RuntimeError( + "ib-insync is required for IBKR operations. Install the " + "'brokers' optional dependencies." + ) from exc + + ib = IB() + _LOGGER.info( + "Connecting to IBKR at %s:%s (client_id=%s)", + self.config.host, + self.config.port, + self.config.client_id, + ) + ib.connect(self.config.host, self.config.port, clientId=self.config.client_id) + self._ib = ib + return ib + + def disconnect(self) -> None: + """Disconnect if an IBKR session has been opened.""" + + if self._ib is not None: + _LOGGER.info("Disconnecting from IBKR") + self._ib.disconnect() + self._ib = None + + # -- trading primitives ------------------------------------------------- + def place_order( + self, + symbol: str, + side: str, + quantity: float, + order_type: str = "MKT", + exchange: str = "SMART", + currency: str = "USD", + ) -> Dict[str, Any]: + """Submit a market order to IBKR paper trading. + + Parameters + ---------- + symbol: + The ticker symbol to trade (e.g. ``"AAPL"``). + side: + ``"BUY"`` or ``"SELL"``. + quantity: + Number of shares / contracts to trade. + order_type: + Supported order type, defaults to market. + exchange: + Preferred routing exchange. + currency: + Trading currency. + """ + + ib = self.connect() + + try: + from ib_insync import MarketOrder, Stock # type: ignore + except ModuleNotFoundError as exc: # pragma: no cover - import guard + raise RuntimeError( + "ib-insync is required for IBKR operations. Install the " + "'brokers' optional dependencies." + ) from exc + + contract = Stock(symbol, exchange, currency) + action = side.upper() + if action not in {"BUY", "SELL"}: + raise ValueError("side must be 'BUY' or 'SELL'") + order = MarketOrder(action, quantity) if order_type.upper() == "MKT" else None + if order is None: + raise NotImplementedError(f"Unsupported order type: {order_type}") + + trade = ib.placeOrder(contract, order) + _LOGGER.info("Placed IBKR order %s", trade.order.orderId) + return { + "order_id": trade.order.orderId, + "status": trade.orderStatus.status, + "filled": trade.orderStatus.filled, + "avg_fill_price": trade.orderStatus.avgFillPrice, + } + + def positions(self) -> List[Dict[str, Any]]: + """Return open positions as plain dictionaries.""" + + ib = self.connect() + raw_positions: Iterable[Any] = ib.positions() + normalized: List[Dict[str, Any]] = [] + for pos in raw_positions: + normalized.append( + { + "account": getattr(pos, "account", None), + "symbol": getattr(pos.contract, "symbol", None), + "position": getattr(pos, "position", None), + "avg_cost": getattr(pos, "avgCost", None), + } + ) + return normalized + + def account_info(self) -> List[Dict[str, Any]]: + """Return the IBKR account summary.""" + + ib = self.connect() + summary: Iterable[Any] = ib.accountSummary() + return [ + { + "tag": getattr(item, "tag", None), + "value": getattr(item, "value", None), + "currency": getattr(item, "currency", None), + } + for item in summary + ] + + # -- context manager ---------------------------------------------------- + def __enter__(self) -> "IBKRClient": + self.connect() + return self + + def __exit__(self, exc_type, exc, tb) -> None: + self.disconnect() + + +__all__ = ["IBKRClient", "IBKRConfig"] diff --git a/openbb_lab/execution/tradovate_client.py b/openbb_lab/execution/tradovate_client.py new file mode 100644 index 0000000..667c3a5 --- /dev/null +++ b/openbb_lab/execution/tradovate_client.py @@ -0,0 +1,138 @@ +"""Tradovate demo REST client.""" + +from __future__ import annotations + +import logging +import os +import time +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(slots=True) +class TradovateConfig: + """Configuration for the Tradovate demo REST API.""" + + base_url: str = "https://demo.tradovateapi.com/v1" + username: Optional[str] = None + password: Optional[str] = None + app_id: Optional[str] = None + app_version: Optional[str] = None + device_id: Optional[str] = None + + @classmethod + def from_env(cls) -> "TradovateConfig": + return cls( + base_url=os.getenv("TRADOVATE_BASE_URL", cls.base_url), + username=os.getenv("TRADOVATE_NAME"), + password=os.getenv("TRADOVATE_PASSWORD"), + app_id=os.getenv("TRADOVATE_APP_ID"), + app_version=os.getenv("TRADOVATE_APP_VERSION"), + device_id=os.getenv("TRADOVATE_DEVICE_ID"), + ) + + +@dataclass +class TradovateToken: + access_token: str + expires_at: float + + @property + def is_expired(self) -> bool: + return time.time() >= self.expires_at + + +@dataclass +class TradovateClient: + """Simple Tradovate REST wrapper suitable for demo trading.""" + + config: TradovateConfig = field(default_factory=TradovateConfig.from_env) + _token: Optional[TradovateToken] = None + + def _request(self): # type: ignore[return-value] + try: + import requests + except ModuleNotFoundError as exc: # pragma: no cover - import guard + raise RuntimeError( + "requests is required for Tradovate operations. Install the " + "'brokers' optional dependencies." + ) from exc + + session = requests.Session() + session.headers.update({"Content-Type": "application/json"}) + return session + + def _ensure_token(self) -> TradovateToken: + if self._token and not self._token.is_expired: + return self._token + + session = self._request() + payload = { + "name": self.config.username, + "password": self.config.password, + "appId": self.config.app_id, + "appVersion": self.config.app_version, + "deviceId": self.config.device_id, + "cid": "rvnd", # Demo requirement per Tradovate docs + "sec": "rvnd", # Demo requirement per Tradovate docs + } + auth_url = f"{self.config.base_url}/auth/accesstokenrequest" + response = session.post(auth_url, json=payload, timeout=30) + response.raise_for_status() + data = response.json() + expires_in = data.get("expiresIn", 0) + token = TradovateToken( + access_token=data["accessToken"], + expires_at=time.time() + float(expires_in), + ) + self._token = token + return token + + def _authorized_headers(self) -> Dict[str, str]: + token = self._ensure_token() + return {"Authorization": f"Bearer {token.access_token}"} + + # -- endpoints ---------------------------------------------------------- + def place_order( + self, + symbol: str, + side: str, + quantity: float, + account_id: int, + order_type: str = "Market", + ) -> Dict[str, Any]: + session = self._request() + session.headers.update(self._authorized_headers()) + + payload = { + "accountId": account_id, + "action": side.upper(), + "symbol": symbol, + "orderQty": quantity, + "orderType": order_type, + } + order_url = f"{self.config.base_url}/orders/placeorder" + response = session.post(order_url, json=payload, timeout=30) + response.raise_for_status() + return response.json() + + def positions(self) -> List[Dict[str, Any]]: + session = self._request() + session.headers.update(self._authorized_headers()) + url = f"{self.config.base_url}/positions" + response = session.get(url, timeout=30) + response.raise_for_status() + return response.json() + + def account_info(self) -> Dict[str, Any]: + session = self._request() + session.headers.update(self._authorized_headers()) + url = f"{self.config.base_url}/accounts" + response = session.get(url, timeout=30) + response.raise_for_status() + return response.json() + + +__all__ = ["TradovateClient", "TradovateConfig"] diff --git a/openbb_lab/version.py b/openbb_lab/version.py new file mode 100644 index 0000000..83552c5 --- /dev/null +++ b/openbb_lab/version.py @@ -0,0 +1,5 @@ +"""Package version information.""" + +__all__ = ["__version__"] + +__version__ = "0.1.0" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a3733af --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools>=67", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "openbb-lab" +version = "0.1.0" +description = "Paper trading clients for IBKR and Tradovate" +readme = "README.md" +authors = [{name = "OpenBB Labs"}] +requires-python = ">=3.9" +dependencies = [ + "typer>=0.9.0", + "python-dotenv>=1.0.0", +] + +[project.optional-dependencies] +brokers = ["ib-insync>=0.9.86", "requests>=2.31"] + +[project.scripts] +openbb-lab = "openbb_lab.cli:app" + +[tool.setuptools.packages.find] +where = ["."] +include = ["openbb_lab*"] From ffec7116264705e6bab75d8346d2f2e7f3a897dc Mon Sep 17 00:00:00 2001 From: AlstonLi007 <148654191+AlstonLi007@users.noreply.github.com> Date: Thu, 9 Oct 2025 10:17:40 -0500 Subject: [PATCH 2/3] Document follow-up improvement ideas --- IMPROVEMENTS.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 IMPROVEMENTS.md diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md new file mode 100644 index 0000000..52e67a4 --- /dev/null +++ b/IMPROVEMENTS.md @@ -0,0 +1,43 @@ +# Suggested Improvements + +The current paper-trading clients and CLI provide a functional baseline, but a few +targeted enhancements could make the project more resilient and extensible: + +## Cross-cutting enhancements + +- **Centralized HTTP session management.** Both broker clients instantiate fresh + network sessions for every call. Establishing persistent sessions (and closing + them explicitly) would reduce overhead and simplify testing by enabling + session-level instrumentation. +- **Credential validation and helpful errors.** Configuration objects should + validate required fields up front and raise descriptive errors when mandatory + credentials or endpoints are missing. This avoids late failures deep inside API + calls. +- **Consistent response typing.** Return types currently vary between raw + dictionaries and lists. Introducing dataclasses or TypedDicts for normalized + payloads would provide clearer contracts for downstream consumers. + +## IBKR client + +- **Support additional order types.** `IBKRClient.place_order` accepts `order_type` + but only handles market orders. Implementing limit/stop orders would align the + method signature with behavior and unlock more realistic workflows. +- **Expose order lifecycle hooks.** The wrapper returns the initial trade status + but does not monitor fills or errors. Surfacing trade callbacks or a polling + utility would help strategies react to execution updates. +- **Connection lifecycle management.** Consider retry/backoff when the TWS + gateway is unavailable instead of raising immediately, and allow users to pass + a preconfigured `ib_insync.IB` instance for advanced scenarios. + +## Tradovate client + +- **Token refresh robustness.** The access token handling assumes the API always + returns `accessToken` and `expiresIn`. Add defensive checks and retries to + handle transient failures or malformed responses. +- **Order validation.** Sanitize `side`, `order_type`, and `quantity` before + submission to catch mistakes locally and surface clearer error messages. +- **WebSocket streaming.** Implement the Tradovate WebSocket feed so positions + and fills update in near real-time, as hinted in the original roadmap. + +These improvements build on the existing structure while keeping tests isolated +from live network calls. From f4c0eb64ed256d3ab96ef8c9385cc0cb45306749 Mon Sep 17 00:00:00 2001 From: AlstonLi007 <148654191+AlstonLi007@users.noreply.github.com> Date: Thu, 9 Oct 2025 10:54:04 -0500 Subject: [PATCH 3/3] Expand IBKR client utilities --- openbb_lab/execution/ibkr_client.py | 241 ++++++++++++++++++++++++++++ 1 file changed, 241 insertions(+) diff --git a/openbb_lab/execution/ibkr_client.py b/openbb_lab/execution/ibkr_client.py index ba37a02..3283c60 100644 --- a/openbb_lab/execution/ibkr_client.py +++ b/openbb_lab/execution/ibkr_client.py @@ -4,6 +4,8 @@ import logging import os +import time +from collections import defaultdict from dataclasses import dataclass from typing import Any, Dict, Iterable, List, Optional @@ -74,6 +76,39 @@ def disconnect(self) -> None: self._ib.disconnect() self._ib = None + def is_connected(self) -> bool: + """Return ``True`` when an active IBKR connection is available.""" + + return bool(self._ib and getattr(self._ib, "isConnected", lambda: False)()) + + def ensure_connected(self) -> Any: + """Ensure a live connection and return the underlying ``IB`` instance.""" + + ib = self._ib + if ib is None or not getattr(ib, "isConnected", lambda: False)(): + ib = self.connect() + return ib + + def refresh_connection(self) -> Any: + """Force a reconnect to IBKR and return the fresh connection.""" + + self.disconnect() + return self.connect() + + def connection_state(self) -> Dict[str, Any]: + """Return metadata describing the current connection state.""" + + ib = self._ib + server_time_attr = getattr(ib, "serverTime", None) if ib else None + server_time = server_time_attr() if callable(server_time_attr) else server_time_attr + return { + "connected": self.is_connected(), + "client_id": self.config.client_id, + "host": self.config.host, + "port": self.config.port, + "server_time": server_time, + } + # -- trading primitives ------------------------------------------------- def place_order( self, @@ -160,6 +195,212 @@ def account_info(self) -> List[Dict[str, Any]]: for item in summary ] + def list_accounts(self) -> List[str]: + """Return the list of managed accounts available to the session.""" + + ib = self.connect() + accounts = ib.managedAccounts() + if isinstance(accounts, str): + return [part for part in accounts.split(",") if part] + return list(accounts or []) + + def open_orders(self) -> List[Dict[str, Any]]: + """Return normalized open orders.""" + + ib = self.connect() + orders: Iterable[Any] = ib.openOrders() + normalized: List[Dict[str, Any]] = [] + for order in orders: + normalized.append(self._normalize_order(order)) + return normalized + + def open_order_ids(self) -> List[int]: + """Return the list of open order identifiers.""" + + return [order["order_id"] for order in self.open_orders() if order.get("order_id") is not None] + + def count_open_orders(self) -> int: + """Return the number of outstanding open orders.""" + + return len(self.open_order_ids()) + + def cancel_order(self, order_id: int) -> bool: + """Attempt to cancel an open order by identifier.""" + + ib = self.connect() + for trade in ib.trades(): + order = getattr(trade, "order", None) + current_id = getattr(order, "orderId", None) + if current_id == order_id: + ib.cancelOrder(order) + return True + return False + + def trades(self) -> List[Dict[str, Any]]: + """Return all known trades in normalized form.""" + + ib = self.connect() + trades: Iterable[Any] = ib.trades() + normalized: List[Dict[str, Any]] = [] + for trade in trades: + order = getattr(trade, "order", None) + status = getattr(trade, "orderStatus", None) + contract = getattr(trade, "contract", None) + normalized.append( + { + "order_id": getattr(order, "orderId", None), + "symbol": getattr(contract, "symbol", None), + "status": getattr(status, "status", None), + "filled": getattr(status, "filled", None), + "remaining": getattr(status, "remaining", None), + "avg_fill_price": getattr(status, "avgFillPrice", None), + } + ) + return normalized + + def trades_by_symbol(self, symbol: str) -> List[Dict[str, Any]]: + """Return trades filtered by symbol.""" + + return [trade for trade in self.trades() if trade.get("symbol") == symbol] + + def fills(self) -> List[Dict[str, Any]]: + """Return fill reports for the current session.""" + + ib = self.connect() + fills: Iterable[Any] = ib.fills() + normalized: List[Dict[str, Any]] = [] + for fill_entry in fills: + fill = fill_entry[0] if isinstance(fill_entry, (tuple, list)) else fill_entry + contract = getattr(fill, "contract", None) + execution = getattr(fill, "execution", None) + normalized.append( + { + "symbol": getattr(contract, "symbol", None), + "side": getattr(execution, "side", None), + "quantity": getattr(execution, "shares", None), + "price": getattr(execution, "price", None), + "time": getattr(execution, "time", None), + "order_id": getattr(execution, "orderId", None), + } + ) + return normalized + + def aggregate_fills(self) -> Dict[str, float]: + """Aggregate filled quantities by symbol.""" + + totals: Dict[str, float] = defaultdict(float) + for fill in self.fills(): + symbol = fill.get("symbol") + quantity = float(fill.get("quantity") or 0) + if symbol: + totals[symbol] += quantity if str(fill.get("side", "")).upper() != "SELL" else -quantity + return dict(totals) + + def portfolio(self) -> List[Dict[str, Any]]: + """Return the IBKR portfolio view as normalized dictionaries.""" + + ib = self.connect() + entries: Iterable[Any] = ib.portfolio() + normalized: List[Dict[str, Any]] = [] + for entry in entries: + contract = getattr(entry, "contract", None) + normalized.append( + { + "symbol": getattr(contract, "symbol", None), + "position": getattr(entry, "position", None), + "market_price": getattr(entry, "marketPrice", None), + "market_value": getattr(entry, "marketValue", None), + "average_cost": getattr(entry, "averageCost", None), + "unrealized_pnl": getattr(entry, "unrealizedPNL", None), + "realized_pnl": getattr(entry, "realizedPNL", None), + } + ) + return normalized + + def market_value_by_symbol(self, symbol: str) -> Optional[float]: + """Return the portfolio market value for ``symbol`` if present.""" + + for entry in self.portfolio(): + if entry.get("symbol") == symbol: + value = entry.get("market_value") + return float(value) if value is not None else None + return None + + def total_position_value(self) -> float: + """Return the aggregate market value of the portfolio.""" + + total = 0.0 + for entry in self.portfolio(): + value = entry.get("market_value") + if value is not None: + total += float(value) + return total + + def positions_by_symbol(self, symbol: str) -> List[Dict[str, Any]]: + """Return open positions filtered by symbol.""" + + return [pos for pos in self.positions() if pos.get("symbol") == symbol] + + def has_position(self, symbol: str) -> bool: + """Return ``True`` if there is any open position for ``symbol``.""" + + return any(self.positions_by_symbol(symbol)) + + def account_value(self, tag: str, currency: Optional[str] = None) -> Optional[str]: + """Return a specific account value tag, optionally filtered by currency.""" + + tag_upper = tag.upper() + for entry in self.account_info(): + if str(entry.get("tag", "")).upper() == tag_upper: + if currency is None or entry.get("currency") == currency: + return entry.get("value") + return None + + def cash_balance(self, currency: str = "USD") -> Optional[float]: + """Return the cash balance for the given currency when available.""" + + value = self.account_value("CashBalance", currency=currency) + if value is None: + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + def wait_for_order_completion( + self, order_id: int, timeout: float = 30.0, poll_interval: float = 1.0 + ) -> Dict[str, Any]: + """Poll order status until it completes or ``timeout`` is reached.""" + + deadline = time.time() + timeout + while time.time() < deadline: + for trade in self.trades(): + if trade.get("order_id") == order_id: + status = str(trade.get("status", "")).upper() + if status in {"FILLED", "CANCELLED"}: + return trade + ib = self.ensure_connected() + wait_fn = getattr(ib, "waitOnUpdate", None) + if callable(wait_fn): + wait_fn(timeout=poll_interval) + else: + time.sleep(poll_interval) + raise TimeoutError(f"Order {order_id} did not complete within {timeout} seconds") + + @staticmethod + def _normalize_order(order: Any) -> Dict[str, Any]: + """Normalize an order object to a dictionary.""" + + return { + "order_id": getattr(order, "orderId", None), + "action": getattr(order, "action", None), + "total_quantity": getattr(order, "totalQuantity", None), + "order_type": getattr(order, "orderType", None), + "tif": getattr(order, "tif", None), + "lmt_price": getattr(order, "lmtPrice", None), + "aux_price": getattr(order, "auxPrice", None), + } + # -- context manager ---------------------------------------------------- def __enter__(self) -> "IBKRClient": self.connect()