From 0cb6969ab8f89e2b8b3155e5bdb7c6073bf8e212 Mon Sep 17 00:00:00 2001 From: jasonzb Date: Sat, 9 May 2026 16:43:52 -0400 Subject: [PATCH] Add IBKR broker support via ib_insync (TWS-required, paper by default) Adds Interactive Brokers as an alternate broker for single-leg equity option orders. Mirrors the surface and logging style of SafeCashBot's option methods so call sites can swap brokers without changes. - utils/ibkr_broker.py: IBKRBroker class wrapping ib_insync. Lazy-imports ib_insync so dry-run paths work fully offline (no TWS connection attempted). Banner / [OK] / [ERR] / [WARN] / '='*70 dividers match safe_cash_bot.py. - scripts/place_option_order_ibkr.py: CLI wrapper mirroring the RH script. Defaults to dry-run; --live requires TWS / IB Gateway on 127.0.0.1:7497. - tests/test_ibkr_broker.py: smoke tests with mocked ib_insync. Asserts dry_run=True does not connect or placeOrder; dry_run=False connects exactly once and calls placeOrder once with a LimitOrder of the right action / quantity / lmtPrice. - requirements.txt: add ib_insync>=0.9.86 (only needed for live). - .env.example: add IBKR_HOST / IBKR_PORT / IBKR_CLIENT_ID / IBKR_PAPER. - TRADING_SYSTEM_GUIDE.md: append IBKR setup section covering TWS / Gateway install, paper login, API socket port + read-only flag, trusted IP. Defaults to paper (port 7497). Live (7496) is opt-in via IBKR_PORT and IBKR_PAPER=false. Account is not yet funded; this PR scaffolds the plumbing. --- .env.example | 8 + TRADING_SYSTEM_GUIDE.md | 15 ++ requirements.txt | 4 + scripts/place_option_order_ibkr.py | 129 ++++++++++ tests/test_ibkr_broker.py | 135 ++++++++++ utils/ibkr_broker.py | 379 +++++++++++++++++++++++++++++ 6 files changed, 670 insertions(+) create mode 100644 scripts/place_option_order_ibkr.py create mode 100644 tests/test_ibkr_broker.py create mode 100644 utils/ibkr_broker.py diff --git a/.env.example b/.env.example index aacd43b..ff6fd74 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,11 @@ # Paid tier ($99/mo) gives SIP NBBO (full market) # ALPACA_API_KEY=your_key # ALPACA_SECRET_KEY=your_secret + +# Optional: Interactive Brokers (IBKR) execution via TWS / IB Gateway +# Live IBKR orders require TWS or IB Gateway running locally with the API +# socket enabled. Defaults below are for paper trading. +# IBKR_HOST=127.0.0.1 +# IBKR_PORT=7497 # 7497 = paper, 7496 = live +# IBKR_CLIENT_ID=1 +# IBKR_PAPER=true # set to "false" only when intentionally trading the live account diff --git a/TRADING_SYSTEM_GUIDE.md b/TRADING_SYSTEM_GUIDE.md index 17d3730..01b2fcd 100644 --- a/TRADING_SYSTEM_GUIDE.md +++ b/TRADING_SYSTEM_GUIDE.md @@ -311,3 +311,18 @@ For issues or questions: - Review component test outputs - Inspect `trading_state.json` for current state - Check Robinhood account for order status + +## IBKR setup + +Live IBKR orders require Trader Workstation (TWS) or IB Gateway running locally. +Dry runs (`python scripts/place_option_order_ibkr.py ... buy` without `--live`) +do not connect at all. + +1. Download TWS or IB Gateway from interactivebrokers.com. +2. Sign in using your **Paper Trading** account (paper username, not live). +3. In TWS: `File -> Global Configuration -> API -> Settings`. +4. Tick **Enable ActiveX and Socket Clients**. +5. Set **Socket port** to `7497` (paper) or `7496` (live), matching `IBKR_PORT`. +6. Untick **Read-Only API** so orders can actually route. +7. (Optional) Add `127.0.0.1` to **Trusted IPs** to skip the connect popup. +8. Run live: `python scripts/place_option_order_ibkr.py XLY 2026-09-18 120 call 1 4.50 buy --live`. diff --git a/requirements.txt b/requirements.txt index 6bf89c2..6beabd4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,7 @@ fpdf2>=2.7.0 # Optional: Execution quality cross-referencing # alpaca-py>=0.30.0 # Note: uncomment above if you want Alpaca NBBO fill auditing + +# Optional: Interactive Brokers (IBKR) execution via TWS / IB Gateway +# Required only when running live IBKR orders (dry-run paths import lazily) +ib_insync>=0.9.86 diff --git a/scripts/place_option_order_ibkr.py b/scripts/place_option_order_ibkr.py new file mode 100644 index 0000000..9b2dc50 --- /dev/null +++ b/scripts/place_option_order_ibkr.py @@ -0,0 +1,129 @@ +""" +Place a single-leg option order against IBKR via TWS / IB Gateway. + +Dry runs work fully offline - no TWS connection is attempted. Live orders require +TWS or IB Gateway running locally with the API socket enabled (default port 7497 +for paper). See `TRADING_SYSTEM_GUIDE.md` ("IBKR setup") for the one-time setup. + +Usage: + # Dry run (no TWS connection) + python scripts/place_option_order_ibkr.py XLY 2026-09-18 120 call 1 4.50 buy + + # Live (requires TWS/IB Gateway running on 127.0.0.1:7497) + python scripts/place_option_order_ibkr.py XLY 2026-09-18 120 call 1 4.50 buy --live + python scripts/place_option_order_ibkr.py XLY 2026-09-18 120 call 1 6.75 sell --live +""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from utils.ibkr_broker import IBKRBroker # noqa: E402 + + +USAGE = """\ +Usage: + python scripts/place_option_order_ibkr.py SYMBOL EXPIRATION STRIKE TYPE QTY PRICE SIDE [--live] + + SYMBOL Underlying ticker (e.g. XLY) + EXPIRATION YYYY-MM-DD (e.g. 2026-09-18) + STRIKE Strike price (e.g. 120) + TYPE put | call + QTY Number of contracts (1 = 100 shares) + PRICE Limit price per contract share (e.g. 4.50) + SIDE buy -> buy-to-open + sell -> sell-to-close (must already hold the contract) + --live Send to broker. Default is DRY RUN (no TWS connection). + +Examples: + Dry-run open: python scripts/place_option_order_ibkr.py XLY 2026-09-18 120 call 1 4.50 buy + Live open: python scripts/place_option_order_ibkr.py XLY 2026-09-18 120 call 1 4.50 buy --live + Live close: python scripts/place_option_order_ibkr.py XLY 2026-09-18 120 call 1 6.75 sell --live + +Live orders require TWS or IB Gateway running locally with API access enabled. +Default paper port is 7497 (live is 7496). Override via IBKR_HOST / IBKR_PORT. +""" + + +def main(): + if len(sys.argv) < 8: + print(USAGE) + sys.exit(1) + try: + symbol = sys.argv[1].upper() + expiration = sys.argv[2] + strike = float(sys.argv[3]) + option_type = sys.argv[4].lower() + quantity = int(sys.argv[5]) + price = float(sys.argv[6]) + side = sys.argv[7].lower() + dry_run = '--live' not in sys.argv + except (ValueError, IndexError) as e: + print(f"[ERR] Could not parse args: {e}\n") + print(USAGE) + sys.exit(1) + + if option_type not in ('put', 'call'): + print(f"[ERR] TYPE must be 'put' or 'call', got {option_type!r}") + sys.exit(1) + if side not in ('buy', 'sell'): + print(f"[ERR] SIDE must be 'buy' or 'sell', got {side!r}") + sys.exit(1) + if quantity <= 0: + print("[ERR] QTY must be positive") + sys.exit(1) + if price <= 0: + print("[ERR] PRICE must be positive") + sys.exit(1) + + broker = IBKRBroker() + try: + try: + if side == 'buy': + order = broker.place_option_buy_limit_order( + symbol=symbol, + expiration=expiration, + strike=strike, + option_type=option_type, + quantity=quantity, + price=price, + dry_run=dry_run, + ) + else: + order = broker.place_option_sell_limit_order( + symbol=symbol, + expiration=expiration, + strike=strike, + option_type=option_type, + quantity=quantity, + price=price, + dry_run=dry_run, + ) + except ImportError as e: + print(f"\n[ERR] {e}\n") + sys.exit(2) + + if isinstance(order, dict): + status = order.get('status') + if status == 'submitted': + print(f"\n[OK] Order ID: {order.get('order_id')} state={order.get('order_status', 'N/A')}\n") + elif status == 'dry_run': + print("\n[OK] Dry run completed - no real order placed (no TWS connection attempted)\n") + elif status == 'error': + print(f"\n[ERR] Order failed: {order.get('reason')}\n") + sys.exit(2) + elif status == 'rejected': + print(f"\n[ERR] Order rejected: {order.get('reason')}\n") + sys.exit(2) + else: + print(f"\n[ERR] Unknown order status: {order}\n") + sys.exit(2) + else: + print(f"\n[ERR] Unexpected response: {order}\n") + sys.exit(2) + finally: + broker.disconnect() + + +if __name__ == "__main__": + main() diff --git a/tests/test_ibkr_broker.py b/tests/test_ibkr_broker.py new file mode 100644 index 0000000..cfc0b8b --- /dev/null +++ b/tests/test_ibkr_broker.py @@ -0,0 +1,135 @@ +""" +Smoke tests for utils.ibkr_broker.IBKRBroker. + +We never connect to a real TWS / IB Gateway here; everything is mocked. +The two assertions matter: + +1. dry_run=True must NOT call connect() or placeOrder() (so the engine can be + imported, banner-printed, and dry-run-tested with no TWS at all). +2. dry_run=False (with a mocked ib_insync.IB) must call connect() then + placeOrder() exactly once with a LimitOrder of the right action / quantity / + limit price. +""" + +import os +import sys +from unittest.mock import MagicMock, patch + +# Make the repo root importable regardless of where pytest is run from. +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +def _fake_ib_insync(): + """Build a stand-in for the ib_insync module with the symbols we use.""" + fake = MagicMock(name='ib_insync') + + # Option(symbol, expiration_yyyymmdd, strike, right, exchange=, currency=) + def _option(symbol, expiry, strike, right, exchange='SMART', currency='USD'): + c = MagicMock(name='Option') + c.symbol = symbol + c.lastTradeDateOrContractMonth = expiry + c.strike = strike + c.right = right + c.exchange = exchange + c.currency = currency + c.secType = 'OPT' + return c + + fake.Option.side_effect = _option + + # LimitOrder(action, quantity, lmtPrice) + def _limit_order(action, quantity, lmt_price): + o = MagicMock(name='LimitOrder') + o.action = action + o.totalQuantity = quantity + o.lmtPrice = lmt_price + o.tif = 'GTC' + return o + + fake.LimitOrder.side_effect = _limit_order + + # IB() instance + ib_instance = MagicMock(name='IB-instance') + ib_instance.isConnected.return_value = False # forces connect() path + + trade = MagicMock(name='trade') + trade.order.orderId = 4242 + trade.orderStatus.status = 'Submitted' + ib_instance.placeOrder.return_value = trade + + fake.IB.return_value = ib_instance + return fake, ib_instance + + +def test_dry_run_does_not_connect_or_place_order(): + """dry_run=True must never touch ib_insync.IB at all.""" + fake, ib_instance = _fake_ib_insync() + + with patch.dict(sys.modules, {'ib_insync': fake}): + from utils.ibkr_broker import IBKRBroker + + broker = IBKRBroker(host='127.0.0.1', port=7497, client_id=1, paper=True) + result = broker.place_option_buy_limit_order( + symbol='XLY', + expiration='2026-09-18', + strike=120.0, + option_type='call', + quantity=1, + price=4.50, + dry_run=True, + ) + + assert result['status'] == 'dry_run' + assert result['action'] == 'BUY' + assert result['symbol'] == 'XLY' + assert result['quantity'] == 1 + assert result['price'] == 4.50 + # No connection or order should have happened. + ib_instance.connect.assert_not_called() + ib_instance.placeOrder.assert_not_called() + fake.LimitOrder.assert_not_called() + + +def test_live_run_connects_and_places_one_order_with_correct_limit(): + """dry_run=False must connect() and call placeOrder() exactly once with a LimitOrder of the right shape.""" + fake, ib_instance = _fake_ib_insync() + + with patch.dict(sys.modules, {'ib_insync': fake}): + from utils.ibkr_broker import IBKRBroker + + broker = IBKRBroker(host='127.0.0.1', port=7497, client_id=1, paper=True) + result = broker.place_option_buy_limit_order( + symbol='XLY', + expiration='2026-09-18', + strike=120.0, + option_type='call', + quantity=2, + price=4.50, + dry_run=False, + ) + + # connect() called exactly once + ib_instance.connect.assert_called_once_with('127.0.0.1', 7497, clientId=1) + + # placeOrder() called exactly once with (contract, order) + ib_instance.placeOrder.assert_called_once() + args, _ = ib_instance.placeOrder.call_args + assert len(args) == 2 + contract, order = args + + # Contract: XLY 20260918 120 C + assert contract.symbol == 'XLY' + assert contract.lastTradeDateOrContractMonth == '20260918' + assert contract.strike == 120.0 + assert contract.right == 'C' + + # Order: BUY, qty 2, limit 4.50 + fake.LimitOrder.assert_called_once_with('BUY', 2, 4.50) + assert order.action == 'BUY' + assert order.totalQuantity == 2 + assert order.lmtPrice == 4.50 + + # Result envelope + assert result['status'] == 'submitted' + assert result['order_id'] == 4242 + assert result['order_status'] == 'Submitted' diff --git a/utils/ibkr_broker.py b/utils/ibkr_broker.py new file mode 100644 index 0000000..3968a53 --- /dev/null +++ b/utils/ibkr_broker.py @@ -0,0 +1,379 @@ +""" +Interactive Brokers (IBKR) broker for single-leg equity option orders. + +Wraps `ib_insync` to mirror the surface of `SafeCashBot.place_option_buy_limit_order` / +`place_option_sell_limit_order` so the rest of the engine can swap brokers without +changing call sites. + +EXECUTION REQUIRES TWS / IB GATEWAY +----------------------------------- +IBKR has no public REST endpoint for order placement; routing goes through the +Trader Workstation (TWS) or IB Gateway desktop process listening on a local +socket. So `dry_run=True` works fully offline (no `connect()` call), but +`dry_run=False` requires TWS or IB Gateway running locally with API access +enabled. See `TRADING_SYSTEM_GUIDE.md` ("IBKR setup") for the one-time +configuration. + +Defaults to PAPER trading (port 7497). Set `IBKR_PAPER=false` and use port 7496 +for live. +""" + +import os +import sys +from datetime import datetime + +from dotenv import load_dotenv + + +def _load_ib_insync(): + """Lazy import so dry-run paths don't require ib_insync at all.""" + try: + import ib_insync # type: ignore + except ImportError as e: + raise ImportError( + "ib_insync is required for IBKR live execution. " + "Install with: pip install ib_insync" + ) from e + return ib_insync + + +class IBKRBroker: + """ + IBKR broker wrapper for single-leg equity option orders via TWS / IB Gateway. + + Logging style mirrors SafeCashBot: + - section dividers '=' * 70 + - [OK] / [ERR] / [WARN] prefixes + - banner shows DRY RUN vs LIVE and PAPER vs LIVE account + """ + + def __init__( + self, + host: str = '127.0.0.1', + port: int = 7497, + client_id: int = 1, + paper: bool = True, + ): + load_dotenv() + + # Allow env vars to override constructor defaults so a single + # IBKRBroker() invocation respects deployment config. + self.host = os.getenv('IBKR_HOST', host) + self.port = int(os.getenv('IBKR_PORT', port)) + self.client_id = int(os.getenv('IBKR_CLIENT_ID', client_id)) + + env_paper = os.getenv('IBKR_PAPER') + if env_paper is not None: + self.paper = env_paper.strip().lower() in ('1', 'true', 'yes', 'on') + else: + self.paper = paper + + self._ib = None # ib_insync.IB instance (lazy) + + print(f"\n{'=' * 70}") + print(f"[LOCKED] IBKR BROKER ({'PAPER' if self.paper else 'LIVE'})") + print(f"{'=' * 70}") + print(f" Host: {self.host}") + print(f" Port: {self.port}") + print(f" Client ID: {self.client_id}") + print(f"{'=' * 70}\n") + + # ------------------------------------------------------------------ + # Connection + # ------------------------------------------------------------------ + + def connect(self) -> None: + """Open a TWS / IB Gateway socket connection. Errors with a clear hint.""" + ib_insync = _load_ib_insync() + if self._ib is None: + self._ib = ib_insync.IB() + if self._ib.isConnected(): + return + try: + self._ib.connect(self.host, self.port, clientId=self.client_id) + print(f"[OK] Connected to TWS at {self.host}:{self.port} (clientId={self.client_id})") + except Exception as e: + raise ConnectionError( + f"Could not connect to TWS at {self.host}:{self.port}. " + f"Start TWS in Paper Trading mode and ensure 'Enable ActiveX and Socket Clients' " + f"is on under API Settings. Underlying error: {e}" + ) from e + + def disconnect(self) -> None: + """Close the TWS connection if open.""" + if self._ib is not None and self._ib.isConnected(): + try: + self._ib.disconnect() + print("[OK] Disconnected from TWS") + except Exception as e: + print(f"[WARN] Disconnect failed: {e}") + + # ------------------------------------------------------------------ + # Account / quote helpers + # ------------------------------------------------------------------ + + def get_account(self) -> dict: + """Return account summary tags from TWS as a {tag: value} dict.""" + self.connect() + try: + summary = self._ib.accountSummary() + except Exception as e: + print(f"[ERR] accountSummary() failed: {e}") + return {} + out = {} + for row in summary or []: + tag = getattr(row, 'tag', None) + value = getattr(row, 'value', None) + if tag is not None: + out[tag] = value + return out + + def get_option_quote(self, symbol, expiration, strike, option_type) -> dict: + """ + Snapshot bid/ask/last for a single equity option contract. + + expiration: 'YYYY-MM-DD' + option_type: 'put' or 'call' + """ + ib_insync = _load_ib_insync() + right = self._right(option_type) + if right is None: + return {} + + self.connect() + # IBKR option contracts use YYYYMMDD (no dashes) for lastTradeDateOrContractMonth. + ib_expiry = expiration.replace('-', '') + contract = ib_insync.Option(symbol, ib_expiry, float(strike), right, exchange='SMART', currency='USD') + try: + self._ib.qualifyContracts(contract) + ticker = self._ib.reqMktData(contract, '', False, False) + self._ib.sleep(1.0) # let the snapshot populate + quote = { + 'symbol': symbol, + 'expiration': expiration, + 'strike': float(strike), + 'option_type': option_type.lower(), + 'bid': getattr(ticker, 'bid', None), + 'ask': getattr(ticker, 'ask', None), + 'last': getattr(ticker, 'last', None), + } + self._ib.cancelMktData(contract) + return quote + except Exception as e: + print(f"[ERR] get_option_quote failed: {e}") + return {} + + # ------------------------------------------------------------------ + # Orders + # ------------------------------------------------------------------ + + def place_option_buy_limit_order( + self, + symbol, + expiration, + strike, + option_type, + quantity, + price, + dry_run: bool = True, + time_in_force: str = 'GTC', + ) -> dict: + """Single-leg limit BUY-TO-OPEN on an equity option via IBKR.""" + return self._place_option_order( + action='BUY', + symbol=symbol, + expiration=expiration, + strike=strike, + option_type=option_type, + quantity=quantity, + price=price, + dry_run=dry_run, + time_in_force=time_in_force, + label='OPTION BUY ORDER', + money_label='Total Debit', + ) + + def place_option_sell_limit_order( + self, + symbol, + expiration, + strike, + option_type, + quantity, + price, + dry_run: bool = True, + time_in_force: str = 'GTC', + ) -> dict: + """Single-leg limit SELL-TO-CLOSE on an equity option via IBKR.""" + return self._place_option_order( + action='SELL', + symbol=symbol, + expiration=expiration, + strike=strike, + option_type=option_type, + quantity=quantity, + price=price, + dry_run=dry_run, + time_in_force=time_in_force, + label='OPTION SELL (CLOSE) ORDER', + money_label='Total Credit', + ) + + def _place_option_order( + self, + *, + action: str, + symbol, + expiration, + strike, + option_type, + quantity, + price, + dry_run: bool, + time_in_force: str, + label: str, + money_label: str, + ) -> dict: + option_type = option_type.lower() + right = self._right(option_type) + if right is None: + return {'status': 'rejected', 'reason': f"option_type must be 'put' or 'call', got {option_type!r}"} + + if quantity is None or int(quantity) <= 0: + return {'status': 'rejected', 'reason': f'quantity must be positive, got {quantity!r}'} + if price is None or float(price) <= 0: + return {'status': 'rejected', 'reason': f'price must be positive, got {price!r}'} + + notional = int(quantity) * float(price) * 100 + mode = 'DRY RUN' if dry_run else ('LIVE-PAPER' if self.paper else 'LIVE-REAL') + + print(f"\n{'=' * 70}") + print(f"{label} - {mode} (IBKR)") + print(f"{'=' * 70}") + print(f" Broker: IBKR ({'paper' if self.paper else 'live'}) {self.host}:{self.port}") + print(f" Contract: {symbol} {expiration} ${float(strike):.2f} {option_type.upper()}") + print(f" Quantity: {int(quantity)} contract(s) ({int(quantity) * 100} shares)") + print(f" Limit Price: ${float(price):.2f} per share") + print(f" {money_label}: ${notional:.2f}") + print(f" TIF: {time_in_force.upper()}") + + if dry_run: + print("\n[WARN] DRY RUN MODE - Order not executed (no TWS connection attempted)") + print(" To execute real orders, call with dry_run=False (requires TWS / IB Gateway running)") + print(f"{'=' * 70}\n") + return { + 'status': 'dry_run', + 'broker': 'ibkr', + 'action': action, + 'symbol': symbol, + 'expiration': expiration, + 'strike': float(strike), + 'option_type': option_type, + 'quantity': int(quantity), + 'price': float(price), + 'time_in_force': time_in_force.upper(), + 'paper': self.paper, + } + + ib_insync = _load_ib_insync() + try: + self.connect() + except ConnectionError as e: + print(f"\n[ERR] {e}") + print(f"{'=' * 70}\n") + return {'status': 'error', 'reason': str(e)} + + ib_expiry = expiration.replace('-', '') + contract = ib_insync.Option(symbol, ib_expiry, float(strike), right, exchange='SMART', currency='USD') + try: + self._ib.qualifyContracts(contract) + except Exception as e: + print(f"[WARN] qualifyContracts failed (proceeding): {e}") + + order = ib_insync.LimitOrder(action, int(quantity), float(price)) + order.tif = time_in_force.upper() + + try: + print(f"\nExecuting option {action} order on IBKR...") + trade = self._ib.placeOrder(contract, order) + order_id = getattr(getattr(trade, 'order', None), 'orderId', None) + order_status = getattr(getattr(trade, 'orderStatus', None), 'status', None) + print("[OK] IBKR option order placed!") + print(f" Order ID: {order_id}") + print(f" State: {order_status or 'N/A'}") + print(f"{'=' * 70}\n") + return { + 'status': 'submitted', + 'broker': 'ibkr', + 'order_id': order_id, + 'order_status': order_status, + 'action': action, + 'symbol': symbol, + 'expiration': expiration, + 'strike': float(strike), + 'option_type': option_type, + 'quantity': int(quantity), + 'price': float(price), + 'time_in_force': time_in_force.upper(), + 'paper': self.paper, + } + except Exception as e: + print(f"[ERR] IBKR option {action} order failed: {e}") + print(f"{'=' * 70}\n") + return {'status': 'error', 'reason': str(e)} + + # ------------------------------------------------------------------ + # Positions + # ------------------------------------------------------------------ + + def get_open_option_positions(self) -> list: + """Return open option positions reported by TWS as a list of dicts.""" + self.connect() + try: + positions = self._ib.positions() + except Exception as e: + print(f"[ERR] positions() failed: {e}") + return [] + + out = [] + for p in positions or []: + contract = getattr(p, 'contract', None) + sec_type = getattr(contract, 'secType', None) if contract else None + if sec_type != 'OPT': + continue + out.append({ + 'symbol': getattr(contract, 'symbol', None), + 'expiration': getattr(contract, 'lastTradeDateOrContractMonth', None), + 'strike': getattr(contract, 'strike', None), + 'right': getattr(contract, 'right', None), + 'quantity': getattr(p, 'position', None), + 'avg_cost': getattr(p, 'avgCost', None), + 'account': getattr(p, 'account', None), + }) + return out + + # ------------------------------------------------------------------ + # Internals + # ------------------------------------------------------------------ + + @staticmethod + def _right(option_type) -> str: + ot = (option_type or '').lower() + if ot == 'call': + return 'C' + if ot == 'put': + return 'P' + print(f"[ERR] option_type must be 'put' or 'call', got {option_type!r}") + return None + + +def main(): + """Smoke entrypoint: print the banner only, no TWS connection.""" + print("\nIBKR Broker (utils/ibkr_broker.py)") + print(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n") + IBKRBroker() + print("Note: dry-run paths require no TWS. Use --live in scripts to attempt a real connect.\n") + + +if __name__ == "__main__": + main()