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
  1. Python trading bot architecture: five parts, one process
  2. Part 1: configuration and logging
  3. Part 2: indicators that update one bar at a time
  4. Part 3: a paper broker that pays the spread and fees
  5. Part 4: bars, signals and risk checks
  6. Position sizing and risk limits
  7. Part 5: the run loop
  8. Test the engine without the network
  9. Before you trust the results
  10. 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

  1. Streamcrypto.quotes + crypto.trades
  2. Bar builder1-minute OHLC from trade ts
  3. IndicatorsEMA 9, EMA 21, ATR 14
  4. Signal + riskcrossover, 4 guards, sizing
  5. Paper brokerask/bid fills, fees, CSV journal
One REST call warms the indicators up; everything after that comes from the stream.

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.

SettingValueWhat it controls
BAR_MINUTES1Bar size for signals: 1, 5 or 15 minutes
FAST, SLOW9, 21EMA lengths for the crossover
RISK_PER_TRADE0.5%Equity lost if the stop is hit
STOP_ATR2Stop distance in multiples of ATR(14)
MAX_EXPOSURE25%Cap on position value as a share of equity
FEE_BPS10Assumed fee per side, in basis points
MAX_SPREAD_BPS5No entry when the spread is wider
MAX_QUOTE_AGE_MS5,000No entry on a quote older than this
DAILY_LOSS_LIMIT2%No new entries after this loss on the UTC day
Every value is an assumption for testing, not a recommendation.

Part 1: configuration and logging

paper_bot.py (part 1 of 5)Python
"""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.
Live warm-up on 2026-09-28 at 10:58 UTC, 119 closed 1-minute BTCUSD bars: EMA9 82,966.95, EMA21 82,935.04, ATR14 37.70.
paper_bot.py (part 2 of 5)Python
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_N

Incremental 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

paper_bot.py (part 3 of 5)Python
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

paper_bot.py (part 4 of 5)Python
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.
Risk-based size 50 / 75.40 = 0.663 BTC (about 55,000 USD); the cap allows 2,500 / 82,980 = 0.0301 BTC, so the cap wins.

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

  • Spread paid (1 cent)0.01
  • Stop distance, 2 x 1-min ATR75.40
  • Fees, 10 bps per side165.96
ATR from the live warm-up on 2026-09-28; the fee rate is an assumption, check your own venue's schedule.

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

paper_bot.py (part 5 of 5)Python
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.

Output (live start, 2026-09-28)
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 fires

Test 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_test.pyPython
"""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())
Output (trimmed; synthetic prices, times depend on when you run it)
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.64

The 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.

Keep reading

Ready to integrate?

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