Build a Stock Trading Bot with Alpaca MCP and Claude Code (Python)

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

What You Are Building

A Python trading bot that connects to Alpaca’s official MCP V2 server to fetch live US stock data, calculate moving average signals, and execute paper trades through Claude Code. Alpaca released MCP V2 in April 2026 with 61 tools covering stocks, ETFs, options, and crypto. This tutorial walks you through connecting the server, writing a simple strategy, and running it against Alpaca’s paper trading environment.

Why Alpaca MCP

Alpaca is the most popular commission-free trading API for algorithmic traders. Their V2 MCP server makes AI-assisted stock trading much simpler because you no longer need to write API wrapper code. Claude Code calls the MCP tools directly. You describe what you want in plain English and the AI handles the API calls.

Here is what Alpaca MCP V2 exposes compared to V1:

FeatureV1 (43 tools)V2 (61 tools)
Stock ordersMarket, limitMarket, limit, stop, trailing stop
OptionsNot supportedFull chain exploration, multi-leg spreads
CryptoBasicFull trading support
ScreeningNot availableBuilt-in market screener
AccountBalance onlyActivity logs, order history, positions
Order managementPlace onlyPlace, replace, cancel, bracket orders

If you have followed our Bybit MCP tutorial for crypto, the workflow here is similar. The main difference is Alpaca focuses on US equities and options instead of crypto futures.

Prerequisites

  • Python 3.10+
  • An Alpaca account (free, paper trading enabled by default)
  • Claude Code installed and working
  • Node.js 18+ (for the MCP server)
  • Basic Python familiarity

Step 1: Get Alpaca API Keys

Alpaca gives you separate keys for paper and live trading:

  1. Sign up at alpaca.markets
  2. Go to the Paper Trading dashboard
  3. Click API Keys and generate a new key pair
  4. Save the API key ID and secret key

Paper trading uses real market data but simulated execution. Your orders fill against real prices but no actual money moves.

Step 2: Install the Alpaca MCP Server

Alpaca’s official V2 server is on npm:

npm install -g @alpacahq/mcp-server

Or install from the GitHub repo:

git clone https://github.com/alpacahq/alpaca-mcp-server.git ~/alpaca-mcp
cd ~/alpaca-mcp
npm install

Step 3: Configure Claude Code

Add the Alpaca MCP server to your Claude Code configuration:

nano ~/.claude/.mcp.json
{
  "mcpServers": {
    "alpaca": {
      "command": "npx",
      "args": ["-y", "@alpacahq/mcp-server"],
      "env": {
        "ALPACA_API_KEY": "your-paper-api-key",
        "ALPACA_API_SECRET": "your-paper-secret",
        "ALPACA_PAPER": "true"
      }
    }
  }
}

Keep ALPACA_PAPER set to true until you have tested your strategy thoroughly. Switching to live trading is a one-line change, but you should run paper for at least two weeks first.

If you already run other MCP servers (like TradingView MCP or Bybit MCP), add the Alpaca block alongside them in mcpServers.

Step 4: Verify the Connection

Restart Claude Code and test:

What's my Alpaca paper trading buying power?

Claude should return your account info through the MCP tools. The default paper account starts with $100,000 in buying power. If you get an error, check that your API keys are correct and that the MCP server path is valid.

The Strategy: Moving Average Crossover

We will build a classic MA crossover strategy for SPY (S&P 500 ETF):

  1. Fetch daily OHLCV bars for the last 100 trading days
  2. Calculate a 20-day simple moving average (fast) and a 50-day SMA (slow)
  3. Buy when the fast MA crosses above the slow MA (golden cross)
  4. Sell when the fast MA crosses below the slow MA (death cross)
  5. Use a 2% stop-loss on every position
  6. One position at a time, sized at 20% of portfolio value

This is a teaching strategy, not a profitable system out of the box. The point is to show how market data, signal logic, and order execution connect through Alpaca’s API. Tuning it for real returns takes separate work.

Prompt 1: Core Bot Structure

Here is what I gave Claude Code:

Build a Python stock trading bot for SPY using Alpaca’s API with these specs:

  1. Use the alpaca-py library to connect to Alpaca paper trading
  2. Fetch 100 daily bars of OHLCV data for SPY
  3. Calculate 20-day and 50-day simple moving averages using pandas
  4. Buy when the 20-day SMA crosses above the 50-day SMA
  5. Sell when the 20-day SMA crosses below the 50-day SMA
  6. Position size: 20% of portfolio equity per trade
  7. Set a 2% stop-loss on every buy order using a trailing stop
  8. Only one open position at a time — check existing positions first
  9. Run in a loop, checking every 5 minutes during market hours
  10. Log every signal, order, and fill to console and CSV
  11. Skip trading when market is closed
  12. Load API keys from environment variables

