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
- The design: the model proposes, code disposes
- Setup
- The complete AI trading bot in one file
- Market-hours check: why is_open is not enough
- Stale-data guard: a freshness budget per asset class
- Spread, size and inventory checks
- Run the bot
- Guardrails checklist for an AI trading agent
- Where to take it next
- 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
- WatchlistUS:KO, US:JPM, BTCUSD
- Claudereads read-only MCP tools
- propose_paper_orderthe only action tool
- Guardrailsstatus, freshness, spread, size
- Paper ledgerpaper_ledger.json
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.
- get_market_status, get_snapshot, get_historyMCP connector, read-only toolsClaude to TickerLayer
- JSON with timestampsTickerLayer to Claude
- propose_paper_order(BTCUSD, buy, 500)Claude to Your code
- GET /markets/status, GET /crypto/quote/BTCUSDindependent re-check over RESTYour code to TickerLayer
- status open, fresh quoteTickerLayer to Your code
- Spread, size and inventory checks
- filled on paper, or rejected with a reasonYour code to Claude
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.
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-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
}
statusWhat the guard branches on. Only "open" means the regular session.is_openTrue in pre-market and post-market as well. A bot that trusts it trades in thin extended hours.next_openGoes straight into the rejection message, so the model can tell you when to try again.
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.
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
| Check | Rejects | Why it matters |
|---|---|---|
bid <= 0 or ask <= bid | Empty, crossed and locked quotes | Not a tradable price; usually a transient update |
| Spread over 30 bps | Thin or disorderly books | Crossing a wide spread costs more than most signals earn |
| Notional outside (0, 1,000] | Fat-finger sizes | Models mis-scale units; a hard cap is cheaper than a lesson |
| Sell larger than the position | Short sales | This paper account is long only |
| A second fill in one run | Retry loops | A model can keep re-proposing a rejected idea |
Run the bot
python paper_agent.py US:KO US:JPM BTCUSD 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:
{
"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_openincludes 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.