You can use Claude Code to help build a trading bot without asking it to generate a large, opaque system in one prompt. A better first project is a small Python program with one understandable strategy rule, an explicit paper-trading boundary, and tests you can run before allowing it to submit even a simulated order.

In this tutorial, you will build a starter bot that requests recent one-minute stock bars from Alpaca, calculates a simple moving-average crossover, prints a signal, and—with a separate command-line flag—can submit a one-share order to an Alpaca paper-trading account. The default run only reports a signal; it does not send an order. The trading client is constructed with paper=True, and the script refuses to run unless PAPER_TRADING=true is set.

This is a software-development exercise, not a profitable strategy. Claude Code helps write and review code; the example bot itself uses a deterministic moving-average rule. It does not ask a language model to make live trading decisions.

What you will build

The minimal project contains:

  • paper_bot.py — fetches recent bars, calculates a crossover signal, and optionally submits one-share paper orders.
  • .env — stores your paper-account API credentials outside the source code.
  • .gitignore — prevents local credentials and state files from being committed.
  • bot_state.json — records the most recently processed bar and whether this demo opened a one-share position.
  • test_signals.py — tests the crossover function without connecting to a broker.

The bot processes one check each time you run it. It does not run continuously, schedule itself, or manage a complete portfolio. Keep that small scope deliberate: it makes the code easier to inspect and the first failures easier to diagnose.

1. Install Claude Code and prepare Python

Follow Anthropic's current Claude Code setup guide for your operating system. After installation, open a terminal in the folder where you want the project and confirm that the claude command starts correctly. Claude Code can inspect project files, explain code, propose edits, and help run tests, but you should review each change instead of approving a large project rewrite without inspection.

Install Python 3.10 or newer. In Windows PowerShell, create a project folder and virtual environment:

mkdir claude-paper-bot
cd claude-paper-bot
py -m venv .venv
.\.venv\Scripts\python.exe -m pip install --upgrade pip
.\.venv\Scripts\python.exe -m pip install alpaca-py python-dotenv pandas pytest

On macOS or Linux, use python3 -m venv .venv and the virtual environment's Python executable under .venv/bin/python instead. Use the same interpreter for installation, tests, and running the script so that the packages are installed into the environment the project actually uses.

2. Create a paper-only Alpaca account and API keys

Alpaca provides a paper-trading environment for testing code with simulated orders. Its paper-only account documentation says the account uses separate paper credentials and that paper-account users are limited to IEX market data. Read the current paper-trading guide and authentication reference before setting up credentials.

Create a paper-only account, then generate the paper API key and secret in the dashboard. Do not copy live-account credentials into this project. The example below is explicitly configured to use paper trading, but keeping paper and live credentials separate is still essential.

Create a file named .env in the project folder:

ALPACA_API_KEY=PASTE_YOUR_PAPER_KEY_HERE
ALPACA_SECRET_KEY=PASTE_YOUR_PAPER_SECRET_HERE
PAPER_TRADING=true

Replace the two placeholder values locally. Do not paste your real keys into Claude Code prompts, source files, screenshots, error reports, or chat messages. The script loads the values from .env at runtime.

Create .gitignore with:

.env
.venv/
__pycache__/
.pytest_cache/
bot_state.json

If .env was ever committed to a repository, deleting it in a later commit is not enough: revoke or rotate the exposed credentials.

3. Define the rule before writing the bot

The example uses a five-period and twenty-period simple moving average (SMA) calculated from one-minute closing prices. It reports BUY when the shorter average crosses from at-or-below the longer average to above it. It reports SELL for the inverse crossover. Otherwise, it reports HOLD.

Those numbers are teaching parameters, not optimized values. A moving-average crossover can produce false signals, react late, and perform poorly after costs. The purpose is to demonstrate the data-to-signal-to-paper-order path, not to recommend a strategy.

The program must also follow explicit boundaries:

  • It processes only one symbol per run, defaulting to SPY.
  • It uses one-minute bars from the IEX feed and conservatively excludes the most recently returned bar, which may still be forming.
  • It never shorts. A SELL signal only closes the single long share this demo previously recorded as opening.
  • It never adds to an existing position. If the account already holds the symbol unexpectedly, the program stops rather than guessing who owns that position.
  • It never connects to a live trading endpoint. The trading client is created with paper=True in code, and the environment flag must equal true.
  • It does not add stop orders, continuous scheduling, or automatic deployment.

These restrictions are intentionally conservative. A real trading system would need more complete portfolio reconciliation, fill handling, order-state recovery, limits, and monitoring.

4. Create paper_bot.py

Use the following as the first implementation. It uses Alpaca's official Python SDK to retrieve historical bars and, only when explicitly requested, submit a paper order. See the official stock-bars request reference and order examples if the SDK changes.