Claude Code generated a solid first draft. The main issues:

  • It used the older alpaca-trade-api library instead of alpaca-py
  • Market hours check did not account for half-days or holidays
  • No handling for partial fills

Prompt 2: Fix Library and Market Hours

Fix these issues in the Alpaca bot:

  1. Switch to the alpaca-py library (from alpaca.trading.client import TradingClient)
  2. Use Alpaca’s built-in clock endpoint to check if the market is open instead of hardcoding hours
  3. Add proper error handling for InsufficientFunds, APIError, and connection timeouts
  4. Log the account equity and buying power on each loop iteration

Prompt 3: Risk Controls

Add risk management to the Alpaca bot:

  1. Maximum 2 trades per day — reset counter at market open
  2. Weekly loss limit: if net realized loss exceeds $500 for the week, stop trading until Monday
  3. Track win/loss count and log stats every 5 trades
  4. Add a kill switch: if file “STOP” exists in the working directory, exit gracefully
  5. Send a summary log entry at market close with day’s PnL

The Full Code

Here is the complete bot after three rounds of prompting with manual corrections noted in comments:

import os
import time
import logging
import csv
from datetime import datetime, timezone, timedelta
from pathlib import Path
from dataclasses import dataclass, field
import pandas as pd
from alpaca.trading.client import TradingClient
from alpaca.trading.requests import (
    MarketOrderRequest,
    TrailingStopOrderRequest,
    GetAssetsRequest,
)
from alpaca.trading.enums import OrderSide, TimeInForce, OrderType
from alpaca.data.historical import StockHistoricalDataClient
from alpaca.data.requests import StockBarsRequest
from alpaca.data.timeframe import TimeFrame

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[
        logging.FileHandler("alpaca_bot.log"),
        logging.StreamHandler(),
    ],
)
logger = logging.getLogger(__name__)

# --- Config ---
API_KEY = os.environ.get("ALPACA_API_KEY", "")
API_SECRET = os.environ.get("ALPACA_API_SECRET", "")
PAPER = os.environ.get("ALPACA_PAPER", "true").lower() == "true"
SYMBOL = "SPY"
FAST_MA = 20
SLOW_MA = 50
POSITION_SIZE_PCT = 0.20
STOP_LOSS_PCT = 0.02
MAX_TRADES_PER_DAY = 2
WEEKLY_LOSS_LIMIT = -500.0
CHECK_INTERVAL = 300  # 5 minutes


@dataclass
class BotState:
    position_qty: float = 0.0
    entry_price: float = 0.0
    trades_today: int = 0
    today_date: str = ""
    weekly_pnl: float = 0.0
    week_start: str = ""
    total_trades: int = 0
    wins: int = 0
    losses: int = 0


def get_trading_client():
    return TradingClient(API_KEY, API_SECRET, paper=PAPER)


def get_data_client():
    return StockHistoricalDataClient(API_KEY, API_SECRET)


def fetch_bars(data_client, symbol: str, limit: int = 100):
    """Fetch daily bars and return a DataFrame."""
    end = datetime.now(timezone.utc)
    start = end - timedelta(days=limit * 2)  # Fetch extra to account for weekends
    request = StockBarsRequest(
        symbol_or_symbols=symbol,
        timeframe=TimeFrame.Day,
        start=start,
        end=end,
        limit=limit,
    )
    bars = data_client.get_stock_bars(request)
    df = bars.df.reset_index()
    # Handle MultiIndex if multiple symbols
    if "symbol" in df.columns:
        df = df[df["symbol"] == symbol]
    df = df.sort_values("timestamp").tail(limit).reset_index(drop=True)
    return df


def calc_signals(df: pd.DataFrame) -> pd.DataFrame:
    """Add moving averages and crossover signals."""
    df["sma_fast"] = df["close"].rolling(window=FAST_MA).mean()
    df["sma_slow"] = df["close"].rolling(window=SLOW_MA).mean()
    df["prev_fast"] = df["sma_fast"].shift(1)
    df["prev_slow"] = df["sma_slow"].shift(1)
    return df


def check_market_open(trading_client) -> bool:
    """Use Alpaca's clock endpoint to check market status."""
    clock = trading_client.get_clock()
    return clock.is_open


def get_account_info(trading_client) -> dict:
    account = trading_client.get_account()
    return {
        "equity": float(account.equity),
        "buying_power": float(account.buying_power),
        "cash": float(account.cash),
    }


