Tutorial
Python trading bot for crypto: from live WebSocket data to signals
Most bot tutorials are an exchange SDK plus a moving average. The parts that decide whether a bot survives are elsewhere: clean bars, honest fills, position sizing, and a kill switch you trust.
On this page
- Python trading bot architecture: five parts, one process
- Part 1: configuration and logging
- Part 2: indicators that update one bar at a time
- Part 3: a paper broker that pays the spread and fees
- Part 4: bars, signals and risk checks
- Position sizing and risk limits
- Part 5: the run loop
- Test the engine without the network
- Before you trust the results
- Questions
Key takeaways
- A paper-trading bot has five parts: a REST warm-up, bars built from the trade stream, indicators, a signal, and a risk-checked paper broker.
- Build bars from each trade's `ts` (the event time) and skip the minute you join part-way, or your first live bar is incomplete.
- Fill paper buys at the ask and sells at the bid, and charge fees: in the replay test, fees were 5.00 of a 6.36 USD loss.
- Size from risk: equity x risk per trade / stop distance, capped by maximum exposure. On 1-minute BTCUSD bars the cap, not the stop, set the size.
- At an assumed 10 bps per side, a round trip costs about 166 USD per bitcoin at 82,980, more than twice a 75 USD stop built from a 1-minute ATR.
A Python trading bot is a loop that turns market data into decisions: read prices, update indicators, check a rule, size a position, and record what it would do. This tutorial builds that loop on a live crypto WebSocket from a crypto price API, with an EMA crossover signal, ATR position sizing, four risk limits and a paper broker that pays the spread and fees. It never sends an order.
Python trading bot architecture: five parts, one process
- Streamcrypto.quotes + crypto.trades
- Bar builder1-minute OHLC from trade ts
- IndicatorsEMA 9, EMA 21, ATR 14
- Signal + riskcrossover, 4 guards, sizing
- Paper brokerask/bid fills, fees, CSV journal
Two channels feed it. crypto.trades supplies prices for bars and stop checks. crypto.quotes supplies the bid and ask the paper broker fills against, and its ts tells the bot how fresh the quote is. The stream sends crypto prices as strings, so every numeric goes through float(). The whole program is about 200 lines in five parts; paste them into one file in order.
| Setting | Value | What it controls |
|---|---|---|
BAR_MINUTES | 1 | Bar size for signals: 1, 5 or 15 minutes |
FAST, SLOW | 9, 21 | EMA lengths for the crossover |
RISK_PER_TRADE | 0.5% | Equity lost if the stop is hit |
STOP_ATR | 2 | Stop distance in multiples of ATR(14) |
MAX_EXPOSURE | 25% | Cap on position value as a share of equity |
FEE_BPS | 10 | Assumed fee per side, in basis points |
MAX_SPREAD_BPS | 5 | No entry when the spread is wider |
MAX_QUOTE_AGE_MS | 5,000 | No entry on a quote older than this |
DAILY_LOSS_LIMIT | 2% | No new entries after this loss on the UTC day |
Part 1: configuration and logging
"""Paper-trading signal engine on a live crypto stream. Educational: it never places an order."""
import asyncio
import csv
import json
import logging
import os
import random
import time
from collections import deque
from datetime import datetime, timedelta, timezone
from urllib.parse import quote
import requests
import websockets # pip install websockets requests
API = "https://api.tickerlayer.com"
KEY = os.environ["TICKERLAYER_API_KEY"]
STREAM = "wss://stream.tickerlayer.com/?apiKey=" + quote(KEY, safe="")
SYMBOL = "BTCUSD"
BAR_MINUTES = 1 # 1, 5 or 15
BAR_MS = BAR_MINUTES * 60_000
FAST, SLOW, ATR_N = 9, 21, 14
START_EQUITY = 10_000.0
RISK_PER_TRADE = 0.005 # lose at most 0.5% of equity if the stop is hit
STOP_ATR = 2.0 # stop distance = 2 x ATR(14)
MAX_EXPOSURE = 0.25 # never hold more than 25% of equity in the position
FEE_BPS = 10 # assumed fee per side, in basis points
MAX_SPREAD_BPS = 5.0
MAX_QUOTE_AGE_MS = 5_000
DAILY_LOSS_LIMIT = 0.02 # no new entries after a 2% loss on the UTC day
logging.Formatter.converter = time.gmtime # log in UTC
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(message)s",
handlers=[logging.StreamHandler(), logging.FileHandler("paper_bot.log")],
)
log = logging.getLogger("bot")Logs go to the terminal and to paper_bot.log, in UTC, so a log line can be matched against bar timestamps without time-zone arithmetic. The key goes into the stream URL percent-encoded; never log that URL.
Part 2: indicators that update one bar at a time
EMA(t) = EMA(t-1) + α × (close(t) - EMA(t-1)), α = 2 ÷ (n + 1)TR(t) = max(high - low, |high - close(t-1)|, |low - close(t-1)|)ATR(t) = (ATR(t-1) × 13 + TR(t)) ÷ 14
- EMA
- Exponential moving average, seeded with a simple average of the first n closes.
- TR
- True range: the bar's range, extended to include any gap from the previous close.
- ATR
- Average true range with Wilder smoothing: a volatility yardstick in price units.
class Indicators:
"""Incremental EMA(FAST), EMA(SLOW) and Wilder ATR(ATR_N) over closed bars."""
def __init__(self):
self.n, self.window = 0, deque(maxlen=SLOW)
self.fast = self.slow = self.atr = self.prev_close = None
def update(self, bar):
h, l, c = bar["h"], bar["l"], bar["c"]
tr = h - l if self.prev_close is None else max(h - l, abs(h - self.prev_close), abs(l - self.prev_close))
self.atr = tr if self.atr is None else (self.atr * (ATR_N - 1) + tr) / ATR_N
self.prev_close = c
self.n += 1
self.window.append(c)
self.fast = self._ema(self.fast, c, FAST)
self.slow = self._ema(self.slow, c, SLOW)
def _ema(self, prev, price, n):
if self.n < n:
return None
if prev is None:
return sum(list(self.window)[-n:]) / n # seed with a simple average
return prev + 2 / (n + 1) * (price - prev)
@property
def ready(self):
return self.n > SLOW + ATR_NIncremental indicators matter in a live loop. Recomputing a 21-bar average from a growing list each minute works for a day and then quietly slows the loop down; updating one value per bar costs the same forever. The same Wilder smoothing drives the RSI indicator, and a crossover of two moving averages is the idea behind the golden cross, on a much longer timescale.
Part 3: a paper broker that pays the spread and fees
class PaperBroker:
def __init__(self):
self.cash, self.qty, self.stop = START_EQUITY, 0.0, None
self.day, self.day_start_equity = None, START_EQUITY
self.file = open("paper_trades.csv", "a", newline="")
self.journal = csv.writer(self.file)
def equity(self, bid):
return self.cash + self.qty * bid
def buy(self, qty, ask, stop, ts):
cost = qty * ask * (1 + FEE_BPS / 10_000)
self.cash -= cost
self.qty, self.stop = qty, stop
self._record("BUY", qty, ask, ts)
def sell(self, bid, ts, why):
self.cash += self.qty * bid * (1 - FEE_BPS / 10_000)
self._record(f"SELL ({why})", self.qty, bid, ts)
self.qty, self.stop = 0.0, None
def _record(self, side, qty, price, ts):
when = datetime.fromtimestamp(ts / 1000, tz=timezone.utc).isoformat()
self.journal.writerow([when, SYMBOL, side, f"{qty:.6f}", f"{price:.2f}", f"{self.cash:.2f}"])
self.file.flush()
log.info("%s %.6f %s at %.2f, cash %.2f", side, qty, SYMBOL, price, self.cash)The most common way a paper bot lies is by filling at the last trade or the mid. A real market order buys at the ask and sells at the bid, then pays a fee on both sides. This broker does exactly that, so its results are pessimistic in the right direction. The CSV journal is flushed on every write, so a crash never loses the record of what the bot did; the bid-ask spread explainer covers why the spread matters.
Part 4: bars, signals and risk checks
class Engine:
def __init__(self):
self.ind, self.broker = Indicators(), PaperBroker()
self.bar, self.bid, self.ask, self.quote_ts, self.first_live_bar = None, None, None, 0, 0
def warm_up(self):
"""Rebuild indicators from the latest closed bars (one REST call)."""
now = datetime.now(timezone.utc)
start = (now - timedelta(days=2)).date().isoformat()
resp = requests.get(
f"{API}/crypto/agg/{SYMBOL}/{BAR_MINUTES}/minute/{start}/{now.date().isoformat()}",
params={"sort": "desc", "limit": 120}, headers={"x-api-key": KEY}, timeout=15)
resp.raise_for_status()
this_minute = int(now.timestamp() * 1000) // BAR_MS * BAR_MS
closed = [b for b in reversed(resp.json()["results"]) if b["t"] < this_minute]
self.ind = Indicators()
for bar in closed:
self.ind.update(bar)
# We join the current minute part-way through, so its bar would be incomplete:
# skip it and build the first live bar from the next full minute.
self.bar, self.first_live_bar = None, this_minute + BAR_MS
log.info("warm-up: %d closed bars, EMA%d %.2f, EMA%d %.2f, ATR%d %.2f",
len(closed), FAST, self.ind.fast, SLOW, self.ind.slow, ATR_N, self.ind.atr)
def on_quote(self, msg):
self.bid, self.ask, self.quote_ts = float(msg["bid"]), float(msg["ask"]), msg["ts"]
def on_trade(self, msg):
price, ts = float(msg["price"]), msg["ts"] # trade ts is the event time
if self.broker.qty and price <= self.broker.stop and self.bid:
self.broker.sell(self.bid, ts, "stop")
start = ts // BAR_MS * BAR_MS
if start < self.first_live_bar:
return
if self.bar is None or start > self.bar["t"]:
if self.bar is not None:
self.on_bar_close(self.bar, ts)
self.bar = {"t": start, "o": price, "h": price, "l": price, "c": price}
elif start == self.bar["t"]:
self.bar["h"], self.bar["l"] = max(self.bar["h"], price), min(self.bar["l"], price)
self.bar["c"] = price
def on_bar_close(self, bar, ts):
was_above = self.ind.fast is not None and self.ind.fast > self.ind.slow
self.ind.update(bar)
if not self.ind.ready:
return
is_above = self.ind.fast > self.ind.slow
stamp = datetime.fromtimestamp(bar["t"] / 1000, tz=timezone.utc).strftime("%H:%M")
log.info("bar %s close %.2f EMA%d %.2f EMA%d %.2f ATR %.2f qty %.6f",
stamp, bar["c"], FAST, self.ind.fast, SLOW, self.ind.slow, self.ind.atr, self.broker.qty)
if is_above and not was_above and not self.broker.qty:
self.try_enter(ts)
elif was_above and not is_above and self.broker.qty and self.bid:
self.broker.sell(self.bid, ts, "cross down")
def try_enter(self, ts):
b = self.broker
if not self.ask or time.time() * 1000 - self.quote_ts > MAX_QUOTE_AGE_MS:
log.info("skip entry: no fresh quote")
return
spread_bps = (self.ask - self.bid) / ((self.ask + self.bid) / 2) * 10_000
if spread_bps > MAX_SPREAD_BPS:
log.info("skip entry: spread %.1f bps", spread_bps)
return
day = datetime.now(timezone.utc).date()
if b.day != day:
b.day, b.day_start_equity = day, b.equity(self.bid)
equity = b.equity(self.bid)
if equity < b.day_start_equity * (1 - DAILY_LOSS_LIMIT):
log.info("skip entry: daily loss limit reached")
return
stop_distance = STOP_ATR * self.ind.atr
if stop_distance <= 0:
return
qty = min(equity * RISK_PER_TRADE / stop_distance, equity * MAX_EXPOSURE / self.ask)
b.buy(qty, self.ask, self.ask - stop_distance, ts)Three decisions in the engine are easy to get wrong. Bars are built from trade ts, which is when the trade happened, while the freshness check uses quote ts against the local clock, so keep the machine's clock synced. The stop is checked on every trade, not once a minute. And the signal fires only on the bar where the fast EMA crosses the slow one, not on every bar where it is above, so the bot never re-enters the same trend.
Position sizing and risk limits
qty = min( equity × risk ÷ stop distance, equity × max exposure ÷ ask )stop distance = 2 × ATR(14)
- equity × risk
- Dollars you accept losing if the stop is hit: 10,000 × 0.5% = 50.
- stop distance
- 2 × 37.70 = 75.40 USD per bitcoin with the live warm-up ATR.
- max exposure
- Hard ceiling on position value: 25% of equity = 2,500 USD.
On 1-minute bars the ATR is small, so a pure risk-based size would put five times the account into one position. The exposure cap is what keeps the bot sane. The fee arithmetic is the sobering part: with the assumed 10 bps per side, entering and leaving costs about 166 USD per bitcoin, while the stop sits only 75 USD away. A 1-minute crossover has to win big to pay for itself, which is why the bar size is a setting; try 5 or 15 minutes and compare the journals.
Cost of one round trip vs the stop, USD per bitcoin at 82,980
The four guards before any entry
- A quote exists and is younger than
MAX_QUOTE_AGE_MS: never size off a stale price. - The spread is at most
MAX_SPREAD_BPS: a wide spread means a thin book or a bad print. - The day's loss is under
DAILY_LOSS_LIMIT: after a bad day the bot stops opening positions. - The stop distance is positive: a zero ATR would divide by zero and size to infinity.
Part 5: the run loop
async def run():
engine, backoff = Engine(), 1.0
while True:
try:
engine.warm_up() # also closes any gap left by a disconnect
async with websockets.connect(STREAM, compression=None, open_timeout=10) as ws:
if json.loads(await ws.recv()).get("event") != "ready":
raise RuntimeError("no ready frame")
await ws.send(json.dumps({"action": "subscribe",
"channels": ["crypto.quotes", "crypto.trades"], "symbols": [SYMBOL]}))
backoff = 1.0
async for raw in ws:
msg = json.loads(raw)
if msg.get("type") == "quote":
engine.on_quote(msg)
elif msg.get("type") == "trade":
engine.on_trade(msg)
elif msg.get("type") == "error":
log.error("stream error: %s", msg.get("code"))
except websockets.InvalidStatus as exc:
if exc.response.status_code in (401, 403):
raise
log.warning("upgrade refused: HTTP %s", exc.response.status_code)
except (websockets.ConnectionClosed, OSError, asyncio.TimeoutError, requests.RequestException) as exc:
log.warning("connection problem: %s", type(exc).__name__)
await asyncio.sleep(backoff + random.uniform(0, backoff / 2))
backoff = min(backoff * 2, 30.0)
if __name__ == "__main__":
asyncio.run(run())Every reconnect starts with a fresh warm-up, which also repairs the bars missed while disconnected. Open positions survive in the broker object. Backoff doubles up to 30 seconds with jitter, and 401 or 403 stop the bot, because a wrong key or a missing WebSocket entitlement will not fix itself. For the connection details, see the Python WebSocket client tutorial.
2026-09-28 10:58:51,345 warm-up: 119 closed bars, EMA9 82966.95, EMA21 82935.04, ATR14 37.70
# from here, one "bar" line per closed minute, and BUY or SELL lines when a rule firesTest the engine without the network
Waiting for a real crossover to test the code is slow. Feed the engine synthetic frames instead: a slow decline to seed the indicators, a 20-minute rally that forces a crossover, then a sharp drop that must hit the stop. If the journal does not show a buy followed by a stop-out, something is broken.
"""Replay synthetic frames through the engine: no network, no key needed."""
import os
import time
os.environ.setdefault("TICKERLAYER_API_KEY", "offline")
import paper_bot as pb # noqa: E402
engine = pb.Engine()
now_ms = int(time.time() * 1000)
t0 = now_ms // 60_000 * 60_000 - 100 * 60_000
price = 83_000.0
for i in range(60): # a slow decline seeds the indicators with EMA9 below EMA21
price -= 5
engine.ind.update({"t": t0 + i * 60_000, "h": price + 10, "l": price - 10, "c": price})
def tick(p, ts):
engine.on_quote({"bid": f"{p - 0.01:.2f}", "ask": f"{p:.2f}", "ts": int(time.time() * 1000)})
engine.on_trade({"price": f"{p:.2f}", "ts": ts})
start = t0 + 60 * 60_000
for m in range(40): # 20 minutes of rally, then a sharp drop
move = m * 8 if m < 20 else 160 - (m - 20) * 25
for k in range(6):
tick(price + move + k, start + m * 60_000 + k * 5_000)
print(open("paper_trades.csv").read().strip())11:17:19 BUY 0.030201 BTCUSD at 82780.00, cash 7497.50
11:17:19 SELL (stop) 0.030201 BTCUSD at 82734.99, cash 9993.64
2026-09-28T10:47:00+00:00,BTCUSD,BUY,0.030201,82780.00,7497.50
2026-09-28T11:02:00+00:00,BTCUSD,SELL (stop),0.030201,82734.99,9993.64The trade lost 6.36 USD. Only 1.36 of that was the price move from entry to the stop-out fill; the other 5.00 was fees. That ratio is the most useful thing a paper bot can teach before any real money is involved.
Before you trust the results
- Run paper mode for weeks, across weekends and volatile days, not one afternoon.
- Backtest the same rules on history first; the golden cross backtest shows the pattern with costs included.
- Screen for bad prints before they reach the indicators; the bad ticks guide has the checks.
- Compare the journal's fills with what the market actually offered at those times.
- Keep a manual kill switch: a file or flag the loop checks before every entry.
- If you add a language model to the loop, keep the same guards; the AI trading bot tutorial shows how.
Questions
Can you build a trading bot with Python?
Yes. Python has everything needed: websockets for streaming data, requests for REST, and plain classes for indicators and a paper broker. Start in paper mode and keep execution out until the logic has been tested for a long time.
How do I build a crypto trading bot step by step?
Warm up indicators from recent bars, stream trades and quotes, build your own bars from trades, compute indicators on each closed bar, check a signal and your risk limits, then record a paper fill at the bid or ask with fees.
What is paper trading?
Simulated trading on live prices: the bot records what it would buy and sell, at realistic prices and costs, without sending orders. It tests logic and costs without risking money.
How much should a trading bot risk per trade?
There is no universal number and this is not advice. Many examples use a fixed fraction of equity per trade with a hard cap on position size; whatever you choose, make it a setting and test it in paper mode.
Should a trading bot use WebSocket or REST?
Use REST to warm up indicators and backfill gaps, and a WebSocket for live trades and quotes. Polling REST fast enough for a live signal wastes requests and still misses moves between polls.