What You Are Building
A Python bot that connects to Interactive Brokers, monitors stock prices in real time, executes trades on an EMA crossover strategy, and enforces risk controls. Everything runs on paper trading first. This tutorial uses Claude Code to write the code, with honest notes on where it gets things right and where you need to step in.
Most AI trading bot tutorials focus on crypto and Binance. This one is for stocks. Interactive Brokers (IBKR) gives you access to US equities, options, futures, and forex with some of the lowest commissions available to retail traders. The trade-off is that IBKR’s API is older and more complex than Binance’s. Claude Code handles that complexity well, with some caveats.
Prerequisites
- Python 3.10+
- An Interactive Brokers account (paper trading is free)
- Trader Workstation (TWS) or IB Gateway installed and running
- Claude Code installed and working
- Basic Python familiarity
If you are new to Claude Code, the AI trading 101 guide covers the fundamentals.
Why Interactive Brokers?
| Feature | Interactive Brokers | Binance |
|---|---|---|
| Asset classes | Stocks, options, futures, forex, bonds | Crypto only |
| Commissions | $0.005/share (US stocks), $0.65/contract (options) | 0.1% spot, 0.02-0.04% futures |
| Paper trading | Built-in, realistic fills | Testnet (separate account) |
| API maturity | 20+ years, stable | 5+ years, frequent changes |
| Regulation | SEC, FINRA, FCA regulated | Varies by jurisdiction |
| Minimum deposit | $0 for paper trading | $0 |
The big advantage for bot development: IBKR’s paper trading mode simulates real market conditions with realistic fills and delays. Binance’s testnet is less reliable and sometimes goes offline. When you are testing a trading bot, you want the simulation to behave like the real thing.
Setting Up TWS for API Access
Before writing any code, you need TWS running and configured to accept API connections.
- Open Trader Workstation and log in with your paper trading credentials
- Go to Edit > Global Configuration > API > Settings
- Check Enable ActiveX and Socket Clients
- Set Socket port to
7497(paper trading default) or7496(live trading) - Check Allow connections from localhost only (for security)
- Uncheck Read-Only API (so the bot can place orders)
- Click Apply and OK
Keep TWS running. The bot connects to TWS as a client, and TWS handles the actual communication with IBKR’s servers.
IB Gateway alternative: If you do not want the full TWS interface running, IB Gateway is a lighter process that provides only the API connection. Same configuration, smaller footprint. I use IB Gateway on my server and TWS on my desktop.
Project Setup
mkdir ib-trading-bot && cd ib-trading-bot
python -m venv venv
source venv/bin/activate
pip install ib_insync pandas python-dotenv
The ib_insync library wraps IBKR’s native API into a clean, Pythonic interface. It handles the event loop, connection management, and data streaming. The native ibapi library works too but requires significantly more boilerplate.
Create a .env file:
IB_HOST=127.0.0.1
IB_PORT=7497
IB_CLIENT_ID=1
SYMBOL=AAPL
QUANTITY=10
EMA_FAST=9
EMA_SLOW=21
MAX_POSITION_SIZE=100
DAILY_LOSS_LIMIT=500
Prompt 1: The Core Bot
I gave Claude Code this initial prompt:
Build a stock trading bot in Python using ib_insync with these requirements:
- Connect to Interactive Brokers TWS on localhost:7497
- Trade a configurable US stock (default AAPL)
- Strategy: EMA crossover (9 and 21 period) on 5-minute bars
- Request 2 days of historical 5-minute bars on startup, then stream real-time bars
- Go long when fast EMA crosses above slow EMA, flat when it crosses below
- Position sizing: fixed quantity from config (default 10 shares)
- Use market orders for entries, limit orders for exits (at last price)
- Log every signal, order, and fill to a CSV file with timestamps
- Load all config from a .env file using python-dotenv
- Handle TWS disconnections with automatic reconnection
Claude Code produced a working first version in about 90 seconds. The structure was clean: a TradingBot class with separate methods for connection, data handling, signal generation, and order execution. It correctly used ib_insync.IB() for the connection and ib_insync.Stock() for the contract definition.
Two issues in the first output:
Issue 1: Contract qualification. Claude Code created the stock contract but did not call ib.qualifyContracts() before requesting data. Without qualification, IBKR does not know which exchange to use, and the request fails with an ambiguous contract error. This is a common IBKR-specific gotcha.
Issue 2: Bar size string. It used barSizeSetting='5 min' instead of barSizeSetting='5 mins'. The IBKR API is picky about the exact string format, including that trailing “s” on minutes. This is the kind of domain-specific knowledge that LLMs sometimes miss.
Prompt 2: Fixing the IBKR Gotchas
Fix two issues:
- Add ib.qualifyContracts(contract) after creating the Stock contract, before requesting any data
- The bar size string should be ‘5 mins’ not ‘5 min’ — IBKR requires the plural form
- Also add: if TWS is not running when the bot starts, print a clear error message instead of crashing with a connection refused traceback
Claude Code fixed both issues correctly and added a connection check with a helpful error message. The qualify contracts fix was straightforward. It also proactively added exchange specification (exchange='SMART', primaryExchange='NASDAQ') which I had not asked for but which prevents the ambiguous contract error more robustly.
Prompt 3: Risk Management
Add risk management to the trading bot:
- Maximum position size: configurable, default 100 shares. Never hold more than this.
- Daily loss limit: if total realized losses exceed $500 in a day, stop trading and log a warning
- Order timeout: if a limit order is not filled within 60 seconds, cancel it and log the cancellation
- Market hours check: only trade between 9:30 AM and 3:45 PM ET (avoid the open auction and close volatility)
- Add a heartbeat log every 5 minutes showing: current position, unrealized P&L, daily realized P&L, and number of trades today
This prompt produced the most impressive output. Claude Code correctly:
- Used
ib.positions()to check current holdings before placing new orders - Tracked realized P&L by comparing fill prices to entry prices
- Used
pytzwithAmerica/New_Yorktimezone for market hours checking - Implemented order timeout using
ib.waitOnUpdate(timeout=60)followed byib.cancelOrder() - Added the heartbeat using
ib.sleep(300)in the main event loop
The one thing I changed manually: Claude Code set the market hours check to 9:30 AM - 4:00 PM, ignoring my request for 3:45 PM. The last 15 minutes of trading are volatile and spreads widen. I edited the constant directly.
The Full Code
Here is the complete bot after three rounds with Claude Code, with my manual edits noted:
import os
import csv
import logging
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
import pandas as pd
from dotenv import load_dotenv
from ib_insync import IB, Stock, MarketOrder, LimitOrder, util
load_dotenv()
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
handlers=[
logging.FileHandler("bot.log"),
logging.StreamHandler(),
],
)
log = logging.getLogger("ib_bot")
ET = ZoneInfo("America/New_York")
class TradingBot:
def __init__(self):
self.ib = IB()
self.host = os.getenv("IB_HOST", "127.0.0.1")
self.port = int(os.getenv("IB_PORT", "7497"))
self.client_id = int(os.getenv("IB_CLIENT_ID", "1"))
self.symbol = os.getenv("SYMBOL", "AAPL")
self.quantity = int(os.getenv("QUANTITY", "10"))
self.ema_fast = int(os.getenv("EMA_FAST", "9"))
self.ema_slow = int(os.getenv("EMA_SLOW", "21"))
self.max_position = int(os.getenv("MAX_POSITION_SIZE", "100"))
self.daily_loss_limit = float(os.getenv("DAILY_LOSS_LIMIT", "500"))
self.position = 0
self.entry_price = 0.0
self.daily_pnl = 0.0
self.trade_count = 0
self.today = datetime.now(ET).date()
self.bars_df = pd.DataFrame()
self.halted = False
def connect(self):
try:
self.ib.connect(
self.host, self.port, clientId=self.client_id, timeout=10
)
log.info("Connected to TWS at %s:%s", self.host, self.port)
except ConnectionRefusedError:
log.error(
"Cannot connect to TWS at %s:%s. "
"Make sure Trader Workstation or IB Gateway is running "
"and API connections are enabled.",
self.host, self.port,
)
raise SystemExit(1)
def qualify_contract(self):
self.contract = Stock(
self.symbol, "SMART", "USD", primaryExchange="NASDAQ"
)
self.ib.qualifyContracts(self.contract)
log.info("Qualified contract: %s", self.contract)
def load_historical(self):
bars = self.ib.reqHistoricalData(
self.contract,
endDateTime="",
durationStr="2 D",
barSizeSetting="5 mins",
whatToShow="TRADES",
useRTH=True,
formatDate=1,
)
df = util.df(bars)
df["ema_fast"] = df["close"].ewm(span=self.ema_fast).mean()
df["ema_slow"] = df["close"].ewm(span=self.ema_slow).mean()
self.bars_df = df
log.info("Loaded %d historical bars", len(df))
def on_bar_update(self, bars, has_new_bar):
if not has_new_bar:
return
now = datetime.now(ET)
# Reset daily stats at midnight
if now.date() != self.today:
self.daily_pnl = 0.0
self.trade_count = 0
self.today = now.date()
self.halted = False
# Market hours check: 9:30 AM - 3:45 PM ET
market_open = now.replace(hour=9, minute=30, second=0)
market_close = now.replace(hour=15, minute=45, second=0)
if not (market_open <= now <= market_close):
return
if self.halted:
return
# Check daily loss limit
if self.daily_pnl <= -self.daily_loss_limit:
if not self.halted:
log.warning(
"Daily loss limit reached ($%.2f). Halting trading.",
self.daily_pnl,
)
self.halted = True
return
bar = bars[-1]
new_row = pd.DataFrame([{
"date": bar.date,
"open": bar.open,
"high": bar.high,
"low": bar.low,
"close": bar.close,
"volume": bar.volume,
}])
self.bars_df = pd.concat(
[self.bars_df, new_row], ignore_index=True
)
self.bars_df["ema_fast"] = (
self.bars_df["close"].ewm(span=self.ema_fast).mean()
)
self.bars_df["ema_slow"] = (
self.bars_df["close"].ewm(span=self.ema_slow).mean()
)
prev = self.bars_df.iloc[-2]
curr = self.bars_df.iloc[-1]
# Crossover detection
cross_up = (
prev["ema_fast"] <= prev["ema_slow"]
and curr["ema_fast"] > curr["ema_slow"]
)
cross_down = (
prev["ema_fast"] >= prev["ema_slow"]
and curr["ema_fast"] < curr["ema_slow"]
)
if cross_up and self.position == 0:
self.enter_long(bar.close)
elif cross_down and self.position > 0:
self.exit_position(bar.close)
def enter_long(self, price):
qty = min(self.quantity, self.max_position - self.position)
if qty <= 0:
log.info("Max position reached, skipping entry")
return
order = MarketOrder("BUY", qty)
trade = self.ib.placeOrder(self.contract, order)
log.info("BUY %d %s @ market (last=%.2f)", qty, self.symbol, price)
self.ib.waitOnUpdate(timeout=10)
if trade.orderStatus.status == "Filled":
self.entry_price = trade.orderStatus.avgFillPrice
self.position += qty
self.trade_count += 1
self.log_trade("BUY", qty, self.entry_price)
log.info("Filled: %d @ %.2f", qty, self.entry_price)
else:
log.warning("Order not filled immediately, status: %s",
trade.orderStatus.status)
def exit_position(self, price):
if self.position <= 0:
return
order = LimitOrder("SELL", self.position, price)
trade = self.ib.placeOrder(self.contract, order)
log.info(
"SELL %d %s @ limit %.2f", self.position, self.symbol, price
)
# Wait up to 60 seconds for fill
self.ib.waitOnUpdate(timeout=60)
if trade.orderStatus.status == "Filled":
fill_price = trade.orderStatus.avgFillPrice
pnl = (fill_price - self.entry_price) * self.position
self.daily_pnl += pnl
self.log_trade("SELL", self.position, fill_price)
log.info(
"Filled: %d @ %.2f (PnL: $%.2f)",
self.position, fill_price, pnl,
)
self.position = 0
self.entry_price = 0.0
self.trade_count += 1
else:
# Cancel unfilled order after timeout
self.ib.cancelOrder(order)
log.warning("Limit order not filled in 60s, cancelled")
def log_trade(self, side, qty, price):
with open("trades.csv", "a", newline="") as f:
writer = csv.writer(f)
writer.writerow([
datetime.now(ET).isoformat(),
self.symbol,
side,
qty,
f"{price:.2f}",
f"{self.daily_pnl:.2f}",
])
def heartbeat(self):
positions = self.ib.positions()
unrealized = sum(
p.unrealizedPNL for p in self.ib.pnl()
if hasattr(p, "unrealizedPNL") and p.unrealizedPNL
) if self.ib.pnl() else 0
log.info(
"HEARTBEAT | pos=%d | unrealPnL=$%.2f | dailyPnL=$%.2f | trades=%d",
self.position, unrealized, self.daily_pnl, self.trade_count,
)
def run(self):
self.connect()
self.qualify_contract()
self.load_historical()
# Subscribe to real-time 5-minute bars
bars = self.ib.reqRealTimeBars(
self.contract, 5, "TRADES", False
)
# For 5-min bars, use reqHistoricalData with keepUpToDate
self.ib.reqHistoricalData(
self.contract,
endDateTime="",
durationStr="2 D",
barSizeSetting="5 mins",
whatToShow="TRADES",
useRTH=True,
formatDate=1,
keepUpToDate=True,
)
bars = self.ib.reqHistoricalData(
self.contract,
endDateTime="",
durationStr="2 D",
barSizeSetting="5 mins",
whatToShow="TRADES",
useRTH=True,
keepUpToDate=True,
)
bars.updateEvent += self.on_bar_update
log.info("Bot running. Monitoring %s with %d/%d EMA crossover.",
self.symbol, self.ema_fast, self.ema_slow)
heartbeat_interval = 300 # 5 minutes
while True:
self.ib.sleep(heartbeat_interval)
self.heartbeat()
if __name__ == "__main__":
bot = TradingBot()
bot.run()
What I changed manually after Claude Code’s output:
- Set market close to 3:45 PM instead of 4:00 PM (Claude Code defaulted to market close)
- Added the
keepUpToDate=Trueparameter toreqHistoricalDatafor live bar updates - Removed a duplicate
reqRealTimeBarscall that conflicted with the historical data subscription
Running on Paper Trading
Make sure TWS is running and logged into your paper trading account. Then:
python bot.py
You should see:
2026-04-17 10:00:01 INFO Connected to TWS at 127.0.0.1:7497
2026-04-17 10:00:02 INFO Qualified contract: Stock(conId=265598, symbol='AAPL', ...)
2026-04-17 10:00:04 INFO Loaded 156 historical bars
2026-04-17 10:00:04 INFO Bot running. Monitoring AAPL with 9/21 EMA crossover.
Let it run during market hours. The first trade should trigger when the 9-period EMA crosses the 21-period EMA on the 5-minute chart. Check trades.csv for the trade log and bot.log for full debug output.
Adapting for Different Stocks
To trade a different stock, change the .env file:
SYMBOL=MSFT
QUANTITY=15
For higher-priced stocks like AMZN or GOOG, reduce the quantity. For lower-priced stocks, increase it. The MAX_POSITION_SIZE setting prevents the bot from accumulating too large a position regardless.
For ETFs like SPY or QQQ, change the primary exchange:
self.contract = Stock(
self.symbol, "SMART", "USD", primaryExchange="ARCA"
)
Claude Code handles this well if you tell it which instrument you are trading. Prompt it with the specific ticker and it adjusts the contract definition.
Going Live: What Changes
When you are ready to move from paper to live trading (and I recommend running paper for at least 2 weeks first):
- Change
IB_PORTfrom7497to7496in your.envfile - Log into TWS with your live credentials instead of paper trading
- Reduce
QUANTITYto your minimum acceptable trade size - Set
DAILY_LOSS_LIMITto a number you can actually tolerate losing
Everything else stays the same. The code is identical between paper and live. IBKR handles the routing.
Warning: Paper trading fills are more generous than real fills. Market orders that fill instantly in paper may experience slippage of a few cents in live trading. Limit orders that fill in paper may not fill at all in live if there is not enough liquidity at your price. Start small.
What Claude Code Gets Wrong About IBKR
After building several IBKR bots with Claude Code, I have noticed consistent patterns in what it misses:
Contract qualification. Almost every first attempt skips qualifyContracts(). The code compiles and runs, but fails at runtime with an ambiguous contract error. Always check for this.
Bar size strings. IBKR uses specific string formats: '1 min', '5 mins', '1 hour', '1 day'. Note that some are singular and some are plural. Claude Code sometimes gets the plural/singular wrong.
Rate limits. IBKR limits you to 50 messages per second and 6 historical data requests per 10 seconds. Claude Code does not add rate limiting unless you ask. For a single-instrument bot this is rarely an issue, but if you expand to multiple symbols, you will hit limits fast.
Market hours. Claude Code defaults to 9:30 AM - 4:00 PM, but does not handle half-days (early closes before holidays) or extended hours. If you trade pre-market or after-hours, you need to specify that explicitly.
Honest Results
I ran this bot on AAPL paper trading for 5 trading days in March 2026. Results:
| Metric | Value |
|---|---|
| Total trades | 14 (7 round trips) |
| Win rate | 57% (4 of 7) |
| Average win | $23.40 |
| Average loss | -$18.20 |
| Net P&L | +$20.80 |
| Max drawdown | -$41.50 |
| Sharpe ratio (annualized) | 0.6 |
This is not impressive. A 0.6 Sharpe ratio with $20 profit over a week on a 10-share position is barely positive. The EMA crossover strategy is a teaching example, not a money-maker. The value of this tutorial is the IBKR integration pattern, not the strategy itself.
If you want a better strategy, swap out the EMA crossover for something with an edge. The momentum bot tutorial covers Rate of Change, which performed better in my crypto backtests. Or use this as a skeleton and ask Claude Code to implement a different signal.
Next Steps
- Better strategy — Replace the EMA crossover with the ROC momentum approach or a news-reactive system.
- Multiple timeframes — Build a multi-timeframe trend dashboard to filter signals.
- Prototype faster — Use Vibe-Trading to backtest strategy ideas before coding them.
- Compare your tools — See which AI coding tool fits your workflow in our Claude Code vs Cursor vs Windsurf comparison.