Guide

How to Build an AI Trading Agent with Claude Code and Alpaca

Most algorithmic trading setups fall into one of two camps: rigid rule-based scripts that can’t adapt to changing conditions, or manual Claude sessions where you paste in data, get a recommendation, and have to execute it yourself. Neither is truly autonomous.

What we’re building here is different: an agent that wakes up on a schedule, pulls live market data, reasons about what to do, places trades through Alpaca’s brokerage API, and keeps a journal of every decision it makes — all without you being in the loop.

This is a real, working system. We’ll use Python for the data and execution layer, Claude Code as the reasoning engine, and Alpaca’s paper trading account so you can test everything safely before risking real money.


What the Agent Does

Every trading day, the agent runs three sessions:

  1. Morning research (9:45 AM ET) — pulls price history, news headlines, and current portfolio state. Summarizes what it sees and flags potential opportunities.
  2. Trade execution (10:00 AM ET) — reviews the research, decides whether to buy, sell, or hold. Places limit orders if it decides to act.
  3. End-of-day journal (4:15 PM ET) — logs what happened, how positions moved, and what it would do differently.

Prerequisites

  • Claude Code installed: npm install -g @anthropic-ai/claude-code
  • Alpaca account — sign up at alpaca.markets. Use the paper trading environment (free, no real money)
  • Python 3.9+ with these packages:
pip install requests python-dotenv
  • Your Alpaca API key and secret (found in the Alpaca dashboard under “Paper Trading”)

Project Structure

trading-agent/
├── CLAUDE.md              # Agent persona and decision rules
├── research.py            # Pulls market data from Alpaca
├── trade.py               # Places and manages orders
├── journal.py             # Logs daily decisions to markdown
├── watchlist.json         # Symbols the agent monitors
├── .env                   # API credentials (never commit this)
└── .claude/
    └── settings.json      # Schedules the three daily sessions

Step 1: Define the Agent’s Persona in CLAUDE.md

This file is where you encode how the agent thinks — not just what it does. Claude Code reads this before every session.

# Trading Agent

You are a systematic equity trader managing a small paper trading portfolio.
Your job is to research a watchlist of stocks, make disciplined trade decisions,
and keep a clear record of your reasoning.

## Personality

You are patient and skeptical. You require multiple confirming signals before
entering a position. You never chase momentum. You would rather miss a move
than enter a trade you don't understand.

## Decision Framework

Before placing any trade, answer these four questions:
1. What is the trend on the daily chart over the last 20 sessions?
2. Is today's volume above or below the 20-day average?
3. What does recent news say about the company?
4. Does this trade fit within current risk limits?

If you can't answer all four confidently, do not trade.

## Risk Rules (non-negotiable)
- Never allocate more than 10% of portfolio value to a single position
- Never place market orders — use limit orders only
- Do not trade in the first 15 minutes after market open (9:30–9:45 ET)
- If a position is down more than 5% from entry, flag it for review
- Maximum 3 open positions at any time

## Output Format

When you complete a research session, write a brief summary:
- Overall market sentiment (1 sentence)
- For each watchlist symbol: trend, volume signal, news signal (1-2 sentences each)
- Recommended action: BUY / SELL / HOLD with reasoning

When you place a trade, log it to the journal immediately.

Step 2: Market Data and Research Script

This script wraps the Alpaca API and gives the agent four tools: historical price bars, account status, current positions, and recent news.

Create research.py:

import os
import requests
from datetime import datetime, timedelta
from dotenv import load_dotenv

load_dotenv()

BASE_URL = "https://paper-api.alpaca.markets"
DATA_URL = "https://data.alpaca.markets"

HEADERS = {
    "APCA-API-KEY-ID": os.getenv("ALPACA_API_KEY"),
    "APCA-API-SECRET-KEY": os.getenv("ALPACA_SECRET_KEY"),
}


