How to Backtest a Crypto Trading Strategy (Complete Python Guide 2025)
Never deploy a trading bot without backtesting first. This guide shows you exactly how to backtest any crypto strategy using Python, backtesting.py, and real historical data โ with code you can run today.
Builder of AI agents, crypto trading bots, and open-source automation tools. Sharing practical guides on how to build, deploy, and profit from AI and DeFi technology.
Why Backtesting Is Non-Negotiable
A strategy that looks brilliant in your head might have lost 70% of its value over the past two years. Backtesting shows you this before you risk real money.
Backtesting tells you:
- What returns the strategy would have generated historically
- Maximum drawdown (worst period โ how much you could have lost)
- Win rate (percentage of profitable trades)
- Sharpe ratio (return vs. risk)
- How it performs in different market conditions
Backtesting does NOT tell you:
- That the strategy will work in the future
- That historical performance repeats
Always backtest. Never trust backtest results blindly.
Setup: Tools You Need
pip install backtesting pandas yfinance ccxt ta-lib
For historical crypto data, we will use ccxt to fetch from exchanges:
import ccxt
import pandas as pd
def get_historical_ohlcv(symbol: str, timeframe: str = '1d', limit: int = 500) -> pd.DataFrame:
"""Fetch OHLCV data from Binance."""
exchange = ccxt.binance()
ohlcv = exchange.fetch_ohlcv(symbol, timeframe, limit=limit)
df = pd.DataFrame(ohlcv, columns=['timestamp', 'Open', 'High', 'Low', 'Close', 'Volume'])
df['timestamp'] = pd.to_datetime(df['timestamp'], unit='ms')
df.set_index('timestamp', inplace=True)
return df
# Get 500 days of BTC/USDT daily candles
btc_data = get_historical_ohlcv('BTC/USDT', '1d', 500)
print(btc_data.tail())
Strategy 1: Simple Moving Average Crossover
from backtesting import Backtest, Strategy
from backtesting.lib import crossover
import pandas as pd
def SMA(prices, period):
return pd.Series(prices).rolling(period).mean()
class SMACrossover(Strategy):
# Strategy parameters (optimizable)
fast_period = 20
slow_period = 50
def init(self):
# Calculate moving averages
self.fast_ma = self.I(SMA, self.data.Close, self.fast_period)
self.slow_ma = self.I(SMA, self.data.Close, self.slow_period)
def next(self):
# When fast MA crosses above slow MA: BUY
if crossover(self.fast_ma, self.slow_ma):
self.buy(size=0.95) # Use 95% of available cash
# When fast MA crosses below slow MA: SELL
elif crossover(self.slow_ma, self.fast_ma):
self.position.close()
# Run the backtest
bt = Backtest(
btc_data,
SMACrossover,
cash=10_000, # Start with $10,000
commission=0.001, # 0.1% commission per trade
exclusive_orders=True
)
results = bt.run()
print(results)
bt.plot()
Example output:
Start 2023-01-01
End 2024-12-31
Duration 730 days
Exposure Time [%] 62.4
Equity Final [$] 18,234
Return [%] 82.3%
Buy & Hold Return [%] 145.2% โ Always compare to this
Max. Drawdown [%] -23.4%
Trades 12
Win Rate [%] 58.3%
Sharpe Ratio 1.42
Strategy 2: RSI Mean Reversion
def RSI(prices, period=14):
delta = pd.Series(prices).diff()
gain = delta.clip(lower=0)
loss = -delta.clip(upper=0)
avg_gain = gain.rolling(period).mean()
avg_loss = loss.rolling(period).mean()
rs = avg_gain / avg_loss
return 100 - (100 / (1 + rs))
class RSIMeanReversion(Strategy):
rsi_period = 14
oversold = 30 # Buy when RSI < 30
overbought = 70 # Sell when RSI > 70
stop_loss_pct = 5 # 5% stop loss
def init(self):
self.rsi = self.I(RSI, self.data.Close, self.rsi_period)
def next(self):
if not self.position:
# Entry: RSI oversold
if self.rsi[-1] < self.oversold:
# Set stop loss 5% below entry
stop_price = self.data.Close[-1] * (1 - self.stop_loss_pct / 100)
self.buy(size=0.95, sl=stop_price)
else:
# Exit: RSI overbought
if self.rsi[-1] > self.overbought:
self.position.close()
bt_rsi = Backtest(btc_data, RSIMeanReversion, cash=10_000, commission=0.001)
results_rsi = bt_rsi.run()
print("\nRSI Strategy Results:")
print(f"Return: {results_rsi['Return [%]']:.1f}%")
print(f"Max Drawdown: {results_rsi['Max. Drawdown [%]']:.1f}%")
print(f"Win Rate: {results_rsi['Win Rate [%]']:.1f}%")
print(f"Sharpe: {results_rsi['Sharpe Ratio']:.2f}")
Strategy 3: Bollinger Bands + Volume Filter
def BollingerBands(prices, period=20, std_dev=2):
series = pd.Series(prices)
ma = series.rolling(period).mean()
std = series.rolling(period).std()
upper = ma + (std * std_dev)
lower = ma - (std * std_dev)
return upper, lower
class BollingerBreakout(Strategy):
period = 20
std_dev = 2
def init(self):
upper, lower = self.I(
lambda prices: BollingerBands(prices, self.period, self.std_dev),
self.data.Close,
overlay=True
)
# Unpack both bands
self.upper = self.I(lambda p: BollingerBands(p, self.period, self.std_dev)[0], self.data.Close)
self.lower = self.I(lambda p: BollingerBands(p, self.period, self.std_dev)[1], self.data.Close)
def next(self):
if not self.position:
# Buy when price touches lower band
if self.data.Close[-1] <= self.lower[-1]:
self.buy(size=0.95)
else:
# Sell when price touches upper band
if self.data.Close[-1] >= self.upper[-1]:
self.position.close()
Optimizing Strategy Parameters
The backtesting.py library has built-in optimization:
# Find the best moving average periods
results_optimized = bt.optimize(
fast_period=range(5, 30, 5), # Test: 5, 10, 15, 20, 25
slow_period=range(30, 100, 10), # Test: 30, 40, 50, 60, 70, 80, 90
maximize='Sharpe Ratio', # Optimize for risk-adjusted return
constraint=lambda p: p.fast_period < p.slow_period # fast < slow always
)
print(f"Best parameters: fast={results_optimized._strategy.fast_period}, slow={results_optimized._strategy.slow_period}")
print(f"Optimized return: {results_optimized['Return [%]']:.1f}%")
Warning: Overfitting If you optimize on the same data you then "test", the results are meaningless. Use a train/test split:
# Split data: 70% train, 30% test
split_idx = int(len(btc_data) * 0.7)
train_data = btc_data.iloc[:split_idx]
test_data = btc_data.iloc[split_idx:]
# Optimize on training data
bt_train = Backtest(train_data, SMACrossover, cash=10_000, commission=0.001)
best_params = bt_train.optimize(fast_period=range(5, 50, 5), slow_period=range(20, 100, 10))
# Validate on test data (out-of-sample)
bt_test = Backtest(test_data, SMACrossover, cash=10_000, commission=0.001)
test_results = bt_test.run(
fast_period=best_params._strategy.fast_period,
slow_period=best_params._strategy.slow_period
)
print("OUT-OF-SAMPLE results:", test_results[['Return [%]', 'Max. Drawdown [%]', 'Sharpe Ratio']])
Reading Backtest Results: What Actually Matters
| Metric | Good | Acceptable | Red Flag | |--------|------|------------|----------| | Return vs Buy&Hold | Beat it | Match it | Lower | | Max Drawdown | under 15% | 15โ30% | above 30% | | Sharpe Ratio | above 1.5 | 0.5โ1.5 | below 0.5 | | Win Rate | above 55% | 45โ55% | below 45%* | | Number of Trades | 20โ200 | 5โ20 | below 5 |
*Low win rate is okay if winners are much larger than losers (high reward:risk ratio)
Common Backtesting Mistakes
- Look-ahead bias โ Using future data to make past decisions. Always use
[-1]not[0]for current values - Survivorship bias โ Only testing on coins that survived. Many went to zero.
- Ignoring slippage โ Real fills are worse than the last price. Add
slippage=0.001 - Over-optimizing โ Strategies that work on historical data often fail forward
From Backtest to Live Bot
Once your strategy shows consistent results (especially on out-of-sample data):
- Paper trade for 2โ4 weeks
- Verify live signals match backtest signals
- Start with 10% of intended capital
- Scale up over 2โ3 months if live results match backtest
Our crypto bots include backtesting scripts for each strategy. Download, test on your preferred asset, then deploy.
Tagged in
Related Articles
Crypto Bot Risk Management: The 10 Rules That Separate Winners From Losers
7 min read
Crypto BotsHow to Build a Self-Healing Trading Bot That Fixes Its Own Errors
5 min read
Crypto BotsPump.fun and Solana Meme Coin Bots: How to Automate the Hottest Trend
5 min read
Crypto BotsHow to Build a Crypto Portfolio Auto-Rebalancing Bot
5 min read