CRYPCryptoTutorial NO. ---
How to Backtest a Crypto Trading Strategy in Python with CCXT
A step-by-step Python guide to fetching Bitcoin candles with CCXT, coding a moving-average crossover in pandas without look-ahead bias, charging fees and slippage, and measuring return, drawdown and win rate, plus why a backtest is not a forecast.
Key takeaways
- To backtest a crypto trading strategy in Python, you need three pieces: historical candles (OHLCV data), a rule that turns those candles into positions, and an accounting step that subtracts fees and slippage before you measure results.
- The open-source, MIT-licensed CCXT library downloads Bitcoin and other candles from more than 100 exchanges through one function,
fetch_ohlcv; pandas handles the moving averages and the bookkeeping. - The most common coding error is look-ahead bias: letting a rule trade on information it could not have had yet. A single
shift(1)and dropping the still-open candle prevent the classic version of it. - Trying many parameter combinations and keeping the best one produces "backtest overfitting", which researchers have shown can make a worthless strategy look excellent on past data.
- A backtest describes how a fixed rule would have behaved in the past. It is not a forecast, and this guide gives no trading signals or recommended settings.
To backtest a crypto trading strategy in Python, you need historical candles, a rule that turns them into positions, and an accounting step for fees and slippage. This tutorial fetches Bitcoin daily candles with CCXT and builds a moving-average crossover in pandas.
If you are new to crypto data in general, our beginner's guide to reading on-chain data covers the blockchain side; this article stays with exchange price data.
What is a backtest, and what can it tell you?
A backtest is a historical simulation: you write down a trading rule, apply it to past prices as if you had followed it in real time, and record the hypothetical results. Done carefully, it answers narrow questions. Did the rule trade often enough for costs to matter? How deep were its losing stretches? Did it behave very differently in different years?
It cannot tell you what will happen next. Market conditions change, liquidity changes, and a rule tuned to one period can fail in the next. The US Commodity Futures Trading Commission has warned in a customer advisory on trading bots that no technology can reliably predict future market moves, and that automated-trading pitches promising returns that are high or "guaranteed" are a red flag. Treat everything below as an exercise in measurement, not a search for a money machine.
Step 1: Set up Python, CCXT and pandas
You need Python 3 and two packages. CCXT's GitHub repository lists support for Python 3 alongside JavaScript, PHP and other languages, under the MIT license. Install both packages in a virtual environment:
python3 -m venv venv
source venv/bin/activate # on Windows: venv\Scripts\activate
pip install ccxt pandasYou do not need an exchange account or API key. Historical candles are public market data, and this guide never places an order.
Step 2: Understand the OHLCV data format
Every candle that CCXT returns is a flat list of six values. According to the CCXT manual, the fields and their order are as follows.
| Position | Field | Type and meaning (per CCXT manual) |
|---|---|---|
| 0 | timestamp | UTC time in milliseconds, integer; the start of the candle |
| 1 | open | First traded price in the period, float |
| 2 | high | Highest price in the period, float |
| 3 | low | Lowest price in the period, float |
| 4 | close | Last price in the period, float |
| 5 | volume | Traded volume, usually in the base currency (BTC for BTC/USDT) |
Source: CCXT Manual, "OHLCV Candlestick Charts" section.
The manual adds three points that matter for backtesting. Candles come back in ascending order, oldest first. Exchanges limit how far back you can request data, so long histories need several requests. And the last (current) candle may be incomplete until it closes, which means its close price can still change.
Step 3: Fetch historical Bitcoin candles with CCXT
The function signature, per the manual, is fetch_ohlcv(symbol, timeframe='1m', since=None, limit=None, params={}). Because one request returns a limited batch, the loop below keeps asking for the next batch, starting one millisecond after the last candle it received, until the exchange returns nothing.
import ccxt
import pandas as pd
exchange = ccxt.binance() # public data only, no API key
symbol, timeframe = "BTC/USDT", "1d"
since = exchange.parse8601("2021-01-01T00:00:00Z")
rows = []
while True:
batch = exchange.fetch_ohlcv(symbol, timeframe, since=since, limit=1000)
if not batch or batch[-1][0] < since: # nothing new: stop
break
rows += batch
since = batch[-1][0] + 1 # continue after the last candle
df = pd.DataFrame(rows, columns=["timestamp", "open", "high", "low", "close", "volume"])
df = df.drop_duplicates("timestamp").sort_values("timestamp")
# Drop the candle that is still open: its close price is not final yet
tf_ms = exchange.parse_timeframe(timeframe) * 1000
df = df[df["timestamp"] + tf_ms <= exchange.milliseconds()]
df["date"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
df = df.set_index("date")
print(df.tail())A few notes on this step:
- Exchange choice. Some exchanges are not reachable from every country. If one fails, try another CCXT exchange class (for example
ccxt.kraken()orccxt.coinbase()) and check its symbol format withexchange.load_markets(). History depth and maximum batch size differ by exchange, and the manual notes that not every exchange honourslimit. - Rate limits. CCXT's built-in rate limiter is enabled by default, according to the manual, so the loop above pauses automatically between requests.
- Save a copy. Write the data to disk with
df.to_csv("btc_1d.csv")so later experiments use exactly the same dataset.
Step 4: Build a moving-average crossover rule without look-ahead bias
The example rule is deliberately simple: hold Bitcoin when a short moving average of the closing price is above a long moving average, and hold cash otherwise. It is a teaching example, not a recommendation, and the window lengths below are arbitrary placeholders.
FAST, SLOW = 20, 50 # placeholder values for illustration only
df["fast"] = df["close"].rolling(FAST).mean()
df["slow"] = df["close"].rolling(SLOW).mean()
# 1 = hold BTC, 0 = hold cash, decided at each candle's close
df["signal"] = (df["fast"] > df["slow"]).astype(int)
# Trade on the NEXT candle: today's decision can only use today's close
df["position"] = df["signal"].shift(1).fillna(0)Two details protect this code from look-ahead bias:
rolling()uses trailing windows by default. The pandas documentation states thatcenter=Falseis the default, so each average is labelled at the right edge of its window and only uses past and current prices. Settingcenter=Truewould quietly mix future prices into the signal. With an integer window,min_periodsdefaults to the window size, so the first 49 rows of the slow average are empty instead of being computed on too little data.shift(1)delays the position by one candle. The signal is known only after a candle closes, so the earliest you can act on it is the next candle. Without the shift, the backtest would earn the return of the very candle that produced the signal, which is impossible in real trading. The pandasshiftreference documents theperiodsargument used here.
Because crypto trades around the clock, a daily close and the next day's open are usually very close, so this model assumes you trade at roughly the closing price of the signal candle. Slippage, added in the next step, covers part of the gap.
Step 5: Charge trading fees and slippage
Costs are where many hobby backtests go wrong. Each time the position changes from cash to Bitcoin or back, you pay an exchange fee, and you usually get a slightly worse price than the last quote (slippage).
CCXT exposes each market's default fee rates. According to the CCXT manual's market structure section, taker and maker are decimal rates, where 0.002 means 0.2%, and the manual notes that fetchTradingFees may be more accurate for your account. Your real rate depends on your exchange, your volume tier and any discounts, so check the exchange's own fee schedule.
exchange.load_markets()
FEE = exchange.markets[symbol]["taker"] # default taker rate, e.g. 0.001 = 0.1%
SLIPPAGE = 0.0005 # your own assumption: 0.05% per trade
df["ret"] = df["close"].pct_change().fillna(0)
df["trades"] = df["position"].diff().abs().fillna(0) # 1 on each entry or exit
df["strategy_ret"] = df["position"] * df["ret"] - df["trades"] * (FEE + SLIPPAGE)Subtracting the cost from the period's return is a close approximation for small rates. Set SLIPPAGE higher for thinly traded coins or large orders, and rerun the test at two or three cost levels. If a result disappears when costs rise slightly, it was never robust.
Step 6: Measure return, drawdown and win rate
Three numbers give a first picture. Compare each with simply holding Bitcoin over the same dates, because a rule that underperforms doing nothing has not added much.
equity = (1 + df["strategy_ret"]).cumprod()
buy_hold = (1 + df["ret"]).cumprod()
total_return = equity.iloc[-1] - 1
bh_return = buy_hold.iloc[-1] - 1
drawdown = equity / equity.cummax() - 1
max_drawdown = drawdown.min()
# Group candles into trades: from each entry up to and including its exit candle
entry = df["position"].diff().eq(1)
trade_id = entry.cumsum()
in_trade = df["position"].eq(1) | df["position"].shift(1).eq(1)
trade_rets = (1 + df.loc[in_trade, "strategy_ret"]).groupby(trade_id[in_trade]).prod() - 1
win_rate = (trade_rets > 0).mean()
exposure = df["position"].mean()
print(f"Strategy return: {total_return:.1%}")
print(f"Buy and hold: {bh_return:.1%}")
print(f"Max drawdown: {max_drawdown:.1%}")
print(f"Trades: {len(trade_rets)}, win rate: {win_rate:.1%}")
print(f"Time in market: {exposure:.1%}")Your numbers will depend on the exchange, the date range and the day you run the script, which is exactly why this article does not quote any. The last trade may still be open at the end of the data, so its result is not final.
| Metric | What it measures | What it does not tell you |
|---|---|---|
| Total return | Growth of 1 unit of capital over the whole test, after costs | How bumpy the path was, or whether one lucky period produced most of it |
| Maximum drawdown | Largest fall from a previous equity peak | How long recovery took, or whether a deeper fall is possible later |
| Win rate | Share of completed trades that ended positive | Size of wins versus losses; a 30% win rate can be profitable and a 70% one can lose money |
| Time in market | Share of candles with a Bitcoin position | Risk per trade or the price at which you entered |
Definitions follow the calculations in the code above.
Step 7: Avoid overfitting with an out-of-sample test
Once the code works, the temptation is to loop over dozens of window pairs and keep the best one. That is the trap. In a 2014 paper in the Notices of the American Mathematical Society, David H. Bailey, Jonathan M. Borwein, Marcos López de Prado and Qiji Jim Zhu showed that strong backtest performance is easy to reach after trying only a relatively small number of strategy configurations, and that the risk of overfitting grows with every configuration tested. They also pointed out that the number of trials is rarely reported, so readers cannot judge how overfit a result is.
Practical habits that reduce the problem:
- Split the data before you start. Develop the rule on an earlier period (for example, the first two-thirds of your candles) and keep the rest untouched. Run the final version on the held-out period once.
- Count your experiments. Keep a log of every parameter set you tried. If you tried fifty, the best of fifty is mostly telling you about luck.
- Prefer stable regions over peaks. If 20/50 works but 19/50 and 21/50 fail, the result is fragile.
- Test across regimes. Look at results year by year, including both sharp rallies and long declines.
- Stress the costs. Double the fee and slippage assumptions and see what survives.
Common backtesting mistakes and how to check for them
| Mistake | What goes wrong | Check in the code |
|---|---|---|
| Look-ahead bias | The rule uses a price it could not have known yet | Positions are shift(1) of signals; rolling keeps center=False |
| Using the open candle | The last candle's close can still change | Rows newer than one full timeframe are dropped |
| Ignoring costs | Frequent trading looks free | Fee and slippage charged on every position change |
| Overfitting | Parameters fit noise in one dataset | Held-out test period, logged trials |
| Survivorship bias | Only coins that still trade are tested | Be aware delisted tokens are missing from exchange data |
| Data gaps | Exchange outages or missing candles distort returns | Check that timestamps are evenly spaced with df.index.to_series().diff().value_counts() |
For other Python-based builds on Bullquill, see our guide to building an AI agent with tool calling in Python. For how Bitcoin markets have actually behaved recently, our report on Bitcoin ETF flows and our explainer on Bitcoin downside-protection ETFs give market context that no backtest can capture.
What it means
Writing a backtest is one of the best ways to understand how trading rules interact with costs, volatility and timing, and CCXT plus pandas make it possible in under a hundred lines of Python. The same skills also make it easier to see through marketing that shows a spectacular historical equity curve: ask how many variations were tried, whether costs were included, whether the signal could really have been acted on in time, and how the rule did on data it was not tuned on.
What a backtest cannot do is remove risk. Crypto prices can move sharply in ways no historical sample contains, exchanges can halt or fail, and a rule that worked for years can stop working. The CFTC's advisory is a useful reminder that automation does not change those facts. Use backtesting to learn and to question claims, not as evidence about future returns.
Frequently asked questions
Do I need an exchange account or API key to backtest with CCXT?
No. Historical OHLCV candles are public market data, so CCXT can download them without an account or API key. Keys are only needed for private actions such as checking balances or placing orders, which a backtest does not do.
What is look-ahead bias in a backtest?
Look-ahead bias happens when a simulated trade uses information that was not available at the time, such as the closing price of the candle that generated the signal. In pandas, shifting positions by one candle with shift(1), keeping rolling windows trailing (center=False) and dropping the still-open candle prevent the most common forms.
Does a profitable backtest mean a strategy will make money?
No. A backtest only shows how a fixed rule would have behaved on past data under your cost assumptions. Research by Bailey, Borwein, López de Prado and Zhu shows that trying many configurations makes strong but meaningless backtests easy to find, and future market conditions can differ from the test period.
How do I include trading fees in a Python backtest?
Subtract the fee rate plus an assumed slippage rate from the strategy return on every candle where the position changes. CCXT exposes default maker and taker rates in exchange.markets[symbol], but your actual rate depends on your exchange account, so check its fee schedule.
Sources
- CCXT Manual: OHLCV Candlestick Charts (fetchOHLCV signature, structure, limits)
- CCXT Manual on GitHub: market structure fee fields and rate limiting
- CCXT GitHub repository (license, supported languages and exchanges)
- pandas documentation: DataFrame.rolling
- pandas documentation: DataFrame.shift
- Bailey, Borwein, López de Prado and Zhu, "Pseudo-Mathematics and Financial Charlatanism: The Effects of Backtest Overfitting on Out-of-Sample Performance", Notices of the AMS, May 2014
- CFTC Customer Advisory: AI Won't Turn Trading Bots into Money Machines
This article is for information only and is not investment advice.
Market context · Chart
TradingViewLOADING · TRADINGVIEW
Data delayed per exchange · not investment advice