def get_bars(symbol: str, days: int = 20) -> list[dict]:
    """Get daily OHLCV bars for a symbol."""
    end = datetime.now()
    start = end - timedelta(days=days + 10)  # buffer for weekends/holidays

    url = f"{DATA_URL}/v2/stocks/{symbol}/bars"
    params = {
        "timeframe": "1Day",
        "start": start.strftime("%Y-%m-%d"),
        "end": end.strftime("%Y-%m-%d"),
        "limit": days,
        "feed": "iex",
    }

    resp = requests.get(url, headers=HEADERS, params=params)
    resp.raise_for_status()
    bars = resp.json().get("bars", [])

    return [
        {
            "date": b["t"][:10],
            "open": b["o"],
            "high": b["h"],
            "low": b["l"],
            "close": b["c"],
            "volume": b["v"],
        }
        for b in bars
    ]


def get_account() -> dict:
    """Get current account value and buying power."""
    resp = requests.get(f"{BASE_URL}/v2/account", headers=HEADERS)
    resp.raise_for_status()
    data = resp.json()
    return {
        "portfolio_value": float(data["portfolio_value"]),
        "buying_power": float(data["buying_power"]),
        "cash": float(data["cash"]),
    }


def get_positions() -> list[dict]:
    """Get all open positions."""
    resp = requests.get(f"{BASE_URL}/v2/positions", headers=HEADERS)
    resp.raise_for_status()
    return [
        {
            "symbol": p["symbol"],
            "qty": float(p["qty"]),
            "avg_entry": float(p["avg_entry_price"]),
            "current_price": float(p["current_price"]),
            "unrealized_pl_pct": float(p["unrealized_plpc"]) * 100,
        }
        for p in resp.json()
    ]


def get_news(symbol: str, limit: int = 5) -> list[dict]:
    """Get recent news headlines for a symbol."""
    url = f"{DATA_URL}/v1beta1/news"
    params = {"symbols": symbol, "limit": limit}

    resp = requests.get(url, headers=HEADERS, params=params)
    resp.raise_for_status()

    return [
        {
            "headline": article["headline"],
            "source": article["source"],
            "published": article["created_at"][:10],
            "summary": article.get("summary", "")[:200],
        }
        for article in resp.json().get("news", [])
    ]


if __name__ == "__main__":
    import json
    import sys

    symbols = sys.argv[1:] if len(sys.argv) > 1 else ["AAPL", "MSFT"]

    print("=== ACCOUNT ===")
    print(json.dumps(get_account(), indent=2))

    print("\n=== POSITIONS ===")
    print(json.dumps(get_positions(), indent=2))

    for sym in symbols:
        print(f"\n=== {sym} BARS (last 20 days) ===")
        print(json.dumps(get_bars(sym), indent=2))

        print(f"\n=== {sym} NEWS ===")
        print(json.dumps(get_news(sym), indent=2))

Test it:

python research.py AAPL MSFT

You should see account info, positions (empty if you just signed up), price history, and news headlines.


Step 3: Trade Execution Script

This handles order placement. It uses limit orders only (as CLAUDE.md requires) and validates market hours before doing anything.

Create trade.py:

import os
import requests
from datetime import datetime
from zoneinfo import ZoneInfo
from dotenv import load_dotenv

load_dotenv()

BASE_URL = "https://paper-api.alpaca.markets"

HEADERS = {
    "APCA-API-KEY-ID": os.getenv("ALPACA_API_KEY"),
    "APCA-API-SECRET-KEY": os.getenv("ALPACA_SECRET_KEY"),
}


def is_market_open() -> bool:
    """Check whether the US equity market is currently open."""
    resp = requests.get(f"{BASE_URL}/v2/clock", headers=HEADERS)
    resp.raise_for_status()
    return resp.json()["is_open"]


def get_latest_price(symbol: str) -> float:
    """Get the latest trade price for a symbol."""
    url = f"https://data.alpaca.markets/v2/stocks/{symbol}/trades/latest"
    params = {"feed": "iex"}
    resp = requests.get(url, headers=HEADERS, params=params)
    resp.raise_for_status()
    return float(resp.json()["trade"]["p"])


