Tutorial

Build an AI trading bot with live market data and guardrails

A language model is good at reading a market and bad at knowing what time it is. So let it propose, and let a hundred lines of boring Python decide.

On this page
  1. The design: the model proposes, code disposes
  2. Setup
  3. The complete AI trading bot in one file
  4. Market-hours check: why is_open is not enough
  5. Stale-data guard: a freshness budget per asset class
  6. Spread, size and inventory checks
  7. Run the bot
  8. Guardrails checklist for an AI trading agent
  9. Where to take it next
  10. Questions

Key takeaways

  • A safe AI trading bot separates roles: the model reads data and proposes, deterministic code checks and executes, and nothing reaches a broker.
  • Market-hours checks must branch on the session status: in US pre-market, market status reports is_open true with status "pre_market".
  • A stale-data guard compares the quote timestamp with the clock and rejects anything older than a budget you set per asset class.
  • Crossed, locked or unusually wide quotes are rejected before sizing, and sells can never exceed the paper position.
  • This build is educational: it fills orders on a local paper ledger, and TickerLayer provides market data only, never execution.

An AI trading bot is a program in which a language model reads market data and proposes trades, and ordinary code decides whether they happen. This tutorial builds one in Python: Claude pulls quotes and bars through the TickerLayer financial MCP server and proposes a trade; a guardrail layer re-checks market hours, quote freshness, spread and size, and fills at most one order per run on a paper ledger.

The design: the model proposes, code disposes

  1. WatchlistUS:KO, US:JPM, BTCUSD
  2. Claudereads read-only MCP tools
  3. propose_paper_orderthe only action tool
  4. Guardrailsstatus, freshness, spread, size
  5. Paper ledgerpaper_ledger.json
The model never touches the ledger. It can only ask, and the answer comes from code you can read.

The model never trades directly because its failure modes are specific and predictable. It does not know the current time unless a tool tells it. It can read is_open: true in pre-market as "the market is open". If a tool call fails, it may fill the gap with a remembered price. And text inside its context can persuade it. Each of these is cheap to check in code and expensive to discover with money.

ClaudeYour codeTickerLayer
  1. get_market_status, get_snapshot, get_historyMCP connector, read-only toolsClaude to TickerLayer
  2. JSON with timestampsTickerLayer to Claude
  3. propose_paper_order(BTCUSD, buy, 500)Claude to Your code
  4. GET /markets/status, GET /crypto/quote/BTCUSDindependent re-check over RESTYour code to TickerLayer
  5. status open, fresh quoteTickerLayer to Your code
  6. Spread, size and inventory checks
  7. filled on paper, or rejected with a reasonYour code to Claude
The guardrail never trusts a number the model quotes. It fetches its own.

Setup

You need Python 3.10 or newer, a TickerLayer key and an Anthropic API key. Tested on 2026-09-28 with anthropic 1.8.0 and requests 2.34 on Python 3.12, with the guardrails running against the live TickerLayer API. Each run costs one REST request per tool call Claude makes, plus up to two per proposed order for the guardrail re-check; the free tier includes 3,000 REST requests a month.

TerminalShell
python3 -m venv .venv && source .venv/bin/activate
pip install "anthropic>=1.8" "requests>=2.31"
export TICKERLAYER_API_KEY="YOUR_API_KEY"
export ANTHROPIC_API_KEY="YOUR_ANTHROPIC_KEY"

The complete AI trading bot in one file

One file. Claude reaches the TickerLayer tools through the Claude API's MCP connector, restricted to a read-only allowlist. The only tool it can act with is propose_paper_order, which is a Python function: check_order re-reads the market over REST, and fill writes to the ledger only if every check passes.

paper_agent.pyPython
"""Paper-trading agent: Claude reads market data through the TickerLayer MCP
server, and plain Python decides whether a proposed order may touch the paper
book. Educational only. Nothing here sends an order to a broker."""

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

import anthropic
import requests

TL_KEY = os.environ["TICKERLAYER_API_KEY"]
REST = "https://api.tickerlayer.com"
MCP_URL = "https://mcp.tickerlayer.com/mcp"
MODEL = os.environ.get("CLAUDE_MODEL", "claude-opus-5")
LEDGER = Path("paper_ledger.json")

# Your risk settings. Example values to tune, not recommendations.
MAX_QUOTE_AGE_S = {"stocks": 120, "crypto": 30}
MAX_SPREAD_BPS = 30.0
MAX_ORDER_USD = 1_000.0
MAX_TURNS = 10

