Crypto Bots

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.

A
AI Agents Hubยท2025-04-29ยท5 min readยท994 words

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

  1. Look-ahead bias โ€” Using future data to make past decisions. Always use [-1] not [0] for current values
  2. Survivorship bias โ€” Only testing on coins that survived. Many went to zero.
  3. Ignoring slippage โ€” Real fills are worse than the last price. Add slippage=0.001
  4. 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):

  1. Paper trade for 2โ€“4 weeks
  2. Verify live signals match backtest signals
  3. Start with 10% of intended capital
  4. 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.

Related Articles