def place_limit_order(
    symbol: str,
    side: str,           # "buy" or "sell"
    qty: int,
    limit_price: float,
) -> dict:
    """Place a limit order. Raises if market is closed."""
    if not is_market_open():
        raise RuntimeError("Market is closed. No orders placed.")

    if side not in ("buy", "sell"):
        raise ValueError(f"Invalid side: {side}. Must be 'buy' or 'sell'.")

    if qty <= 0:
        raise ValueError(f"Quantity must be positive, got {qty}.")

    payload = {
        "symbol": symbol,
        "qty": str(qty),
        "side": side,
        "type": "limit",
        "time_in_force": "day",
        "limit_price": str(round(limit_price, 2)),
    }

    resp = requests.post(f"{BASE_URL}/v2/orders", headers=HEADERS, json=payload)
    resp.raise_for_status()
    order = resp.json()

    return {
        "order_id": order["id"],
        "symbol": order["symbol"],
        "side": order["side"],
        "qty": order["qty"],
        "limit_price": order["limit_price"],
        "status": order["status"],
        "submitted_at": order["submitted_at"],
    }


def get_open_orders() -> list[dict]:
    """List all open (unfilled) orders."""
    resp = requests.get(f"{BASE_URL}/v2/orders", headers=HEADERS, params={"status": "open"})
    resp.raise_for_status()
    return [
        {
            "order_id": o["id"],
            "symbol": o["symbol"],
            "side": o["side"],
            "qty": o["qty"],
            "limit_price": o["limit_price"],
            "status": o["status"],
        }
        for o in resp.json()
    ]


def cancel_order(order_id: str) -> bool:
    """Cancel an open order by ID."""
    resp = requests.delete(f"{BASE_URL}/v2/orders/{order_id}", headers=HEADERS)
    return resp.status_code == 204


if __name__ == "__main__":
    import json, sys

    action = sys.argv[1] if len(sys.argv) > 1 else "status"

    if action == "status":
        print("Market open:", is_market_open())
        print("Open orders:", json.dumps(get_open_orders(), indent=2))

    elif action == "buy" and len(sys.argv) == 5:
        # python trade.py buy AAPL 2 189.50
        _, _, symbol, qty, price = sys.argv
        result = place_limit_order(symbol, "buy", int(qty), float(price))
        print(json.dumps(result, indent=2))

    elif action == "sell" and len(sys.argv) == 5:
        _, _, symbol, qty, price = sys.argv
        result = place_limit_order(symbol, "sell", int(qty), float(price))
        print(json.dumps(result, indent=2))

Test with a dry run to confirm the market status check works:

python trade.py status

Step 4: Trade Journal

Every decision gets logged. A journal is what separates a serious trading system from a script that just executes — it’s how you (and the agent) learn over time.

Create journal.py:

import os
import sys
from datetime import date

JOURNAL_DIR = "journal"


def log_entry(title: str, body: str) -> str:
    """Append an entry to today's journal file."""
    os.makedirs(JOURNAL_DIR, exist_ok=True)
    today = date.today().isoformat()
    filepath = os.path.join(JOURNAL_DIR, f"{today}.md")

    header = f"## {title}\n\n"

    with open(filepath, "a") as f:
        if os.path.getsize(filepath) == 0 if os.path.exists(filepath) else True:
            f.write(f"# Trading Journal — {today}\n\n")
        f.write(header + body.strip() + "\n\n---\n\n")

    return filepath


def write_daily_summary(
    account: dict,
    positions: list[dict],
    trades_placed: list[dict],
    research_notes: str,
    reflection: str,
) -> str:
    """Write the end-of-day journal entry."""
    position_lines = "\n".join(
        f"- **{p['symbol']}**: {p['qty']} shares @ avg ${p['avg_entry']:.2f} "
        f"| current ${p['current_price']:.2f} "
        f"| P&L {p['unrealized_pl_pct']:+.1f}%"
        for p in positions
    ) or "_No open positions._"

    trade_lines = "\n".join(
        f"- {t['side'].upper()} {t['qty']} {t['symbol']} @ ${t['limit_price']} (order {t['order_id'][:8]}…)"
        for t in trades_placed
    ) or "_No trades placed today._"

    body = f"""
### Portfolio Status
- Portfolio value: ${account['portfolio_value']:,.2f}
- Buying power: ${account['buying_power']:,.2f}

### Open Positions
{position_lines}

### Trades Placed Today
{trade_lines}

### Research Notes
{research_notes}

### Reflection
{reflection}
"""
    return log_entry("End-of-Day Summary", body)