http = requests.Session()
http.headers["x-api-key"] = TL_KEY
claude = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY

SYSTEM = (
    "You are a cautious paper-trading assistant. Use the TickerLayer tools to check "
    "market status, the latest quote and recent daily bars for each symbol on the "
    "watchlist. Then either propose one paper order with propose_paper_order or "
    "propose nothing. Quote prices only from tool results and give their timestamps. "
    "If a market is closed or data looks stale, say so and do not trade."
)

PROPOSE_ORDER = {
    "name": "propose_paper_order",
    "description": (
        "Propose one paper order. Deterministic guardrails re-check market status, "
        "quote freshness, spread and size, then fill it on a paper ledger or reject "
        "it with a reason. Nothing is sent to a broker."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "asset_class": {"type": "string", "enum": ["stocks", "crypto"]},
            "symbol": {"type": "string"},
            "side": {"type": "string", "enum": ["buy", "sell"]},
            "notional_usd": {"type": "number"},
            "reason": {"type": "string"},
        },
        "required": ["asset_class", "symbol", "side", "notional_usd", "reason"],
        "additionalProperties": False,
    },
    "strict": True,
}

# Read-only allowlist: the model sees only the tools this bot needs.
TOOLSET = {
    "type": "mcp_toolset",
    "mcp_server_name": "tickerlayer",
    "default_config": {"enabled": False},
    "configs": {name: {"enabled": True} for name in (
        "get_market_status", "get_quote", "get_snapshot", "get_history", "list_symbols"
    )},
}


def tl_get(path, params=None):
    """GET a TickerLayer REST path, honouring Retry-After on a per-second 429."""
    for _ in range(3):
        resp = http.get(REST + path, params=params, timeout=10)
        if resp.status_code == 429 and "Retry-After" in resp.headers:
            time.sleep(float(resp.headers["Retry-After"]))
            continue
        resp.raise_for_status()
        return resp.json()
    resp.raise_for_status()
    return resp.json()


def load_book():
    if LEDGER.exists():
        return json.loads(LEDGER.read_text())
    return {"cash": 10_000.0, "positions": {}, "fills": []}


def check_order(order):
    """Return (ok, detail, price). Every check re-reads the market itself."""
    asset, symbol = order["asset_class"], order["symbol"].strip().upper()

    # 1. Regular session only. is_open is also true in pre- and post-market.
    params = {"asset": asset}
    if asset == "stocks":
        params["market"] = symbol.split(":")[0]
    status = tl_get("/markets/status", params)
    if status.get("status") != "open":
        return False, f"market {status.get('status')}, next open {status.get('next_open')}", None

    # 2. The quote must be fresh, uncrossed and tight.
    quote = tl_get(f"/{asset}/quote/{symbol}")
    bid, ask = float(quote["bid"]), float(quote["ask"])
    age_s = time.time() - quote["timestamp"] / 1000
    if age_s > MAX_QUOTE_AGE_S[asset]:
        return False, f"stale quote, {age_s:.0f}s old", None
    if bid <= 0 or ask <= bid:
        return False, f"unusable quote, bid {bid} ask {ask}", None
    spread_bps = (ask - bid) / ((ask + bid) / 2) * 10_000
    if spread_bps > MAX_SPREAD_BPS:
        return False, f"spread {spread_bps:.1f} bps is over the limit", None

    # 3. Size and inventory. No shorting, even on paper.
    notional = float(order["notional_usd"])
    if not 0 < notional <= MAX_ORDER_USD:
        return False, f"notional {notional} outside (0, {MAX_ORDER_USD}]", None
    price = ask if order["side"] == "buy" else bid
    held = load_book()["positions"].get(symbol, 0.0)
    if order["side"] == "sell" and notional / price > held + 1e-9:
        return False, f"sell of {notional / price:.6f} exceeds position {held:.6f}", None
    return True, f"quote inside the {MAX_QUOTE_AGE_S[asset]}s budget, spread {spread_bps:.2f} bps", price


def fill(order, price):
    book = load_book()
    symbol = order["symbol"].strip().upper()
    qty = float(order["notional_usd"]) / price
    signed = qty if order["side"] == "buy" else -qty
    book["cash"] -= signed * price
    book["positions"][symbol] = book["positions"].get(symbol, 0.0) + signed
    book["fills"].append({"ts": int(time.time() * 1000), "symbol": symbol,
                          "side": order["side"], "qty": qty, "price": price,
                          "reason": order["reason"]})
    LEDGER.write_text(json.dumps(book, indent=2))
    return qty


