Building Trading Bots with Tradier Futures API
Overview #
Most futures traders who want to automate spend six months wrangling with NinjaScript limitations, TradeStation's EasyLanguage quirks, or the TWS API's connection management nightmare before they ever place a programmatic order. Tradier takes a different approach: a clean REST API, a genuine sandbox environment, and a WebSocket layer for real-time account events — all with Bearer token authentication that you generate once and use forever.
This article walks through the full lifecycle of building an automated futures trading system on Tradier's infrastructure: API authentication, order placement, WebSocket streaming for real-time fills, position management, sandbox testing methodology, and the production safety controls that separate a toy bot from a system you can actually run with real money.
The target reader knows Python or Node.js and understands futures basics. You need an active Tradier Futures account with API tokens generated at web.tradier.com/user/api. If you're still evaluating whether Tradier is the right execution layer, read Tradier Futures: The API-First Brokerage first.
What Makes Tradier Different for Bot Builders #
The architecture decision that makes everything else easier: the same REST API powering NinjaTrader, Sierra Chart, TradingView, and 100+ platform integrations is the exact API you write code against. No "developer tier" with degraded functionality — the same execution infrastructure as platforms with millions in development behind them.
Three things that matter for bot builders specifically:
No session management. Interactive Brokers requires TWS running, heartbeat maintenance, and reconnection logic that can consume more code than your actual strategy. Tradier uses stateless Bearer tokens. One header, every request.
A real sandbox. https://sandbox.tradier.com/v1 mirrors production exactly. Same endpoints, same response structure, same order types. Flip one environment variable to go live. No surprises because the sandbox is a genuine development environment, not a demo.
An MCP server. Tradier launched a hosted Model Context Protocol server that lets AI coding agents — Claude, Cursor, Gemini — interact with the API directly. For developers using LLMs to write or debug trading code, the AI works against real API responses in sandbox rather than hallucinated examples.
The honest drawback: Tradier is infrastructure, not a full-service brokerage. Customer support is email-based. Research tools don't exist. If you want hand-holding, this isn't your broker. If you want clean programmatic access to liquid futures markets with competitive rates, it's a serious option.
Authentication and Client Setup #
Tradier uses Bearer token authentication across all API calls. Generate two tokens at web.tradier.com/user/api — one for production, one for sandbox — and use them in the Authorization header of every request.
| Header | Value | Notes |
|---|---|---|
| Authorization | Bearer YOUR_TOKEN | Required for all calls |
| Accept | application/json | Returns JSON responses |
| Content-Type | application/x-www-form-urlencoded | POST requests only |
Critical detail most developers miss: REST order placement uses form-encoded bodies, not JSON. Use requests.post(..., data=payload) — not json=payload. Wrong content type returns a silent validation failure.
Python Client Wrapper
import os, requests
from typing import Optional
class TradierClient:
SANDBOX_BASE = "https://sandbox.tradier.com/v1"
PROD_BASE = "https://api.tradier.com/v1"
def __init__(self, sandbox: bool = True):
self.base = self.SANDBOX_BASE if sandbox else self.PROD_BASE
api_key = (os.environ["TRADIER_SANDBOX_KEY"] if sandbox
else os.environ["TRADIER_PROD_KEY"])
self.session = requests.Session()
self.session.headers.update({
"Authorization": f"Bearer {api_key}",
"Accept": "application/json"
})
self.sandbox = sandbox
def get(self, path: str, params: Optional[dict] = None) -> dict:
resp = self.session.get(f"{self.base}{path}", params=params, timeout=10)
resp.raise_for_status()
return resp.json()
def post(self, path: str, data: dict) -> dict:
resp = self.session.post(
f"{self.base}{path}", data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=10
)
resp.raise_for_status()
return resp.json()
Node.js Equivalent
const fetch = require('node-fetch');
const { URLSearchParams } = require('url');
class TradierClient {
constructor({ sandbox = true } = {}) {
this.base = sandbox
? 'https://sandbox.tradier.com/v1'
: 'https://api.tradier.com/v1';
const key = sandbox
? process.env.TRADIER_SANDBOX_KEY
: process.env.TRADIER_PROD_KEY;
this.headers = {
'Authorization': `Bearer ${key}`,
'Accept': 'application/json'
};
}
async post(path, data = {}) {
const body = new URLSearchParams(data);
const res = await fetch(`${this.base}${path}`, {
method: 'POST',
headers: { ...this.headers, 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString()
});
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
return res.json();
}
}
Store keys as environment variables. Never in code. Never committed to repos. A leaked production key has immediate financial consequences. Use .env files with python-dotenv or dotenv for Node, with .env excluded from source control via .gitignore.
Two tokens, two env var names: TRADIER_SANDBOX_KEY and TRADIER_PROD_KEY. The sandbox key is disposable — rotate it freely. The production key controls real money; treat it like a bank PIN and never paste it into AI tools or chat interfaces.
Placing Futures Orders via REST #
All orders go through POST /v1/accounts/{account_id}/orders. The endpoint handles equities, options, futures, multileg, and bracket orders — differentiated by the class parameter.
| Parameter | Values | Notes |
|---|---|---|
| class | equity, future, multileg, oto, oco, otoco | Most futures use "equity" class |
| symbol | MES, MNQ, ESM25, etc. | Verify exact format in sandbox first |
| side | buy, sell | Direction of the order |
| quantity | Integer | Number of contracts |
| type | market, limit, stop, stop_limit | Order execution type |
| duration | day, gtc, pre, post | Time in force |
| price | Decimal | Required for limit/stop_limit |
| preview | true | Validate without submitting -- use this during development |
Order Placement with Preview Validation
def place_order(client, account_id, symbol, side, quantity,
order_type, duration="day", price=None, preview=False):
if quantity <= 0:
raise ValueError(f"Quantity must be positive, got {quantity}")
if order_type == "limit" and price is None:
raise ValueError("Limit orders require a price")
data = {
"class": "equity", "symbol": symbol, "side": side,
"quantity": str(quantity), "type": order_type, "duration": duration
}
if price is not None: data["price"] = str(price)
if preview: data["preview"] = "true"
return client.post(f"/accounts/{account_id}/orders", data)
# Development workflow: always preview first
client = TradierClient(sandbox=True)
preview = place_order(client, account_id, "MES", "buy", 1,
"limit", price=5450.00, preview=True)
print(f"Preview: {preview}") # Shows commissions, margin impact, warnings
# When satisfied, place for real (still in sandbox)
order = place_order(client, account_id, "MES", "buy", 1, "limit", price=5450.00)
order_id = str(order["order"]["id"])
Bracket Orders -- The Systematic Trader Default
Submitting entry + stop + take-profit as a single atomic order eliminates race conditions between legs. Tradier supports this via OTO (One-Triggers-Other) orders.
def place_bracket_oto(client, account_id, symbol, side,
quantity, entry_price, stop_price):
opposite = "sell" if side == "buy" else "buy"
data = {
"class": "oto",
# Leg 0: Entry
"type[0]": "limit", "symbol[0]": symbol,
"side[0]": side, "quantity[0]": str(quantity),
"duration[0]": "day", "price[0]": str(entry_price),
# Leg 1: Stop (triggered when entry fills)
"type[1]": "stop", "symbol[1]": symbol,
"side[1]": opposite, "quantity[1]": str(quantity),
"duration[1]": "gtc", "stop[1]": str(stop_price),
}
return client.post(f"/accounts/{account_id}/orders", data)
The order status lifecycle: pending → open → partially_filled → filled / canceled / expired. Your bot's state machine needs to handle all of these, including the partial-fill-then-cancel scenario that trips up most first implementations.
WebSocket Streaming: Real-Time Order Events #
Tradier's account event WebSocket is session-based. You create a streaming session via REST first — it expires in 5 minutes — then connect to the WebSocket using that sessionid within that window.
Session Creation and Connection (Python asyncio)
import asyncio, json, websockets
WS_PROD = "wss://ws.tradier.com/v1/accounts/events"
WS_SANDBOX = "wss://sandbox-ws.tradier.com/v1/accounts/events"
def create_streaming_session(client) -> str:
resp = client.post("/accounts/events", {})
return resp["stream"]["sessionid"]
class AccountEventStream:
def __init__(self, client, on_fill, on_order_update):
self.client = client
self.on_fill = on_fill
self.on_order_update = on_order_update
self.ws_url = WS_SANDBOX if client.sandbox else WS_PROD
self._running = False
async def connect(self):
self._running = True
while self._running:
try:
session_id = create_streaming_session(self.client)
async with websockets.connect(self.ws_url) as ws:
await ws.send(json.dumps({
"events": ["order"],
"sessionid": session_id,
"excludeAccounts": []
}))
async for raw_msg in ws:
event = json.loads(raw_msg)
status = event.get("status")
if status == "filled":
await self.on_fill(event)
else:
await self.on_order_update(event)
except websockets.ConnectionClosed:
await asyncio.sleep(2) # backoff before reconnect
The WebSocket fill event payload does NOT include the symbol. Maintain a local order_id → symbol mapping populated when orders are submitted, then look up by order_id when fills arrive.
Reconnect protocol (critical, do not skip): 1) Detect disconnect, 2) POST /accounts/events for new sessionid, 3) Connect WebSocket within 5 minutes, 4) GET /accounts/{id}/positions REST reconciliation, 5) Resume strategy. Skipping step 4 means the strategy may resume with stale position data.
Position Management #
Position management uses two sources: REST (authoritative, pull-based) and WebSocket (fast, push-based). A production bot uses both and reconciles them every 5 minutes.
class PositionBook:
def __init__(self):
self.positions = {} # symbol -> {qty, avg_price}
self._order_map = {} # order_id -> (symbol, side, qty)
def register_order(self, order_id, symbol, side, qty):
self._order_map[str(order_id)] = (symbol, side, qty)
def on_fill(self, event):
order_id = str(event["id"])
if order_id not in self._order_map:
return # unknown order -- reconcile from REST
symbol, side, qty = self._order_map[order_id]
delta = qty if side == "buy" else -qty
if symbol in self.positions:
self.positions[symbol]["qty"] += delta
if self.positions[symbol]["qty"] == 0:
del self.positions[symbol]
else:
self.positions[symbol] = {
"qty": delta,
"avg_price": float(event.get("avg_fill_price", 0))
}
def sync_from_rest(self, rest_positions):
"""Full reconciliation -- call periodically and on reconnect."""
self.positions = {
p["symbol"]: {
"qty": int(p["quantity"]),
"avg_price": float(p["cost_basis"])
}
for p in rest_positions
}
def net_contracts(self, symbol: str) -> int:
return self.positions.get(symbol, {"qty": 0})["qty"]
Reconcile against REST at startup, after every WebSocket reconnect, and every 5 minutes. Position drift is subtle — a missed fill event creates a phantom position that compounds into larger errors in subsequent orders.
A Complete Bot Architecture #
MAX_CONTRACTS = 5 # Hard ceiling per symbol
MAX_DAILY_LOSS = 500 # Circuit breaker in dollars
class FuturesBot:
def __init__(self, sandbox=True):
self.client = TradierClient(sandbox=sandbox)
self.account_id = os.environ["TRADIER_ACCOUNT_ID"]
self.book = PositionBook()
self.daily_pnl = 0.0
self.active = True
async def startup(self):
positions = get_positions(self.client, self.account_id)
self.book.sync_from_rest(positions)
stream = AccountEventStream(
client=self.client,
on_fill=self.handle_fill,
on_order_update=self.handle_order_update
)
asyncio.create_task(stream.connect())
asyncio.create_task(self._reconcile_loop())
def risk_check(self, symbol, side, quantity) -> bool:
if not self.active: return False
current = self.book.net_contracts(symbol)
proposed = abs(current + (quantity if side=="buy" else -quantity))
if proposed > MAX_CONTRACTS:
return False
if self.daily_pnl < -MAX_DAILY_LOSS:
self.engage_kill_switch()
return False
return True
def submit_order(self, symbol, side, quantity, order_type, price=None):
if not self.risk_check(symbol, side, quantity):
return None
order = place_order(self.client, self.account_id,
symbol, side, quantity, order_type, price=price)
order_id = str(order["order"]["id"])
self.book.register_order(order_id, symbol, side, quantity)
return order
def engage_kill_switch(self):
self.active = False # no new orders; does NOT cancel existing
async def _reconcile_loop(self):
while True:
await asyncio.sleep(300)
positions = get_positions(self.client, self.account_id)
self.book.sync_from_rest(positions)Sandbox Testing Workflow #
The sandbox at https://sandbox.tradier.com/v1 is the correct development environment. Not a local mock. Not a manual simulation. The real API with paper money. The sandbox runs the same validation engine as production — an order rejected in sandbox will be rejected in production. This makes it genuinely useful, not just a confidence builder.
| Behavior | Sandbox | Production |
|---|---|---|
| Market order fills | Instant at last quote price | Real CME matching engine |
| Margin requirements | Relaxed | Full NFA/CME margins |
| Quote data | Delayed | Real-time (subscription dependent) |
| WS account events | sandbox-ws.tradier.com | ws.tradier.com |
| Order validation rules | Full enforcement | Full enforcement |
As @iantg noted in a NexusFi thread on when to take automated strategies live: sandbox performance is necessary but not sufficient. Paper results validate your logic. Live results validate your execution under real market conditions.
Progressive Test Sequence
# Phase 1: Authentication
profile = client.get("/user/profile")
print(profile["profile"]["account"]["account_number"]) # must succeed
# Phase 2: Order flow -- place far-from-market limit, verify open, cancel
order = place_order(client, acct, "MES", "buy", 1, "limit", price=1000.00)
order_id = str(order["order"]["id"])
status = client.get(f"/accounts/{acct}/orders/{order_id}")
assert status["order"]["status"] == "open"
# cancel it...
assert cancel_response["order"]["status"] in ("canceled", "pending_cancel")
# Phase 3: WebSocket -- market order, verify fill event fires with correct fields
# Phase 4: Position reconciliation -- fill an order, verify book matches REST
# Phase 5: Full strategy cycle -- signal to entry to exit with P&L trackingProduction Go-Live Checklist #
Going live is a configuration change, not a code change. That's the point of the sandbox architecture.
# .env.production
TRADIER_ENV=production
TRADIER_PROD_KEY=your_production_token
TRADIER_ACCOUNT_ID=your_live_account_id
# Code stays identical
sandbox = os.environ.get("TRADIER_ENV","sandbox") != "production"
client = TradierClient(sandbox=sandbox)
| Checklist Item | Action Threshold |
|---|---|
| Order Submission Latency P99 | >500ms = connectivity issue |
| API 429 Rate | Any 429 = add exponential backoff with jitter |
| WebSocket Disconnects | >2/hour = stream reliability problem |
| Reconciliation Drift | Any mismatch = halt bot immediately |
| Daily P&L vs circuit breaker | Monitor percentage of daily budget consumed |
| Order Rejection Rate | >5% = margin or order rule issue |
Week 1 protocol: Start with MAX_CONTRACTS = 1 on a single symbol. Verify fills, reconciliation, and P&L tracking in production before scaling. The sandbox tells you the code works. The first live week tells you the code works under real market conditions with real fills. As @koganam documented in a bot trading journal, knowing how to shut the system down manually is as important as knowing how to start it.
MANUAL KILL PROCEDURE (document before going live):
- web.tradier.com → Orders → Cancel All
- Settings → API → Disable production key
Software kill switch disables new orders.
Manual procedure handles broken code.
Cost Comparison for Systematic Traders #
Tradier Pro Plus at $0.25/side ($0.60 all-in with the $0.35 NFA/exchange/routing pass-through) is the best rate structure for systematic Micro futures traders running meaningful volume. As @bobwest laid out in a NexusFi discussion on futures trading costs: always compare "commission and fees" not just commission — the pass-through fees apply identically across all brokers.
The Pro Plus math: $35/month buys access to $0.25 Micro commissions vs $0.35 on Pro. The $0.10/contract savings pays back the $25/month premium at 250 Micro sides per month (roughly 12 round trips per session on a 22-session month). Any systematic trader above that threshold — and most are — should default to Pro Plus.
API access is included at all tiers. No separate developer fee, no "API add-on" SKU. The same REST infrastructure is available on the $0/month Lite plan as on Pro Plus.
Market Data for Signal Generation #
The order API handles execution. Signal generation needs market data. Tradier provides both REST quotes for periodic checks and WebSocket streaming for tick-by-tick access.
def get_quote(client, symbol):
resp = client.get("/markets/quotes",
{"symbols": symbol, "greeks": "false"})
return resp.get("quotes", {}).get("quote", {})
quote = get_quote(client, "MES")
print(f"MES: bid {quote['bid']}, ask {quote['ask']}, last {quote['last']}")
Account event streaming and market data streaming use separate sessions and separate endpoints. If your bot needs both fill notifications and real-time quotes, maintain two WebSocket connections with separate sessionids.
For sophisticated signal generation, many systematic traders pair Tradier for execution with a dedicated data feed — DTN IQFeed, for example — for market data. The execution API and data feed are fully independent. Tradier handles the order layer; IQFeed handles the data layer. This architecture is common at the professional level and maps cleanly to Tradier's stateless API design. As discussed in NexusFi threads on platform backtesting and live execution, the separation of execution from data enables platform flexibility without sacrificing reliability in either layer.
Common Failure Modes and How to Handle Them #
Rate limiting (429 errors)
import time
def request_with_retry(fn, max_retries=3):
for attempt in range(max_retries):
try:
return fn()
except requests.HTTPError as e:
if e.response.status_code == 429:
wait = (2 ** attempt) + (0.1 * attempt)
time.sleep(wait) # exponential backoff with jitter
else:
raise
raise RuntimeError("Max retries exceeded")
Partial fill then cancel
A 3-contract order where 2 fill and 1 cancels at session close. Strategy expected 0 or 3. Got 2. Handle the partially_filled → canceled transition explicitly — check executed_quantity vs quantity when status is canceled and adjust strategy state so.
Never submit your first live order without verifying the exact symbol format in sandbox. Wrong symbol format returns a clear API error in sandbox — the same error would reject your order in production. Sandbox-first symbol verification is not optional.
Symbol format mismatches
Tradier uses MES (continuous) and specific contract symbols like MESM25 (June 2025). Most systematic traders use the continuous symbol. Verify the exact symbol format by querying /markets/quotes?symbols=MES in sandbox before your first live order. Wrong symbol format returns a clear API error in sandbox — catch it there, not in production.
The Tradier MCP Server: AI-Assisted Development #
Tradier's hosted MCP server at tradier.com/individuals/mcp-server lets AI coding assistants — Claude, Cursor, Gemini CLI — interact directly with the brokerage API. Configure with sandbox tokens during development. Never give an AI assistant your production API key.
The practical workflow: describe the behavior to an AI assistant, the AI writes the code and tests it against your sandbox account in real time, you see working code verified against real API responses. The bottleneck in going from strategy idea to verified execution code is usually the translation step. As discussed in a NexusFi automated trading thread, the MCP server directly addresses that bottleneck for developers using LLM-assisted development tools.
Available through the MCP server: market data, options chains, order placement, account balances, positions, watchlists, and paper trading in the sandbox environment.
Knowledge Map
Go Deeper
Build on this knowledgeReferences This Article
Articles that build on this topicCitations
- — How do you decide when an automated strategy is good enough to take live? (2018) 👍 1“The sandbox tells you whether your strategy logic and order handling code works correctly. Going live tells you whether your execution infrastructure -- latency, fills, connection stability -- can handle real market conditions.”
- — Bot Trading - MCL Futures (2022)“Before your first live trade, document exactly how you will manually shut the system down. Cancel all open orders from the web interface. Disable the API key. Know where these buttons are before you need them.”
- — Costs associated with trading futures? (2020) 👍 7“When comparing broker costs, look at the complete all-in rate: commission plus exchange fees, NFA fees, and clearing fees. The exchange and NFA fees are pass-through charges that are identical at every broker -- they come directly from CME.”
- — Platforms that support true portfolio backtesting/trading? (2018) 👍 7“Separating your data feed from your execution broker gives you architectural flexibility. If the execution layer has issues, your data and signal generation remain independent and unaffected.”
- — Algo automated / semi-automated trading anyone? (2025) 👍 4“AI coding tools have genuinely changed the development cycle for automated strategies. The bottleneck used to be translating strategy logic into correct API calls. Now that gap closes much faster when the AI can interact with real sandbox responses.”
- — Tradier Brokerage API Reference (2024)
- — Micro E-mini Futures Product Specifications (2024)
- — Taking a Trading System Live (2013) 👍 11“The walkforward backtest history plus incubation period -- watching the system perform real-time with no code changes -- gives you the statistical foundation to make the go-live call with confidence rather than hope.”
- — From paper money to real money? (2018) 👍 2“A handful of live trades (5 to 10) is often enough to validate whether your testing tools accurately predict live execution. You do not need weeks of live trading to establish this -- just enough to compare expected vs actual fills.”
- — ZB23's Algo Trading Journey (2021) 👍 3“Python is the right language for algorithmic trading development. The ecosystem -- pandas, numpy, asyncio, websockets, requests -- maps naturally to the problems a systematic trader needs to solve.”
- — When to Quit Paper Trading/Replay and Go Live? (2021) 👍 7“Paper trading until you can fully define your setups, criteria, and probability model -- then go live with the smallest possible size. The goal of week one live is not profit; it is confirming that execution matches expectations.”