if __name__ == "__main__":
    # Quick test
    path = log_entry("Test Entry", "This is a test journal entry.")
    print(f"Journal written to: {path}")

Step 5: Watchlist

Create watchlist.json — the set of symbols the agent monitors each day:

{
  "symbols": [
    {
      "ticker": "AAPL",
      "description": "Apple Inc — large-cap tech, high liquidity",
      "max_allocation_pct": 10
    },
    {
      "ticker": "MSFT",
      "description": "Microsoft Corp — large-cap tech, steady trend",
      "max_allocation_pct": 10
    },
    {
      "ticker": "NVDA",
      "description": "Nvidia Corp — high volatility, AI hardware",
      "max_allocation_pct": 8
    },
    {
      "ticker": "SPY",
      "description": "S&P 500 ETF — used as market benchmark",
      "max_allocation_pct": 15
    }
  ],
  "notes": "Start with 3-4 liquid, large-cap names. Add more only after the agent has run for 30 days."
}

Keep the watchlist short at first. More symbols means more data to reason about and more chances for the agent to get confused by conflicting signals.


Step 6: Schedule the Three Daily Sessions

Create .claude/settings.json:

{
  "hooks": {
    "schedule": [
      {
        "name": "Morning Research",
        "cron": "45 9 * * 1-5",
        "prompt": "Run the morning research session. Load watchlist.json to get the symbol list. For each symbol, call research.py to get the last 20 days of price bars and the 5 most recent news headlines. Also get the current account status and open positions. Summarize your findings and flag any symbols that look interesting today. Write a research summary to the journal using journal.py."
      },
      {
        "name": "Trade Execution",
        "cron": "0 10 * * 1-5",
        "prompt": "Review this morning's research notes in the journal. Based on the decision framework in CLAUDE.md, decide whether to place any trades. If you decide to trade: use trade.py to get the latest price, calculate an appropriate limit price (use the latest price for buys, round to 2 decimal places), and place the order with trade.py. Respect all risk rules in CLAUDE.md. Log every trade to the journal, including your reasoning."
      },
      {
        "name": "End of Day Journal",
        "cron": "15 16 * * 1-5",
        "prompt": "Run the end-of-day session. Get the current account state and all open positions using research.py. Get any trades placed today from trade.py. Write a full end-of-day summary to the journal using journal.py. Include a reflection: what did you learn today, and what would you do differently?"
      }
    ]
  }
}

The cron expressions use 1-5 (Monday through Friday) so the agent only runs on market days.


Step 7: Credentials

Create .env:

ALPACA_API_KEY=your_paper_trading_key_here
ALPACA_SECRET_KEY=your_paper_trading_secret_here

Add .env and the journal directory to .gitignore immediately:

echo ".env" >> .gitignore
echo "journal/" >> .gitignore

Never commit API keys. Even paper trading keys should stay private.


Step 8: Test the Full Pipeline Manually

Before enabling the schedule, run each step by hand and verify the output makes sense.

# 1. Check that research works
python research.py AAPL MSFT

# 2. Check that trade.py can connect and see market status
python trade.py status

# 3. Check that journaling works
python journal.py

# 4. Run a full agent session manually
claude "Run the morning research session for the symbols in watchlist.json. Use research.py. Summarize what you find."

Read the output carefully. Is the agent reasoning about the data, or just repeating it back? If it’s just summarizing numbers without judgment, revisit CLAUDE.md and add more specific decision criteria.


Risk Controls

Three layers of protection before you run this unattended:

Layer 1: CLAUDE.md rules. The agent reads these before every session. Keep them specific and non-negotiable — “never place market orders” is better than “prefer limit orders.”