def handle_proposal(order, already_filled):
    if already_filled:
        return "rejected: one order per run", True
    try:
        ok, detail, price = check_order(order)
    except requests.HTTPError as exc:
        return f"rejected: data check failed ({exc.response.status_code})", True
    if not ok:
        print(f"  guard  REJECT {order['side']} {order['symbol']}: {detail}")
        return f"rejected: {detail}", False
    qty = fill(order, price)
    print(f"  guard  FILL {order['side']} {qty:.6f} {order['symbol']} @ {price} ({detail})")
    return f"filled on paper: {qty:.6f} @ {price}", False


def run(task):
    messages = [{"role": "user", "content": task}]
    filled = False
    for _ in range(MAX_TURNS):
        resp = claude.beta.messages.create(
            model=MODEL,
            max_tokens=16000,
            system=SYSTEM,
            betas=["mcp-client-2025-11-20"],
            mcp_servers=[{"type": "url", "url": MCP_URL, "name": "tickerlayer",
                          "authorization_token": TL_KEY}],
            tools=[TOOLSET, PROPOSE_ORDER],
            messages=messages,
        )
        messages.append({"role": "assistant", "content": resp.content})
        for block in resp.content:
            if block.type == "mcp_tool_use":
                print(f"  data   {block.name} {json.dumps(block.input)}")

        if resp.stop_reason == "refusal":
            print("model declined the request; holding")
            return
        if resp.stop_reason == "pause_turn":
            continue  # long server-side tool turn: send it back to resume
        if resp.stop_reason != "tool_use":
            print("\n" + "".join(b.text for b in resp.content if b.type == "text"))
            return

        results = []
        for block in resp.content:
            if block.type == "tool_use" and block.name == "propose_paper_order":
                text, is_error = handle_proposal(block.input, filled)
                filled = filled or text.startswith("filled")
                results.append({"type": "tool_result", "tool_use_id": block.id,
                                "content": text, "is_error": is_error})
        messages.append({"role": "user", "content": results})
    print(f"stopped after {MAX_TURNS} turns without a final answer")


if __name__ == "__main__":
    watchlist = sys.argv[1:] or ["US:KO", "US:JPM", "BTCUSD"]
    try:
        run("Watchlist: " + ", ".join(watchlist) + ". Decide on at most one paper order.")
    except anthropic.APIStatusError as exc:
        sys.exit(f"Claude API error {exc.status_code}: {exc.message}")
    except requests.RequestException as exc:
        sys.exit(f"TickerLayer request failed: {exc}")

Market-hours check: why is_open is not enough

The first guard calls GET /markets/status. Here is what it returned for US stocks during the guardrail test run, a little before 07:00 in New York:

GET /markets/status?asset=stocks&market=US

{
  "status": "pre_market",1
  "phase": "pre_market",
  "is_open": true,2
  "reason": "Pre-market session",
  "next_open": "2026-09-28T13:30:00.000Z"3
}
  1. statusWhat the guard branches on. Only "open" means the regular session.
  2. is_openTrue in pre-market and post-market as well. A bot that trusts it trades in thin extended hours.
  3. next_openGoes straight into the rejection message, so the model can tell you when to try again.
Trimmed; captured 2026-09-28 at 10:54 UTC. The same call for crypto returned status "open" with the reason "24/7 session".

One rule covers every asset class: crypto reports open around the clock, and the other classes follow their own calendars, including weekends, holidays and extended sessions. The exchange calendar API guide covers sessions, holidays and early closes in detail.

Stale-data guard: a freshness budget per asset class

age = now − timestamp ÷ 1000reject if age > budget[asset_class]

timestamp
The quote time from GET /{asset}/quote, in Unix milliseconds.
now
Your machine clock in Unix seconds. Keep it synced, or the guard lies.
budget
Your setting: 120 s for stocks and 30 s for crypto in this example.
Illustrative: a stock quote stamped 10:54:16 UTC and checked at 10:56:30 is 134 s old, over a 120 s budget, so the order is rejected and the model is told why.

Budgets differ by asset because normal silence differs. A liquid crypto pair that goes quiet for half a minute is suspicious; a thin stock can legitimately sit on the same quote for a while. Start loose, log every rejection, and tighten from your own data. The bad ticks and stale prices explainer covers the full detection recipe, including spike filters and cross-checks.