import argparse
import json
import os
import re
import sys
import time
from pathlib import Path

from dotenv import load_dotenv
from alpaca.data.enums import DataFeed
from alpaca.data.historical import StockHistoricalDataClient
from alpaca.data.requests import StockBarsRequest
from alpaca.data.timeframe import TimeFrame
from alpaca.trading.client import TradingClient
from alpaca.trading.enums import OrderSide, TimeInForce
from alpaca.trading.requests import MarketOrderRequest

FAST = 5
SLOW = 20
ORDER_QTY = 1
ORDER_POLL_TIMEOUT_SECONDS = 30
ORDER_POLL_INTERVAL_SECONDS = 1
STATE_PATH = Path("bot_state.json")


def crossover_signal(closes, fast=FAST, slow=SLOW):
    """Return BUY, SELL, or HOLD from completed closing prices."""
    if fast < 1 or slow <= fast:
        raise ValueError("Require 1 <= fast < slow")
    if len(closes) < slow + 1:
        raise ValueError(f"Need at least {slow + 1} completed closes")

    prior_fast = sum(closes[-fast-1:-1]) / fast
    prior_slow = sum(closes[-slow-1:-1]) / slow
    current_fast = sum(closes[-fast:]) / fast
    current_slow = sum(closes[-slow:]) / slow

    if prior_fast <= prior_slow and current_fast > current_slow:
        return "BUY"
    if prior_fast >= prior_slow and current_fast < current_slow:
        return "SELL"
    return "HOLD"