Layer 2: Script-level validation. trade.py checks market hours before placing any order and raises an error if anything looks wrong. It won’t silently fail.

Layer 3: Alpaca’s built-in protections. Paper trading accounts have position limits and pattern day trader protections enabled by default. These act as a final backstop.

When you’re ready to switch to live trading, change BASE_URL from paper-api.alpaca.markets to api.alpaca.markets and swap in your live API keys. Start with small position sizes.


What the Journal Looks Like After a Week

# Trading Journal — 2026-06-23

## Morning Research

**Market sentiment:** Broadly flat open, tech slightly outperforming.

- **AAPL:** Uptrend intact over 20 days. Volume below average (quiet session).
  News: one analyst upgrade, no major catalysts. Signal: mildly positive.
- **MSFT:** Range-bound for 8 sessions. No clear direction. News: nothing material.
  Signal: neutral, no action.
- **NVDA:** Strong uptrend, volume elevated. News: positive commentary on data center
  demand. Signal: interesting, will review at 10am.

---

## Trade Execution

Placed BUY order: 3 shares NVDA @ $127.40 limit (latest price $127.55, limit set
0.1% below). Reasoning: trend, volume, and news all aligned positive. Position size
$382 = 3.8% of portfolio, within the 8% max allocation for NVDA.

---

## End-of-Day Summary

### Portfolio Status
- Portfolio value: $10,382.40
- Buying power: $9,617.60

### Open Positions
- **NVDA**: 3 shares @ avg $127.40 | current $128.90 | P&L +1.2%

### Reflection
NVDA order filled at $127.40 within the first 30 minutes. Position is up 1.2% at close.
The thesis held for today. Would not have entered AAPL or MSFT — no clear signal.
Tomorrow: watch NVDA volume at open. If it fades, consider trimming.

Realistic Expectations

This agent won’t make you rich. Paper trading results rarely translate directly to live trading because there’s no slippage, no partial fills, and no emotional pressure when real money is on the line.

What it will do:

  • Force you to write down a trading process and stick to it
  • Show you whether your rules actually generate edge over time
  • Give you a decision log you can analyze after 30, 60, 90 days
  • Let you experiment safely before risking capital

Run it on paper for at least 30 trading days before considering live trading. Review the journal every week. If the agent is consistently breaking its own rules or placing trades it can’t explain, fix CLAUDE.md before moving on.


Extensions Worth Adding

Multi-agent risk reviewer. Add a second Claude Code session that reads each proposed trade before it’s placed and must explicitly approve it. Useful for catching trades the primary agent rationalized its way into.

Position sizing calculator. Right now the agent has to reason about position size from the risk rules. A dedicated script that calculates max shares based on portfolio value and per-symbol allocation limits gives it a concrete number to work with.

Performance tracker. A weekly script that reads all journal files, extracts trades, and calculates win rate, average gain/loss, and Sharpe ratio. After a month you’ll have real data to evaluate the strategy.


FAQ

Does this work with a real Alpaca account? Yes. Change BASE_URL in trade.py to api.alpaca.markets and use live API keys. Start small.

What does it cost to run? The Claude Code API calls for three daily sessions use roughly 10,000–20,000 tokens per day depending on watchlist size. At current pricing that’s well under $1/day. Alpaca has no API fees for paper trading.

What if the agent places a bad trade? That’s what paper trading is for. On a live account, Alpaca lets you cancel open orders manually from the dashboard at any time. The agent only places day orders, so anything unfilled cancels automatically at market close.

Can I add more symbols to the watchlist? Yes, but keep it under 10. More symbols means longer research sessions and more chances for the agent to find conflicting signals it can’t resolve. Quality over quantity.

What if Claude Code misses a scheduled session? The journal will have a gap. Add a daily check that looks for today’s journal file — if it’s missing by noon, send yourself an alert. A simple cron job with a bash script handles this.

Can I short sell with this setup? Alpaca supports short selling on live accounts (not paper by default). Add a "sell_short" action to trade.py if you want to go short. Make sure CLAUDE.md includes explicit rules for when shorting is allowed — without rules, the agent will treat it the same as a regular sell.