Spread, size and inventory checks

CheckRejectsWhy it matters
bid <= 0 or ask <= bidEmpty, crossed and locked quotesNot a tradable price; usually a transient update
Spread over 30 bpsThin or disorderly booksCrossing a wide spread costs more than most signals earn
Notional outside (0, 1,000]Fat-finger sizesModels mis-scale units; a hard cap is cheaper than a lesson
Sell larger than the positionShort salesThis paper account is long only
A second fill in one runRetry loopsA model can keep re-proposing a rejected idea
Thresholds are example settings for this tutorial, not recommendations. The [bid-ask spread guide](/learn/bid-ask-spread) explains spreads in basis points.

Run the bot

TerminalShell
python paper_agent.py US:KO US:JPM BTCUSD
Output (example)
  data   get_market_status {"asset_class": "stocks", "market": "US"}
  data   get_snapshot {"asset_class": "stocks", "symbol": "US:KO"}
  data   get_snapshot {"asset_class": "stocks", "symbol": "US:JPM"}
  data   get_market_status {"asset_class": "crypto"}
  data   get_snapshot {"asset_class": "crypto", "symbol": "BTCUSD"}
  guard  REJECT buy US:KO: market pre_market, next open 2026-09-28T13:30:00.000Z
  guard  FILL buy 0.006025 BTCUSD @ 82993.1 (quote inside the 30s budget, spread 0.00 bps)

US equities are in pre-market until 13:30 UTC, so the US:KO idea was rejected.
Bought 0.006025 BTCUSD on paper at 82,993.10 (quote at 10:54 UTC).

The two guard lines come from a live run of the guardrail code on 28 September 2026; the tool calls and the closing summary show the shape of a model run, and your model's choices and wording will differ. The ledger after that fill:

paper_ledger.jsonJSON
{
  "cash": 9500.0,
  "positions": { "BTCUSD": 0.006024597225552485 },
  "fills": [
    { "ts": 1790592856369, "symbol": "BTCUSD", "side": "buy",
      "qty": 0.006024597225552485, "price": 82993.1, "reason": "..." }
  ]
}

Guardrails checklist for an AI trading agent

Before any of this goes near real money

  • Paper-trade for weeks, across sessions, weekends and holidays.
  • Give the model read-only data tools and one proposal tool. Keep execution in code.
  • Re-fetch market status and the quote inside the guard. Never trust numbers the model quotes.
  • Branch on status "open" for the regular session; is_open includes extended hours.
  • Reject stale, crossed, locked and wide quotes, with budgets per asset class.
  • Cap notional per order, orders per run and model turns per run.
  • Log every proposal, rejection reason and fill with timestamps.
  • Add a kill switch: one flag that makes the guard reject everything.
  • Treat text inside tool results as data, never as instructions.

The same split applies when an agent spends money on data instead of trades: with x402 pay-per-call, the wallet sits behind a code-owned policy, not the model. And if you want to explore the data conversationally before automating anything, connect Claude over MCP and ask it the questions your bot will ask.

Where to take it next

  • Schedule it during the sessions you care about, and let the status guard handle the rest of the clock.
  • Add exits in code: stop and target levels checked by the same guard layer, not by the model.
  • Backtest the rules the model is asked to follow, on daily bars from get_history, before trusting its judgement.
  • Stream instead of polling for intraday logic; keep the latest quote in your process and let the guard read it.
  • Swap the ledger for a broker sandbox of your choice only after the paper record is boring.

Questions

Can an AI trade stocks for you?

A model can analyse data and propose trades, but execution needs a broker, and this tutorial deliberately stops at a paper ledger. None of it is investment advice.

Are AI trading bots profitable?

Nothing guarantees it. A language model has no built-in edge, so test the rules on historical data, paper-trade for a long time, and assume costs and spreads are higher than you expect.

Can ChatGPT or Claude be used as a trading bot?

Either can be the reasoning layer, reading market data through MCP tools and proposing actions. Keep checks and execution in deterministic code the model cannot change.

How do I stop an AI bot from trading on stale data?

Check the market session first, then compare the quote timestamp with a synced clock and reject anything older than a budget per asset class.

What data does an AI trading agent need?

At minimum market status, quotes with timestamps and recent bars. Snapshots help the model summarise; the guard should re-fetch its own quote before acting.

Keep reading

Ready to integrate?

Start with the free tier, explore the docs, and connect via REST or WebSocket in minutes.