Backtest Trading Strategy Python Code: Complete Guide
Learn how to write backtest trading strategy Python code from scratch. Build MA crossover strategies, calculate Sharpe ratio, drawdown, and optimize for live crypto trading.
Learn how to write backtest trading strategy Python code from scratch. Build MA crossover strategies, calculate Sharpe ratio, drawdown, and optimize for live crypto trading.
Every trader has a strategy. The honest question is: does yours actually work, or does it just feel like it should? Backtesting is how you find out — running your rules against historical price data to see how they would have performed before risking a single dollar of real capital. Writing backtest trading strategy Python code turns a hunch into a hypothesis you can actually test, reject, or refine. It is one of the highest-leverage habits you can build as a systematic crypto trader.
What does it mean to backtest a trading strategy? At its core, you are simulating trades on historical data: your code reads price candles chronologically, generates buy and sell signals based on fixed rules, and tracks hypothetical profit and loss as if those trades had been executed in real time. The key word is simulated — the data is historical, the trades are paper, and the results describe past performance, not future guarantees. But a strategy that has never worked on clean historical data has very little reason to work going forward. Backtesting weeds out losers before they cost you real money.
Professional quant firms backtest thousands of strategies before deploying a single one live. For retail crypto traders, the same discipline applies whether you are swing trading BTC/USDT on Binance, scalping altcoin perpetuals on Bybit, or running a spot rotation strategy on OKX. The edge is in the process, not the hunch.
Python dominates quant trading for good reasons: a deep ecosystem of financial libraries, readable syntax, and first-class support from every major exchange API. The ccxt library alone gives you a unified interface to over 100 exchanges including Binance, Bybit, OKX, Bitget, Gate.io, and KuCoin. Before writing strategy logic, install your core dependencies.
pip install pandas numpy matplotlib ccxt
With dependencies installed, the first real task is fetching OHLCV (open, high, low, close, volume) candle data. Most backtests run on daily or hourly timeframes. The example below pulls one year of daily BTC/USDT candles from Binance — no API key required for public market data.
import ccxt
import pandas as pd
import numpy as np
# Fetch 365 daily candles from Binance — no API key needed
exchange = ccxt.binance()
ohlcv = exchange.fetch_ohlcv('BTC/USDT', '1d', limit=365)
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)
print(df.tail())
print('Loaded {} candles'.format(len(df)))
Always fetch historical data from the same exchange you plan to trade on live. Binance and Bybit price feeds differ slightly — mixing sources can introduce price discrepancies that make your backtest results misleading.
The moving average crossover is the canonical starter strategy — not because it prints money, but because it is clean enough to implement correctly and serves as a solid template for more complex logic. The rule: when the short-period SMA crosses above the long-period SMA, go long. When it crosses below, exit. Understanding how to backtest a trading strategy Python implementation means splitting the work into two focused functions — signal generation and the execution loop — and keeping them cleanly separated.
def generate_signals(df, short_window=20, long_window=50):
df = df.copy()
df['sma_short'] = df['close'].rolling(short_window).mean()
df['sma_long'] = df['close'].rolling(long_window).mean()
df['signal'] = 0
df.loc[df['sma_short'] > df['sma_long'], 'signal'] = 1 # long
df.loc[df['sma_short'] < df['sma_long'], 'signal'] = -1 # short/flat
# position_change == 2 means new long entry, -2 means new exit
df['position_change'] = df['signal'].diff()
return df
def run_backtest(df, initial_capital=10000.0):
capital = initial_capital
position = 0.0
trades = []
for i in range(1, len(df)):
row = df.iloc[i]
prev = df.iloc[i - 1]
price = row['close']
# Flat -> long entry
if prev['position_change'] == 2 and position == 0:
position = capital / price
trades.append({'side': 'buy', 'price': price, 'equity': capital})
# Long -> flat exit
elif prev['position_change'] == -2 and position > 0:
capital = position * price
trades.append({'side': 'sell', 'price': price, 'equity': capital})
position = 0.0
# Close any open position at the last bar
if position > 0:
capital = position * df.iloc[-1]['close']
return capital, trades
df = generate_signals(df)
final_capital, trades = run_backtest(df)
print('Final capital: ${:,.2f}'.format(final_capital))
print('Return: {:.2f}%'.format(((final_capital / 10000) - 1) * 100))
print('Total trades: {}'.format(len(trades)))
This is a long-only, no-leverage backtest — the safest starting point. Once the core logic is validated, you can layer in short positions, fees, and slippage. Always model transaction costs. On Binance spot, you pay 0.1% per side. On Bybit or OKX futures, maker/taker fees range from 0.02% to 0.055%. Over 50 trades, ignoring fees can overstate returns by 5–15% depending on strategy frequency.
Total return is the headline number but tells you almost nothing in isolation. A strategy that returned 80% with a -60% drawdown is far more dangerous to trade live than one that returned 40% with a -12% drawdown. The metrics below give you a complete picture of strategy quality. Sharpe ratio, max drawdown, win rate, and profit factor together tell you whether an edge is real or just noise.
def calculate_metrics(df, trades, initial_capital=10000.0):
if len(trades) < 2:
return {'error': 'Not enough trades to compute metrics'}
trade_returns = []
for i in range(0, len(trades) - 1, 2):
if i + 1 < len(trades) and trades[i]['side'] == 'buy':
buy_price = trades[i]['price']
sell_price = trades[i + 1]['price']
trade_returns.append((sell_price - buy_price) / buy_price)
trade_returns = np.array(trade_returns)
final_equity = trades[-1]['equity'] if trades[-1]['side'] == 'sell' else initial_capital
total_return = (final_equity - initial_capital) / initial_capital * 100
# Max drawdown via equity curve
equity_curve = df['close'].pct_change().fillna(0)
cumulative = (1 + equity_curve).cumprod()
rolling_max = cumulative.cummax()
drawdown = (cumulative - rolling_max) / rolling_max
max_drawdown = drawdown.min() * 100
# Win rate
wins = trade_returns[trade_returns > 0]
win_rate = len(wins) / len(trade_returns) * 100 if len(trade_returns) > 0 else 0
# Annualized Sharpe ratio
avg_ret = np.mean(trade_returns)
std_ret = np.std(trade_returns)
sharpe = (avg_ret / std_ret) * np.sqrt(252) if std_ret > 0 else 0
# Profit factor
gross_profit = trade_returns[trade_returns > 0].sum()
gross_loss = abs(trade_returns[trade_returns < 0].sum())
profit_factor = gross_profit / gross_loss if gross_loss > 0 else float('inf')
return {
'total_return_pct': round(total_return, 2),
'max_drawdown_pct': round(max_drawdown, 2),
'win_rate_pct': round(win_rate, 2),
'sharpe_ratio': round(sharpe, 3),
'profit_factor': round(profit_factor, 3),
'total_trades': len(trade_returns)
}
metrics = calculate_metrics(df, trades)
for k, v in metrics.items():
print('{}: {}'.format(k, v))
| Metric | Target Range | Red Flag |
|---|---|---|
| Sharpe Ratio | > 1.0 good, > 2.0 excellent | < 0.5 |
| Max Drawdown | < 20% for spot strategies | > 40% |
| Win Rate | > 45% (depends on R:R ratio) | < 35% |
| Profit Factor | > 1.5 | < 1.0 (losing money overall) |
| Total Trades | > 30 for statistical confidence | < 15 |
A strategy's entry and exit signals are only half the equation. Position sizing determines whether you survive long enough to let the edge play out. The fixed fractional method — risking a consistent percentage of capital per trade — is the most robust approach for systematic crypto trading, whether you are running spot on Binance or using leverage on Bybit perpetuals. Here is the formula in code.
def calculate_position_size(capital, risk_pct, entry_price, stop_loss_price):
"""
Fixed fractional position sizing.
risk_pct: % of capital to risk per trade (e.g. 1.5 means 1.5%)
"""
risk_amount = capital * (risk_pct / 100)
price_risk_per_unit = abs(entry_price - stop_loss_price)
if price_risk_per_unit == 0:
raise ValueError('Stop loss cannot equal entry price')
units = risk_amount / price_risk_per_unit
dollar_value = units * entry_price
return {
'units': round(units, 6),
'dollar_value': round(dollar_value, 2),
'risk_amount': round(risk_amount, 2)
}
# Example: BTC/USDT on Binance, $10k account, 1.5% risk per trade
capital = 10_000
entry_price = 65_000
stop_loss = 63_050 # ~3% below entry
size = calculate_position_size(capital, 1.5, entry_price, stop_loss)
print('Position size: {} BTC'.format(size['units']))
print('Dollar exposure: ${:}'.format(size['dollar_value']))
print('Max loss if stopped out: ${}'.format(size['risk_amount']))
# Output:
# Position size: 0.076923 BTC
# Dollar exposure: $5,000.0
# Max loss if stopped out: $150.0
Integrate this directly into the backtest loop by replacing the naive all-in sizing with the fixed fractional calculation. When modeling exchanges like KuCoin or Coinbase, also account for minimum order size constraints — most exchanges have a minimum notional value (typically $5–$10) below which orders are rejected. If your backtest generates undersized trades that would be rejected live, your results are inflated.
VoiceOfChain delivers real-time crypto trading signals that include pre-calculated entry, target, and stop-loss levels. You can feed those parameters directly into the position sizing formula above to automate your sizing decisions without recalculating from scratch on every trade.
A backtest showing 200% annual returns is almost always the result of one of a handful of implementation errors. Recognizing them is as important as knowing how to backtest a trading strategy Python code correctly in the first place.
Writing backtest trading strategy Python code is one of the highest-leverage skills you can develop as a systematic crypto trader. It forces you to formalize your rules, confront the math honestly, and iterate based on evidence rather than gut feel. The implementation above — data fetching via ccxt, signal generation, backtest loop, performance metrics, and fixed fractional position sizing — is a complete foundation you can extend with your own signals and logic. Pair that framework with real-time entry signals from VoiceOfChain and you have a full-stack systematic workflow: signals validated by historical data, sized by math, executed with discipline. The market does not care about your opinion of a setup. Your backtest results do.