def get_position(trading_client, symbol: str) -> dict:
    """Check if we hold a position in this symbol."""
    try:
        pos = trading_client.get_open_position(symbol)
        return {
            "qty": float(pos.qty),
            "entry_price": float(pos.avg_entry_price),
            "market_value": float(pos.market_value),
            "unrealized_pnl": float(pos.unrealized_pl),
        }
    except Exception:
        return {"qty": 0, "entry_price": 0, "market_value": 0, "unrealized_pnl": 0}


def place_buy(trading_client, symbol: str, qty: float, trail_pct: float):
    """Place a market buy with a trailing stop."""
    try:
        # Market buy
        buy_request = MarketOrderRequest(
            symbol=symbol,
            qty=round(qty, 0),
            side=OrderSide.BUY,
            time_in_force=TimeInForce.DAY,
        )
        buy_order = trading_client.submit_order(buy_request)
        logger.info(f"BUY order submitted: {qty:.0f} shares of {symbol}")

        # Trailing stop for risk management
        stop_request = TrailingStopOrderRequest(
            symbol=symbol,
            qty=round(qty, 0),
            side=OrderSide.SELL,
            time_in_force=TimeInForce.GTC,
            trail_percent=trail_pct * 100,
        )
        stop_order = trading_client.submit_order(stop_request)
        logger.info(f"Trailing stop set at {trail_pct*100:.1f}%")

        return buy_order
    except Exception as e:
        logger.error(f"Buy order failed: {e}")
        return None


def place_sell(trading_client, symbol: str, qty: float):
    """Sell entire position at market."""
    try:
        # Cancel any open stop orders first
        trading_client.cancel_orders()
        time.sleep(1)

        sell_request = MarketOrderRequest(
            symbol=symbol,
            qty=round(qty, 0),
            side=OrderSide.SELL,
            time_in_force=TimeInForce.DAY,
        )
        order = trading_client.submit_order(sell_request)
        logger.info(f"SELL order submitted: {qty:.0f} shares of {symbol}")
        return order
    except Exception as e:
        logger.error(f"Sell order failed: {e}")
        return None


def log_trade(action, price, sma_fast, sma_slow, pnl=0.0):
    with open("trades.csv", "a", newline="") as f:
        writer = csv.writer(f)
        writer.writerow([
            datetime.now(timezone.utc).isoformat(),
            action,
            round(price, 2),
            round(sma_fast, 2),
            round(sma_slow, 2),
            round(pnl, 2),
        ])


def run():
    trading_client = get_trading_client()
    data_client = get_data_client()
    state = BotState()
    state.today_date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
    state.week_start = datetime.now(timezone.utc).strftime("%Y-%W")

    # CSV header
    if not Path("trades.csv").exists():
        with open("trades.csv", "w", newline="") as f:
            writer = csv.writer(f)
            writer.writerow(["timestamp", "action", "price", "sma_fast", "sma_slow", "pnl"])

    logger.info(f"Bot started | Symbol: {SYMBOL} | Paper: {PAPER}")
    logger.info(f"Strategy: SMA({FAST_MA}/{SLOW_MA}) crossover")

    while True:
        # Kill switch
        if Path("STOP").exists():
            logger.info("Kill switch activated. Exiting.")
            break

        # Check market hours
        if not check_market_open(trading_client):
            logger.info("Market closed. Waiting.")
            time.sleep(CHECK_INTERVAL)
            continue

        # Reset daily counters
        today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
        if today != state.today_date:
            state.trades_today = 0
            state.today_date = today

        # Reset weekly PnL
        this_week = datetime.now(timezone.utc).strftime("%Y-%W")
        if this_week != state.week_start:
            logger.info(f"New week. Last week PnL: ${state.weekly_pnl:.2f}")
            state.weekly_pnl = 0.0
            state.week_start = this_week

        # Check weekly loss limit
        if state.weekly_pnl <= WEEKLY_LOSS_LIMIT:
            logger.warning(f"Weekly loss limit hit (${state.weekly_pnl:.2f}). Paused until next week.")
            time.sleep(CHECK_INTERVAL)
            continue

        # Check daily trade limit
        if state.trades_today >= MAX_TRADES_PER_DAY:
            logger.info(f"Max daily trades reached ({MAX_TRADES_PER_DAY}). Waiting.")
            time.sleep(CHECK_INTERVAL)
            continue

        try:
            # Get account info
            account = get_account_info(trading_client)
            logger.info(f"Equity: ${account['equity']:,.2f} | Buying power: ${account['buying_power']:,.2f}")

            # Fetch data and calculate signals
            df = fetch_bars(data_client, SYMBOL)
            df = calc_signals(df)

            current = df.iloc[-1]
            previous = df.iloc[-2]
            price = current["close"]
            sma_fast = current["sma_fast"]
            sma_slow = current["sma_slow"]
            prev_fast = previous["sma_fast"]
            prev_slow = previous["sma_slow"]

            logger.info(f"{SYMBOL}: ${price:.2f} | SMA{FAST_MA}: ${sma_fast:.2f} | SMA{SLOW_MA}: ${sma_slow:.2f}")

            # Check current position
            pos = get_position(trading_client, SYMBOL)
            has_position = pos["qty"] > 0

            # Golden cross: fast MA crosses above slow MA
            golden_cross = prev_fast <= prev_slow and sma_fast > sma_slow
            # Death cross: fast MA crosses below slow MA
            death_cross = prev_fast >= prev_slow and sma_fast < sma_slow

            if not has_position and golden_cross:
                trade_value = account["equity"] * POSITION_SIZE_PCT
                qty = int(trade_value / price)
                if qty > 0:
                    result = place_buy(trading_client, SYMBOL, qty, STOP_LOSS_PCT)
                    if result:
                        state.trades_today += 1
                        state.total_trades += 1
                        log_trade("BUY", price, sma_fast, sma_slow)

            elif has_position and death_cross:
                pnl = pos["unrealized_pnl"]
                result = place_sell(trading_client, SYMBOL, pos["qty"])
                if result:
                    state.trades_today += 1
                    state.total_trades += 1
                    state.weekly_pnl += pnl
                    if pnl > 0:
                        state.wins += 1
                    else:
                        state.losses += 1
                    log_trade("SELL", price, sma_fast, sma_slow, pnl)

            # Log stats every 5 trades
            if state.total_trades > 0 and state.total_trades % 5 == 0:
                wr = state.wins / state.total_trades * 100
                logger.info(f"Stats: {state.total_trades} trades | {wr:.1f}% win rate")

        except Exception as e:
            logger.error(f"Error in main loop: {e}")

        time.sleep(CHECK_INTERVAL)


