Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
15 changes: 15 additions & 0 deletions TRADING_SYSTEM_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
4 changes: 4 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
129 changes: 129 additions & 0 deletions scripts/place_option_order_ibkr.py
Original file line number Diff line number Diff line change
@@ -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()
135 changes: 135 additions & 0 deletions tests/test_ibkr_broker.py
Original file line number Diff line number Diff line change
@@ -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'
Loading
Loading