def read_state():
    if not STATE_PATH.exists():
        return {"last_processed_bar": None, "demo_position_open": False}
    try:
        return json.loads(STATE_PATH.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        raise SystemExit(f"Cannot read {STATE_PATH}: {exc}") from exc


def write_state(last_bar, position_open):
    data = {
        "last_processed_bar": last_bar,
        "demo_position_open": bool(position_open),
    }
    temporary = STATE_PATH.with_suffix(".tmp")
    temporary.write_text(json.dumps(data, indent=2), encoding="utf-8")
    temporary.replace(STATE_PATH)


def main():
    parser = argparse.ArgumentParser(description="One-pass paper-trading tutorial bot")
    parser.add_argument("--symbol", default="SPY", help="US stock/ETF ticker, e.g. SPY")
    parser.add_argument(
        "--submit-paper-order",
        action="store_true",
        help="Allow one-share orders in the paper account; without this, no order is sent",
    )
    args = parser.parse_args()
    symbol = args.symbol.upper().strip()
    if not re.fullmatch(r"[A-Z][A-Z0-9.]{0,9}", symbol):
        raise SystemExit("Invalid symbol format")

    load_dotenv()
    api_key = os.getenv("ALPACA_API_KEY", "").strip()
    secret_key = os.getenv("ALPACA_SECRET_KEY", "").strip()
    if os.getenv("PAPER_TRADING", "").strip().lower() != "true":
        raise SystemExit("ABORT: this tutorial only runs with PAPER_TRADING=true")
    if not api_key or not secret_key:
        raise SystemExit("Missing ALPACA_API_KEY or ALPACA_SECRET_KEY in .env")

    data_client = StockHistoricalDataClient(api_key, secret_key)
    trading_client = TradingClient(api_key, secret_key, paper=True)
    request = StockBarsRequest(
        symbol_or_symbols=[symbol],
        timeframe=TimeFrame.Minute,
        limit=50,
        feed=DataFeed.IEX,
    )

    response = data_client.get_stock_bars(request)
    bars = sorted(response[symbol], key=lambda bar: bar.timestamp)
    if len(bars) < SLOW + 2:
        raise SystemExit("Not enough bars returned; check the symbol, market session, and data access")

    # Exclude the newest returned bar so the example does not use a bar
    # that may still be forming.
    completed_bars = bars[:-1]
    closes = [float(bar.close) for bar in completed_bars]
    signal = crossover_signal(closes)
    bar_timestamp = completed_bars[-1].timestamp.isoformat()
    last_close = closes[-1]

    print(f"Symbol: {symbol}")
    print(f"Last included bar: {bar_timestamp}")
    print(f"Last included close: {last_close:.2f}")
    print(f"Signal: {signal}")

    state = read_state()
    if state.get("last_processed_bar") == bar_timestamp:
        print("This completed bar was already processed; no duplicate action taken.")
        return

    if signal == "HOLD":
        write_state(bar_timestamp, state.get("demo_position_open", False))
        print("No crossover. No order sent.")
        return

    if not args.submit_paper_order:
        print("Signal only. No order sent. Review the code before enabling paper orders.")
        print("To allow a paper order on a future signal, add --submit-paper-order.")
        return

    clock = trading_client.get_clock()
    if not clock.is_open:
        raise SystemExit("Market is closed; no paper order sent")

    positions = trading_client.get_all_positions()
    current_position = next((p for p in positions if p.symbol == symbol), None)
    position_open = bool(state.get("demo_position_open", False))

    if signal == "BUY":
        if position_open or current_position is not None:
            raise SystemExit("Position already exists or state disagrees; inspect the paper account before proceeding")
        side = OrderSide.BUY
    else:
        if not position_open:
            write_state(bar_timestamp, False)
            print("SELL signal, but this demo has no recorded position to close. No order sent.")
            return
        if current_position is None or float(current_position.qty) != ORDER_QTY:
            raise SystemExit("Position does not match the demo's one-share state; no order sent")
        if str(current_position.side).lower().split(".")[-1] != "long":
            raise SystemExit("Unexpected position side; no order sent")
        side = OrderSide.SELL

    client_order_id = f"claude-{symbol.lower()}-{signal.lower()}-{completed_bars[-1].timestamp.strftime('%Y%m%d%H%M')}"
    order = trading_client.submit_order(
        order_data=MarketOrderRequest(
            symbol=symbol,
            qty=ORDER_QTY,
            side=side,
            time_in_force=TimeInForce.DAY,
            client_order_id=client_order_id,
        )
    )
    print(f"Paper order submitted: {order.id}")
    print(f"Client order ID: {client_order_id}")
    deadline = time.monotonic() + ORDER_POLL_TIMEOUT_SECONDS
    while time.monotonic() < deadline:
        order = trading_client.get_order_by_id(order.id)
        order_status = str(order.status).lower().split(".")[-1]
        filled_qty = float(order.filled_qty or 0)
        print(f"Order status: {order_status}; filled quantity: {filled_qty:g}")
        if order_status == "filled":
            if filled_qty == ORDER_QTY:
                next_position_state = signal == "BUY"
                write_state(bar_timestamp, next_position_state)
                print("Confirmed full one-share fill; demo position state saved.")
            else:
                print(
                    "The order reports filled, but its filled quantity does not match "
                    "the one-share request. Inspect the paper account and reconcile "
                    "the order before running the script again. No automatic retry "
                    "was made."
                )
            return
        if order_status in {"rejected", "canceled", "expired"} or filled_qty > 0:
            print(
                "The order was not confirmed as a full one-share fill. Inspect the "
                "paper account and reconcile the order before running the script "
                "again. No automatic retry was made."
            )
            return
        remaining = deadline - time.monotonic()
        if remaining > 0:
            time.sleep(min(ORDER_POLL_INTERVAL_SECONDS, remaining))
    print(
        "Order status is still unresolved after the polling timeout. Inspect the "
        "paper account and reconcile the order before running the script again. "
        "No automatic retry was made."
    )


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        print("Interrupted by user.", file=sys.stderr)
        raise SystemExit(130)

The script deliberately requires --submit-paper-order before submitting anything. If an API request times out, do not assume the order failed: inspect the paper account's orders before trying again. The state file prevents processing the same completed bar twice after the state is saved, but it is not a substitute for checking broker order history after an uncertain network result.

5. Test the signal function before connecting to the API

Create test_signals.py:

from paper_bot import crossover_signal


def test_buy_cross():
    closes = [100.0] * 20 + [100.0, 100.0, 100.0, 100.0, 101.0]
    assert crossover_signal(closes) == "BUY"


def test_sell_cross():
    closes = [100.0] * 20 + [100.0, 100.0, 100.0, 100.0, 99.0]
    assert crossover_signal(closes) == "SELL"


def test_no_cross():
    assert crossover_signal([100.0] * 25) == "HOLD"

Run the tests:

.\.venv\Scripts\python.exe -m pytest -q

Expected result: three tests pass. If they fail, fix the signal calculation and rerun the tests before using any API key. These tests check the crossover function only; they do not verify market data quality, order handling, or profitability.

6. Run the bot without orders first

Run a signal-only pass:

.\.venv\Scripts\python.exe paper_bot.py --symbol SPY

The program should print the symbol, timestamp of the last included bar, close, and one of BUY, SELL, or HOLD. Without --submit-paper-order, it will not place an order. If there are too few bars, if the symbol is unavailable, or if credentials are invalid, it should stop with an error instead of inventing a signal.

The signal may be HOLD most of the time. That is normal for a crossover rule: it only returns BUY or SELL when the fast average crosses the slow average between the prior and current windows. A signal is not a prediction of future returns.

7. Allow a one-share paper order only after review

Only after reviewing the code and seeing the test suite pass, you can run:

.\.venv\Scripts\python.exe paper_bot.py --symbol SPY --submit-paper-order

This option may submit a one-share paper market order if the latest completed-bar signal, account position, recorded demo state, and market-open check all permit it. After submission, the script polls the order for up to 30 seconds and updates bot_state.json only when the broker reports a full one-share fill. A rejection, cancellation, partial fill, inconsistent filled quantity, or timeout leaves the intended position state unsaved. Inspect the paper account and reconcile the order before running the script again; the example never retries an uncertain order automatically.

Use a dedicated paper account with no unrelated positions in the symbol. The example intentionally stops if it finds an unexpected position or a mismatch between its small local state file and the broker account. Resolve any mismatch manually before running it again. Do not schedule the script to run unattended at this stage, and do not change paper=True to paper=False.

8. Use Claude Code as a developer, not as the trading authority

Once you have created the three files, start Claude Code from the project directory. Ask it to inspect the project and review the implementation before asking for new features. Anthropic's Claude Code documentation describes setup and project sessions; the CLI reference documents command-line usage.

Use prompts one stage at a time. For example:

Prompt 1 — Inspect, don't edit

Inspect paper_bot.py, test_signals.py, .env.example if present, and .gitignore. Explain the control flow, identify possible API or state-handling bugs, and list tests that are missing. Do not edit files, read secret values, connect to Alpaca, or submit orders.

Prompt 2 — Add tests only

Add unit tests for the crossover function's boundary conditions and invalid input. Do not change paper_bot.py unless you first explain why a change is essential. Do not read .env, contact any API, submit orders, or execute trading actions. Run only the local test suite and summarize the results.

Prompt 3 — Review paper-order safety

Review the paper-order path for duplicate submissions, position mismatches, failed requests, and unsafe assumptions. Do not submit orders or access the account. Report findings first; wait for approval before changing code.

Do not ask Claude to “make the bot profitable.” It can help implement and test defined logic, but it cannot manufacture a trading edge by changing parameters until a backtest looks good. Any change to strategy rules should be specified, tested on data not used to tune the rules, and documented.

9. Understand what this prototype does not prove

Alpaca describes paper trading as a simulation. Its paper environment does not fully reproduce factors such as market impact, latency slippage, queue position for limit orders, price improvement, regulatory fees, or dividends. IEX data is also not the same as a consolidated view of every US venue. Read the current paper-trading limitations before interpreting results.

This starter does not implement a full backtester, stop-loss logic, portfolio-wide exposure limits, continuous scheduling, alerting, or robust order reconciliation. It also does not demonstrate positive expected returns. Those are separate engineering and research steps, not automatic upgrades to add without testing.

Before making a bot persistent, develop and test at least:

  • Data checks for missing, duplicate, stale, and out-of-order bars.
  • Idempotent order handling and recovery after a timeout or restart.
  • Position and open-order reconciliation against the broker.
  • Explicit exposure and loss limits, with fail-closed behavior.
  • Logs, alerts, a manual stop mechanism, and an incident-recovery plan.
  • Backtests that account for fees, spread, slippage, and look-ahead bias.

The detailed Trading Bot Architecture, Trading Bot APIs, Testing Trading Bots, Trading Bot Risk Management, and Monitoring Trading Bots guides cover those additional topics.

Frequently asked questions

Does Claude Code make the trading decisions?

Not in this example. Claude Code helps write and review the program. The running script uses explicit moving-average rules written in Python. This separation makes it easier to inspect what generated each signal.

Does the example place real orders?

No. The code requires PAPER_TRADING=true and constructs the trading client with paper=True. It does not have a live-trading mode. Keep live credentials out of the project.

Why use a moving-average crossover?

It is a small, explainable example of turning market data into a deterministic signal. It is not selected because it is known to be profitable. A production strategy needs separate research and out-of-sample validation.

Can I leave the script running automatically?

Not yet. This tutorial runs one check per command. Before scheduling repeated checks, add and test stronger duplicate-order protection, reconciliation, monitoring, and a safe stop mechanism. Automatic scheduling should come after those controls, not before.

Why can a paper result differ from live trading?

Simulators make assumptions about fills and market behavior. Real execution can be affected by liquidity, spreads, queue position, latency, fees, and market impact. Paper trading is useful for debugging integration and operational behavior; it does not prove expected live returns.

Conclusion: build a small bot you can explain

The most useful way to create a trading bot with Claude Code is to make each step inspectable. Start with a defined rule, fetch and validate data, test the signal calculation offline, review every change, and keep orders disabled unless you deliberately enable the paper-trading path. Check the broker's paper dashboard after any attempted order and investigate state mismatches instead of retrying blindly.

The point of the first version is not to earn money. It is to build software whose inputs, rules, outputs, and limitations you understand. Only expand it after the small version behaves as expected in tests and controlled paper runs.

Educational content only; not financial or investment advice. Trading involves risk, and neither AI-generated code nor simulated results guarantee profit.