Build a Stock Trading Bot with Claude Code and Interactive Brokers (Python)

intermediate 50 min · · By Alpha Guy · claude-code

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?

FeatureInteractive BrokersBinance
Asset classesStocks, options, futures, forex, bondsCrypto only
Commissions$0.005/share (US stocks), $0.65/contract (options)0.1% spot, 0.02-0.04% futures
Paper tradingBuilt-in, realistic fillsTestnet (separate account)
API maturity20+ years, stable5+ years, frequent changes
RegulationSEC, FINRA, FCA regulatedVaries 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.

  1. Open Trader Workstation and log in with your paper trading credentials
  2. Go to Edit > Global Configuration > API > Settings
  3. Check Enable ActiveX and Socket Clients
  4. Set Socket port to 7497 (paper trading default) or 7496 (live trading)
  5. Check Allow connections from localhost only (for security)
  6. Uncheck Read-Only API (so the bot can place orders)
  7. 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:

  1. Connect to Interactive Brokers TWS on localhost:7497
  2. Trade a configurable US stock (default AAPL)
  3. Strategy: EMA crossover (9 and 21 period) on 5-minute bars
  4. Request 2 days of historical 5-minute bars on startup, then stream real-time bars
  5. Go long when fast EMA crosses above slow EMA, flat when it crosses below
  6. Position sizing: fixed quantity from config (default 10 shares)
  7. Use market orders for entries, limit orders for exits (at last price)
  8. Log every signal, order, and fill to a CSV file with timestamps
  9. Load all config from a .env file using python-dotenv
  10. 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:

  1. Add ib.qualifyContracts(contract) after creating the Stock contract, before requesting any data
  2. The bar size string should be ‘5 mins’ not ‘5 min’ — IBKR requires the plural form
  3. 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:

  1. Maximum position size: configurable, default 100 shares. Never hold more than this.
  2. Daily loss limit: if total realized losses exceed $500 in a day, stop trading and log a warning
  3. Order timeout: if a limit order is not filled within 60 seconds, cancel it and log the cancellation
  4. Market hours check: only trade between 9:30 AM and 3:45 PM ET (avoid the open auction and close volatility)
  5. 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 pytz with America/New_York timezone for market hours checking
  • Implemented order timeout using ib.waitOnUpdate(timeout=60) followed by ib.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=True parameter to reqHistoricalData for live bar updates
  • Removed a duplicate reqRealTimeBars call 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):

  1. Change IB_PORT from 7497 to 7496 in your .env file
  2. Log into TWS with your live credentials instead of paper trading
  3. Reduce QUANTITY to your minimum acceptable trade size
  4. Set DAILY_LOSS_LIMIT to 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:

MetricValue
Total trades14 (7 round trips)
Win rate57% (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

Disclaimer: This article is for educational purposes only and is not financial advice. Trading cryptocurrencies involves substantial risk of loss. Past performance does not guarantee future results. Always do your own research before making any trading decisions. Read full disclaimer →
Alpha Guy
Alpha Guy

Founder of VibeTradingLab. Ex-Goldman Sachs engineer, 2025 Binance Top 1% Trader. Writes about using AI tools to build trading systems that actually work. Currently nomading between Bali, Dubai, and the Mediterranean.

Got stuck? Have questions?

Join our Telegram group to ask questions, share your bots, and connect with other AI traders.

Join Telegram