Most bot tutorials stop at "fetch the candles, place an order". The problems start right after: the bot acts on a candle that hasn't closed, sizes an order the exchange rejects, buys twice after a restart, or sells more coins than it holds because the fee was taken in BTC.
This guide builds a complete bot in one Python file of about 190 lines, using the ccxt library. It runs in a paper mode on real Binance prices without any API key, then on Binance's spot testnet. We ran every piece of it; the numbers below are its real output.
What the bot does
- Trades BTC/USDT on 1-hour candles, long or flat (spot).
- Enters when the 20-period EMA crosses above the 50-period EMA.
- Places its stop 2 × ATR(14) below the entry close, and sizes the position so that the stop costs 1% of the USDT balance.
- Exits when a candle closes below the stop or when the fast EMA falls back under the slow one.
- Saves its state to a file, so a restart neither forgets an open position nor repeats an order.
It's a structure to build on, not a strategy we recommend. The backtest below shows why.
Install
pip install ccxt
We used ccxt 4.5.92 on Python 3.14. ccxt gives you one interface for more than a hundred exchanges, so the same code can move from Binance to Bybit or OKX with mostly the exchange name changed.
Candles, and the one still forming
fetch_ohlcv() returns rows of [time in ms, open, high, low, close, volume], oldest first. The last row is the candle that's still forming: its close is just the latest price. A bot that decides on it will see signals appear and vanish during the hour, the same problem as repainting in Pine Script. So the first thing the bot does is drop it:
tf_ms = ex.parse_timeframe(TIMEFRAME) * 1000
candles = ex.fetch_ohlcv(SYMBOL, TIMEFRAME, limit=SLOW * 4)
candles = [c for c in candles if c[0] + tf_ms <= ex.milliseconds()] # drop the candle still forming
One signal function for the backtest and the bot
The indicators are written once and used by both the backtest and the live loop. If the two had separate code, you'd be testing one strategy and trading another. The EMA is seeded with a simple average and the ATR uses Wilder's smoothing, the same definitions as TradingView's ta.ema and ta.atr, so with enough history the values line up with a TradingView chart. You'll find ema(), atr() and signals() in the complete file below.
Backtest it first
The backtest decides on each closed candle and fills at the next candle's open, charging 0.10% per side. It also uses the bot's exact exit rule: the stop is checked on candle closes, as the live loop does.
On the 8,760 hourly candles from 1 November 2025 to 31 October 2026:
| Result | |
|---|---|
| Trades | 80 |
| Winners | 17 (21%) |
| Starting balance | 10,000 USDT |
| Final balance | 8,800.79 USDT (−12.0%) |
| BTC/USDT over the same year | 109,608 → 77,582 (−29.2%) |
The bot lost less than Bitcoin did, but that comparison means little: it risked 1% per trade and sat in cash most of the time. On its own terms it lost money, with a win rate of 21% and winners that didn't make up for it. That's the honest starting point for most simple trend bots on short timeframes, and exactly why you backtest before connecting anything. Our test of 2,160 crypto scalping strategies shows how much harder it gets on smaller timeframes, and the trading fee calculator shows how much of each trade the 0.10% fee takes.
Size the order, then respect the exchange's limits
The quantity comes from the stop, not from a fixed amount:
stop = bar["close"] - ATR_MULT * bar["atr"]
qty = min(cash * RISK_PCT / 100 / (bar["close"] - stop), cash * 0.99 / bar["close"])
The second term stops a tight stop from asking for more BTC than the cash can buy. Then two exchange rules apply to every order:
- Precision.
amount_to_precision()rounds the quantity down to the number of decimals the market accepts. - Minimums.
market["limits"]["amount"]["min"]andmarket["limits"]["cost"]["min"]give the smallest quantity and the smallest order value. Below them the exchange rejects the order, so the bot skips the trade and logs why.
One more detail that breaks many bots: on Binance spot, the fee on a buy is usually taken in the coin you receive. Buy 0.0625 BTC and you hold slightly less, so selling 0.0625 later fails for insufficient balance. The bot reads the order's filled amount and subtracts any fee charged in BTC before it records the position.
Paper mode, then the testnet
BOT_MODE=paper (the default) uses Binance's public market data and simulates fills at the candle close, with the same fee as the backtest. No account or key is needed.
BOT_MODE=testnet sends real orders to Binance's spot testnet. Create testnet keys on the Binance spot testnet site, then export them as BINANCE_TESTNET_KEY and BINANCE_TESTNET_SECRET in your shell or a .env file you never commit. ccxt's set_sandbox_mode(True) points the exchange at the testnet. The testnet has its own order book and its own prices, which can differ a lot from the real market, so use it to test the plumbing (orders, balances, precision, errors), not the strategy.
The loop: wait, act once, survive restarts
- The loop wakes 5 seconds after each candle closes.
- The bot remembers the last candle it handled. After a restart or an early wake-up, it does nothing until a new candle appears.
- The state file is written to a temporary file and then renamed, so a crash mid-write never leaves half a file.
- Network errors (timeouts, rate limits, an exchange under load) are logged and retried next round. Anything else, such as an order rejected for a bad quantity or a wrong key, stops the bot: those don't fix themselves, and a bot that keeps retrying a broken order is how accounts get hurt.
The complete bot
import json
import os
import time
from pathlib import Path
import ccxt
SYMBOL = "BTC/USDT"
TIMEFRAME = "1h"
FAST, SLOW = 20, 50 # EMA lengths
ATR_LEN, ATR_MULT = 14, 2.0 # stop = entry - 2 x ATR(14)
RISK_PCT = 1.0 # % of the USDT balance lost if the stop is hit
FEE = 0.001 # 0.10% per side, used by the backtest
MODE = os.environ.get("BOT_MODE", "paper") # "paper" (no keys) or "testnet"
STATE = Path("bot_state.json")
# ---------- indicators: the same code runs in the backtest and live ----------
def ema(values, n):
"""EMA seeded with the SMA of the first n values, like TradingView's ta.ema."""
out = [None] * len(values)
if len(values) < n:
return out
e = sum(values[:n]) / n
out[n - 1] = e
k = 2 / (n + 1)
for i in range(n, len(values)):
e += k * (values[i] - e)
out[i] = e
return out
def atr(high, low, close, n):
"""Wilder's ATR, like TradingView's ta.atr."""
tr = [high[0] - low[0]] + [
max(high[i] - low[i], abs(high[i] - close[i - 1]), abs(low[i] - close[i - 1])) for i in range(1, len(close))
]
out = [None] * len(close)
if len(tr) < n:
return out
a = sum(tr[:n]) / n
out[n - 1] = a
for i in range(n, len(tr)):
a = (a * (n - 1) + tr[i]) / n
out[i] = a
return out
def signals(candles):
"""candles: closed [time, open, high, low, close, volume] rows, oldest first."""
high = [c[2] for c in candles]
low = [c[3] for c in candles]
close = [c[4] for c in candles]
fast, slow, vol = ema(close, FAST), ema(close, SLOW), atr(high, low, close, ATR_LEN)
rows = []
for c, f, s, a in zip(candles, fast, slow, vol):
up = f is not None and s is not None and f > s
rows.append({"time": c[0], "open": c[1], "close": c[4], "up": up, "atr": a})
for prev, row in zip(rows, rows[1:]):
row["cross_up"] = row["up"] and not prev["up"]
rows[0]["cross_up"] = False
return rows
# ---------- backtest: decide on a closed candle, fill at the next open ----------
def backtest(candles, cash=10_000.0):
rows = signals(candles)
coin, stop, trades, wins, entry_cost = 0.0, None, 0, 0, 0.0
for bar, nxt in zip(rows, rows[1:]):
price = nxt["open"]
if coin and (bar["close"] <= stop or not bar["up"]):
proceeds = coin * price * (1 - FEE)
wins += proceeds > entry_cost
cash, coin = cash + proceeds, 0.0
elif not coin and bar["cross_up"] and bar["atr"]:
stop = bar["close"] - ATR_MULT * bar["atr"]
qty = min(cash * RISK_PCT / 100 / (bar["close"] - stop), cash / (price * (1 + FEE)))
entry_cost = qty * price * (1 + FEE)
cash, coin, trades = cash - entry_cost, qty, trades + 1
equity = cash + coin * rows[-1]["close"]
return {"trades": trades, "wins": wins, "final_equity": round(equity, 2)}
# ---------- live loop ----------
def make_exchange():
if MODE == "paper":
return ccxt.binance({"enableRateLimit": True}) # public market data only, no keys
ex = ccxt.binance({
"apiKey": os.environ["BINANCE_TESTNET_KEY"],
"secret": os.environ["BINANCE_TESTNET_SECRET"],
"enableRateLimit": True,
})
ex.set_sandbox_mode(True) # Binance spot testnet
return ex
def load_state():
if STATE.exists():
return json.loads(STATE.read_text())
return {"last_candle": None, "qty": 0.0, "stop": None, "paper_usdt": 10_000.0}
def save_state(state):
tmp = STATE.with_suffix(".tmp")
tmp.write_text(json.dumps(state))
tmp.replace(STATE) # atomic: a crash never leaves half a file
def log(msg):
print(time.strftime("%Y-%m-%d %H:%M:%S"), msg, flush=True)
def usdt_balance(ex, state):
quote = ex.market(SYMBOL)["quote"]
return state["paper_usdt"] if MODE == "paper" else ex.fetch_balance()["free"].get(quote, 0.0)
def buy(ex, state, bar):
stop = bar["close"] - ATR_MULT * bar["atr"]
cash = usdt_balance(ex, state)
qty = min(cash * RISK_PCT / 100 / (bar["close"] - stop), cash * 0.99 / bar["close"])
limits = ex.market(SYMBOL)["limits"]
try:
qty = float(ex.amount_to_precision(SYMBOL, qty))
except ccxt.InvalidOrder:
qty = 0.0
if qty <= (limits["amount"]["min"] or 0) or qty * bar["close"] < (limits["cost"]["min"] or 0):
log(f"size {qty} is below the exchange minimum: no trade")
return
if MODE == "paper":
price = bar["close"]
state["paper_usdt"] -= qty * price * (1 + FEE)
else:
order = ex.create_order(SYMBOL, "market", "buy", qty)
price = order.get("average") or bar["close"]
base = ex.market(SYMBOL)["base"]
fee = sum(f.get("cost") or 0 for f in order.get("fees") or [] if f.get("currency") == base)
qty = (order.get("filled") or qty) - fee # the buy fee is often taken in the coin you receive
state.update(qty=qty, stop=stop)
log(f"BUY {qty} at {price:.2f}, stop {stop:.2f}")
def sell(ex, state, bar, reason):
qty = state["qty"]
if MODE == "paper":
price = bar["close"]
state["paper_usdt"] += qty * price * (1 - FEE)
else:
order = ex.create_order(SYMBOL, "market", "sell", float(ex.amount_to_precision(SYMBOL, qty)))
price = order.get("average") or bar["close"]
state.update(qty=0.0, stop=None)
log(f"SELL {qty} at {price:.2f} ({reason})")
def step(ex, state):
tf_ms = ex.parse_timeframe(TIMEFRAME) * 1000
candles = ex.fetch_ohlcv(SYMBOL, TIMEFRAME, limit=SLOW * 4)
candles = [c for c in candles if c[0] + tf_ms <= ex.milliseconds()] # drop the candle still forming
bar = signals(candles)[-1]
if bar["time"] == state["last_candle"]:
return # this candle was already handled (restart, or an early wake-up)
state["last_candle"] = bar["time"]
if state["qty"] and (bar["close"] <= state["stop"] or not bar["up"]):
sell(ex, state, bar, "stop" if bar["close"] <= state["stop"] else "trend down")
elif not state["qty"] and bar["cross_up"] and bar["atr"]:
buy(ex, state, bar)
save_state(state)
def run():
ex = make_exchange()
ex.load_markets()
state = load_state()
tf = ex.parse_timeframe(TIMEFRAME)
log(f"{MODE} mode, {SYMBOL} {TIMEFRAME}, position {state['qty']}")
while True:
try:
step(ex, state)
except ccxt.NetworkError as e: # timeout, rate limit, exchange busy: retry next round
log(f"network error, will retry: {e}")
# wake up 5 seconds after the next candle closes
time.sleep(tf - time.time() % tf + 5)
if __name__ == "__main__":
run()
Run it:
BOT_MODE=paper python bot.py
To run the backtest, fetch a year of candles with fetch_ohlcv() in a loop (advance since past the last candle of each batch) and pass the list to backtest().
Before real money
- Restrict the API key. Enable spot trading only, never withdrawals, and limit the key to your server's IP address if the exchange allows it.
- Put a real stop on the exchange. This bot checks its stop on candle closes, so a crash inside the hour isn't caught until the hour ends. For live use, also place a stop-loss order on the exchange, so the stop holds even if the bot is offline.
- Run it on a server, not a laptop that sleeps, and keep the clock synchronized.
- Start small, log every decision, and check the log against the exchange's trade history for the first weeks.
- Add a kill switch: a daily loss limit or a file the bot checks before every order.
Next steps
- Turn a TradingView idea into the same kind of code: converting Pine Script to Python.
- Add holding costs if you move to perpetual futures: what funding cost on BTC, ETH and SOL.
- Size leveraged positions safely: crypto futures position sizing and liquidation price.
Build your bot with Edgoro
Describe the strategy in plain English and the crypto bot builder writes a Python bot for Binance, Bybit or OKX with this structure: closed candles only, risk-based sizing, exchange limits, state saved between restarts. Test the rules first in the Backtest Lab.
This article is for education. The example strategy lost money in its backtest and isn't a recommendation. Automated trading can lose money quickly, including through software errors. Test on a testnet or paper account first and never give a bot withdrawal permissions.
Frequently asked questions
Is ccxt good for building a crypto trading bot?
Yes. ccxt is an open-source library with a single interface for over a hundred exchanges: market data, balances and orders. It handles authentication and rate limits, which leaves you the strategy, sizing and error handling.
How do I test a trading bot without real money?
Run it in a paper mode on real market data with simulated fills, then on the exchange's testnet. On Binance, ccxt's set_sandbox_mode(True) sends orders to the spot testnet with separate testnet keys.
Why should a bot ignore the last candle from fetch_ohlcv?
The last row is the candle still forming, so its close keeps changing until the period ends. A bot that decides on it sees signals appear and disappear, like a repainting indicator. Drop it and decide on closed candles only.
Why does my bot fail to sell everything it bought?
On Binance spot the buy fee is usually charged in the coin you receive, so you hold a little less than you ordered. Record the filled quantity minus the fee, and round sell orders with amount_to_precision().
What permissions should a trading bot API key have?
Trading only. Never enable withdrawals for a bot, and restrict the key to your server's IP address when the exchange supports it.