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"] and market["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}")...
Source:https://dev.to/walid_koussa_0941941b1fdb/how-to-build-a-python-crypto-trading-bot-with-ccxt-data-signals-orders-and-a-testnet-1eef