if __name__ == "__main__":
    run()

What Claude Code Got Right

  • The alpaca-py library usage was correct after the second prompt. The TradingClient, StockHistoricalDataClient, and order request objects all matched Alpaca’s current API.
  • Using the clock endpoint for market hours is the right approach. Hardcoding “9:30 AM to 4 PM ET” breaks on holidays and half-days.
  • The trailing stop order is placed separately from the market buy. Alpaca does not support bracket orders with trailing stops in a single request, so this two-order approach is correct.

What Needed Manual Fixes

  • Library choice: Claude Code initially used alpaca-trade-api (the old library). The current library is alpaca-py with imports from alpaca.trading.client. I corrected this in Prompt 2.
  • Quantity rounding: Alpaca requires whole share quantities for most stocks. Claude Code initially passed float quantities, which caused API errors. The fix was wrapping quantities in int() or round(qty, 0).
  • Cancel-before-sell: When closing a position, you need to cancel existing stop orders first. Otherwise Alpaca rejects the sell because the stop order already has a sell queued for those shares. Claude Code missed this on the first pass.

Running the Bot

pip install alpaca-py pandas
export ALPACA_API_KEY="your-paper-key"
export ALPACA_API_SECRET="your-paper-secret"
export ALPACA_PAPER="true"
python alpaca_bot.py

The bot only trades during market hours (9:30 AM - 4:00 PM ET on regular trading days). Outside those hours, it logs “Market closed” and waits. To stop the bot cleanly:

touch STOP

Checking Results

Watch the console output and review trades.csv for a complete log. You can also check your paper trading dashboard at app.alpaca.markets to see orders and positions in real time.

Moving to Live Trading

Before switching to real money:

  1. Run on paper for at least 2 weeks to understand the strategy’s behavior across different market conditions
  2. Review trades.csv for patterns. Is the bot overtrading? Are stops getting hit right after entry?
  3. Start with a small allocation (5-10% of your planned capital)
  4. Change ALPACA_PAPER to false and use your live API keys
  5. Monitor the first week closely

Warning: This bot trades real US stocks with real money when paper mode is off. The strategy in this tutorial is for educational purposes. Past performance in backtesting or paper trading does not guarantee live results. Only trade with money you can afford to lose.

What to Build Next

  • Add a volume filter to avoid signals on low-volume days (holidays, pre-earnings quiet periods)
  • Combine with TradingView MCP for visual chart confirmation before executing trades
  • Expand to multiple symbols by running the same strategy on QQQ, IWM, and DIA alongside SPY
  • Add options hedging with Alpaca’s new options support: buy protective puts when the bot enters a long position
  • Try the momentum strategy instead of MA crossover for a different approach to trend following
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