Build an AI Trading Bot in Python: Safe Paper-Trading Guide
Reviewed: August 12, 2026. This tutorial builds a paper-trading system in Python that cannot place a real exchange order. It shows the engineering boundary an AI-generated signal must cross: validation, position limits, a daily-loss circuit breaker, duplicate protection, and an execution adapter.
It does not provide a profitable strategy or investment recommendation. The US Commodity Futures Trading Commission warns that AI cannot predict sudden market changes and that claims of guaranteed or unusually high bot returns are a fraud red flag. Treat the project as software-engineering practice, not evidence that you should trade.
A trading bot needs separate decision and authority layers
A safe design does not let a model, indicator, or news feed call an exchange directly.
- Data layer: normalises market data, timestamps, symbol rules, balances, orders, and positions.
- Signal layer: proposes an action with an entry, stop, quantity, reason, and expiry.
- Risk layer: independently rejects malformed, stale, oversized, duplicate, or disallowed proposals.
- Execution layer: converts an approved proposal into an exchange-specific order and reconciles the actual state.
- Control layer: monitors losses, stale data, connectivity, unexpected positions, and emergency shutdown.
The signal layer has no credential. The execution process has the minimum permission needed and accepts only a validated internal order type. This separation matters more than whether the signal came from an LLM, a moving average, or a human.
Build a runnable paper-trading risk gate
This standard-library project performs no network requests. Save it as paper_bot.py and run it with Python 3.10 or later.
from __future__ import annotations
import math
from dataclasses import dataclass
from decimal import Decimal
from hashlib import sha256
from typing import Literal
Side = Literal["buy", "sell"]
@dataclass(frozen=True)
class TradeProposal:
symbol: str
side: Side
quantity: Decimal
entry: Decimal
stop: Decimal
signal_time_ms: int
@property
def notional(self) -> Decimal:
return self.quantity * self.entry
@property
def risk_amount(self) -> Decimal:
return self.quantity * abs(self.entry - self.stop)
@property
def fingerprint(self) -> str:
raw = (
f"{self.symbol}|{self.side}|{self.quantity}|"
f"{self.entry}|{self.stop}|{self.signal_time_ms}"
)
return sha256(raw.encode()).hexdigest()
@dataclass(frozen=True)
class RiskLimits:
allowed_symbols: frozenset[str]
max_notional: Decimal
max_risk_fraction: Decimal
max_daily_loss: Decimal
max_open_orders: int
max_signal_age_ms: int
class RiskRejected(ValueError):
pass
class RiskGuard:
def __init__(self, limits: RiskLimits) -> None:
self.limits = limits
self.seen: set[str] = set()
def validate(
self,
proposal: TradeProposal,
*,
equity: Decimal,
realised_pnl_today: Decimal,
open_orders: int,
now_ms: int,
) -> None:
values = (
proposal.quantity,
proposal.entry,
proposal.stop,
equity,
realised_pnl_today,
)
if not all(math.isfinite(float(value)) for value in values):
raise RiskRejected("A numeric value is not finite")
if proposal.symbol not in self.limits.allowed_symbols:
raise RiskRejected("Symbol is not allowlisted")
if proposal.quantity <= 0 or proposal.entry <= 0 or proposal.stop <= 0:
raise RiskRejected("Quantity and prices must be positive")
if proposal.side == "buy" and proposal.stop >= proposal.entry:
raise RiskRejected("A buy stop must be below entry")
if proposal.side == "sell" and proposal.stop <= proposal.entry:
raise RiskRejected("A sell stop must be above entry")
if now_ms - proposal.signal_time_ms > self.limits.max_signal_age_ms:
raise RiskRejected("Signal is stale")
if proposal.signal_time_ms > now_ms:
raise RiskRejected("Signal timestamp is in the future")
if proposal.notional > self.limits.max_notional:
raise RiskRejected("Position notional exceeds the hard limit")
if equity <= 0:
raise RiskRejected("Equity must be positive")
if proposal.risk_amount > equity * self.limits.max_risk_fraction:
raise RiskRejected("Stop-distance risk exceeds the equity limit")
if realised_pnl_today <= -self.limits.max_daily_loss:
raise RiskRejected("Daily loss circuit breaker is active")
if open_orders >= self.limits.max_open_orders:
raise RiskRejected("Open-order limit reached")
if proposal.fingerprint in self.seen:
raise RiskRejected("Duplicate proposal")
self.seen.add(proposal.fingerprint)
class PaperBroker:
def submit(self, proposal: TradeProposal) -> dict[str, str]:
return {
"status": "paper-accepted",
"symbol": proposal.symbol,
"side": proposal.side,
"quantity": str(proposal.quantity),
"notional": str(proposal.notional),
}
def main() -> None:
limits = RiskLimits(
allowed_symbols=frozenset({"BTC-USD", "ETH-USD"}),
max_notional=Decimal("500"),
max_risk_fraction=Decimal("0.01"),
max_daily_loss=Decimal("50"),
max_open_orders=2,
max_signal_age_ms=30_000,
)
guard = RiskGuard(limits)
proposal = TradeProposal(
symbol="BTC-USD",
side="buy",
quantity=Decimal("0.005"),
entry=Decimal("60000"),
stop=Decimal("59000"),
signal_time_ms=1_000_000,
)
guard.validate(
proposal,
equity=Decimal("1000"),
realised_pnl_today=Decimal("-10"),
open_orders=0,
now_ms=1_005_000,
)
print(PaperBroker().submit(proposal))
if __name__ == "__main__":
main()
The sample proposal has a USD 300 notional and USD 5 stop-distance risk. With USD 1,000 equity and a one-percent risk ceiling, it passes. The numbers illustrate the calculation; they are not recommended limits.
What this guard still does not know
It does not know available balance, exchange lot size, minimum notional, fees, slippage, current bid/ask, existing position exposure, correlated positions, stop-order support, or whether the market data is trustworthy. A production validator must check those facts from authoritative state, not from the signal payload.
Prove that rejection paths work
Save this beside the project as test_paper_bot.py:
from dataclasses import replace
from decimal import Decimal
import pytest
from paper_bot import RiskGuard, RiskLimits, RiskRejected, TradeProposal
LIMITS = RiskLimits(
allowed_symbols=frozenset({"BTC-USD"}),
max_notional=Decimal("500"),
max_risk_fraction=Decimal("0.01"),
max_daily_loss=Decimal("50"),
max_open_orders=2,
max_signal_age_ms=30_000,
)
BASE = TradeProposal(
"BTC-USD", "buy", Decimal("0.005"), Decimal("60000"),
Decimal("59000"), 1_000_000,
)
def validate(guard: RiskGuard, proposal: TradeProposal) -> None:
guard.validate(
proposal,
equity=Decimal("1000"),
realised_pnl_today=Decimal("0"),
open_orders=0,
now_ms=1_005_000,
)
def test_valid_proposal_passes() -> None:
validate(RiskGuard(LIMITS), BASE)
@pytest.mark.parametrize(
"proposal",
[
replace(BASE, symbol="NOT-ALLOWED"),
replace(BASE, quantity=Decimal("0")),
replace(BASE, stop=Decimal("61000")),
replace(BASE, quantity=Decimal("0.02")),
replace(BASE, signal_time_ms=900_000),
],
)
def test_bad_proposals_are_rejected(proposal: TradeProposal) -> None:
with pytest.raises(RiskRejected):
validate(RiskGuard(LIMITS), proposal)
def test_duplicate_is_rejected() -> None:
guard = RiskGuard(LIMITS)
validate(guard, BASE)
with pytest.raises(RiskRejected, match="Duplicate"):
validate(guard, BASE)
python -m pip install pytest
pytest -q
Add separate tests for the daily-loss limit, future timestamps, open-order cap, and non-finite values. A risk control without a failing test is an intention, not a control.
Where AI belongs, and where it does not
An LLM can convert unstructured information into a bounded proposal: classify a document, extract named risks, or produce a sentiment score with cited evidence. It should not calculate the final order quantity from an unconstrained prompt or hold the exchange credential.
Use a strict schema such as:
{
"symbol": "BTC-USD",
"direction": "none",
"confidence": 0.42,
"evidence_ids": ["news-2026-08-12-004"],
"expires_at": "2026-08-12T10:05:00Z"
}
“None” must be a valid and common result. The application validates the schema, verifies evidence IDs and timestamps, combines the signal with deterministic market data, and may still reject it. Never treat a model’s confidence number as a calibrated probability unless you have measured calibration on representative data.
Design the exchange adapter last
Keep the paper broker behind an interface. When the rest of the system is tested, a separate adapter can translate approved internal orders into one exchange’s exact symbol, precision, order type, client-order ID, and authentication scheme.
Do not copy a generic create_order() call and assume success. Exchanges enforce minimum sizes, price increments, permission scopes, timestamps, rate limits, and product-specific rules. Binance’s Spot API, for example, marks order endpoints with TRADE security and says trade permission is disabled on a new key by default.
Use a sandbox or testnet first
Use the official environment for the exact product. Coinbase’s Advanced Trade sandbox currently returns static, predefined responses; it is useful for request/response integration but does not simulate a real matching engine. A passing sandbox test therefore proves formatting, not profitability, fill quality, or production reliability.
Treat an API timeout as an unknown state
The most dangerous retry bug is assuming “no response” means “no order.” Binance documents that a matching-engine timeout can leave execution status unknown. The correct sequence is:
- create a unique client-order ID before submission;
- store the pending intent durably;
- submit once;
- on timeout, query order status or consume the authenticated user-data stream;
- retry only when the exchange proves the original order does not exist;
- reconcile open orders, fills, balances, and positions continuously.
This is idempotency at the application boundary. Without it, one network delay can turn a desired position into two orders.
Backtest without fooling yourself
- Use data that was genuinely available at the decision timestamp.
- Include trading fees, bid/ask spread, slippage, funding, and rejected orders.
- Avoid look-ahead bias, survivorship bias, and tuning on the test period.
- Split development, validation, and untouched out-of-sample periods.
- Use walk-forward evaluation when market regimes change.
- Compare with simple baselines and a no-trade outcome.
- Report drawdown, turnover, exposure, and loss distribution, not only total return.
Then paper trade with live data long enough to observe disconnects, missing candles, partial fills, clock drift, maintenance, and restarts. Paper success is permission to investigate further, not proof that real-money deployment is justified.
Exchange API key safety
- Create separate read-only and trading credentials where the exchange permits it.
- Never enable withdrawal or transfer permission for a trading process.
- Restrict the key to the required portfolio, products, and operations.
- Apply an IP allowlist when supported and your deployment has stable egress.
- Store secrets in a managed secret store; environment variables are safer than source code but can still leak through process or debug tooling.
- Never place a secret in browser/mobile code, a notebook, prompt, log, screenshot, or repository.
- Rotate and revoke unused keys; alert on authentication failures and unexpected orders.
- Run the execution service with a dedicated OS identity and minimal network/file access.
Coinbase’s current security guide recommends least privilege, IP allowlisting, keeping key files outside the source tree, secure storage, rotation, and deletion of unused keys. Apply the exchange’s own instructions rather than assuming every provider uses the same signature or permission model.
Minimum evidence before any live-money discussion
- Every risk rule has positive, negative, boundary, and restart tests.
- The signal process cannot access the trading secret.
- The key cannot withdraw or transfer assets.
- Orders have durable client IDs and unknown-state reconciliation.
- The bot rebuilds positions and open orders after a crash.
- Stale or missing market data stops new orders.
- A server-side kill switch works without the model process.
- Logs prove what input, rule, approval, order, and fill occurred.
- Backtests include realistic costs and untouched evaluation data.
- A qualified professional has addressed legal, tax, exchange, and suitability questions for the jurisdiction and product.
Start with the Python automation course if dataclasses, tests, and failure handling are new. The secure MCP Python tutorial explains why model tool access is an authority boundary, while AI mistakes that damage credibility provides an evidence-audit workflow.
Frequently asked questions
Can an AI trading bot guarantee profit?
No. Markets change, data and execution fail, and models can be wrong. Guaranteed-return or near-perfect-win claims are a warning sign, not evidence.
Should an LLM place orders directly?
No. Let it produce a bounded, expiring proposal without credentials. Deterministic code must validate account state, market rules, risk limits, and authorisation before an execution adapter can act.
Is a two-percent risk limit safe?
No fixed percentage is universally safe. The example uses one percent only to demonstrate arithmetic. Product, leverage, liquidity, correlation, stop behaviour, total exposure, and personal circumstances change the risk.
Why use Decimal instead of float?
Decimal makes decimal arithmetic and comparisons more explicit. Exchange quantities and prices must still be rounded with the exchange’s current tick-size and lot-size rules.
When is a bot ready for production?
There is no tutorial threshold. Paper tests, risk controls, operational evidence, and compliance review can reveal defects; they cannot remove market risk or guarantee suitability.
Official sources and review rule
- CFTC: AI trading bots are not money machines
- Binance Spot REST API security and timeout behaviour
- Binance developer environments and authentication
- Coinbase Advanced Trade API sandbox
- Coinbase API security best practices
Review rule: check exchange authentication, permissions, sandbox behaviour, order semantics, limits, and API versions before every integration release and at least quarterly. Re-run all tests before updating the reviewed date.






