Backtesting Crypto Strategy: Python Tests Before You Trade
This guide is for crypto traders who can read a chart and want a practical Python framework to test signals, fees, sizing, and drawdowns before going live.
This guide is for crypto traders who can read a chart and want a practical Python framework to test signals, fees, sizing, and drawdowns before going live.
Backtesting crypto strategy ideas only helps if the test includes the things that actually hit your PnL: fees, slippage, funding, position size, and regime changes. The goal is not to find the prettiest equity curve; it is to decide whether a setup is worth risking real capital on Binance, Bybit, OKX, Coinbase, or any other venue you trade.
A backtest crypto trading strategy should answer one question before anything else: does the edge survive realistic execution? I usually reject a system fast if a 0.06% round-trip cost turns profit factor from 1.4 to below 1.1, because that edge will not survive a busy perp market.
For BTCUSDT perps on Binance or Bybit, I want to see the same logic work across trending weeks, chop, high funding, and liquidation cascades. For spot BTC-USD on Coinbase, funding is irrelevant, but spread, tax lots, and overnight gap behavior still matter.
| Question | Practical threshold |
|---|---|
| Does it work after costs? | Positive expectancy after fees plus 2-5 bps slippage per side |
| Is drawdown survivable? | Max drawdown below 25% for a directional swing system |
| Is sample size real? | At least 100 trades, preferably 300+ for intraday rules |
| Does it survive stress? | Still profitable when costs are increased by 50% |
| Is it exchange-specific? | Retest separately on Binance, Bybit, OKX, Coinbase, Bitget, Gate.io, and KuCoin data |
VoiceOfChain tracks funding-rate pressure, open interest changes, and market structure in real time across Binance, Bybit and OKX - you can see when your backtest regime is showing up live without building the data stack yourself. [voiceofchain.com]
Use the same market you plan to trade. A backtesting bitcoin trading strategy on Coinbase spot data will not tell you how the same rule behaves on OKX BTC-USDT-SWAP, where funding, mark price, and liquidation flows affect entries.
Keep everything in UTC, enter on the next candle after the signal, and store raw exchange candles before resampling. Binance funding normally settles on scheduled intervals, and Bybit funding history is contract-specific, so do not hard-code every perp as if it pays the same way forever.
| Use case | Source |
|---|---|
| Binance USDT-M futures candles | https://developers.binance.com/docs/derivatives/usds-margined-futures/market-data/rest-api/Kline-Candlestick-Data |
| Binance futures funding mechanics | https://www.binance.com/en/support/faq/detail/360033525031 |
| Bybit funding rate history | https://bybit-exchange.github.io/docs/v5/market/history-fund-rate |
| OKX market data and public endpoints | https://www.okx.com/docs-v5/en/ |
| Coinbase spot product candles | https://docs.cdp.coinbase.com/api-reference/exchange-api/rest-api/products/get-product-candles |
| Backtesting.py framework docs | https://kernc.github.io/backtesting.py/ |
Start with a simple rule you can explain in one sentence. This example buys a BTC breakout only when the short EMA is above the slow EMA, then exits when price loses the fast EMA; it is basic, but it forces clean signal timing.
import numpy as np
import pandas as pd
def load_ohlcv(path):
df = pd.read_csv(path, parse_dates=["time"])
df = df.set_index("time").sort_index()
cols = ["open", "high", "low", "close", "volume"]
return df[cols].astype(float)
def add_signals(df):
df = df.copy()
df["ema_fast"] = df["close"].ewm(span=20, adjust=False).mean()
df["ema_slow"] = df["close"].ewm(span=80, adjust=False).mean()
prior_high = df["high"].rolling(50).max().shift(1)
long_entry = (df["ema_fast"] > df["ema_slow"]) & (df["close"] > prior_high)
long_exit = df["close"] < df["ema_fast"]
df["signal"] = np.select([long_entry, long_exit], [1, 0], default=np.nan)
df["position"] = df["signal"].ffill().fillna(0)
df["position_next_bar"] = df["position"].shift(1).fillna(0)
return df.dropna()
# Setup example
btc = load_ohlcv("BTCUSDT_1h_binance.csv")
btc = add_signals(btc)
Position sizing decides whether a decent signal becomes a tradable system or a liquidation story. My default research setting is 0.5%-1.0% account risk per trade, then a hard notional cap so a tight stop does not create accidental 8x exposure.
| Component | Formula |
|---|---|
| Risk dollars | account_equity * risk_pct |
| Unit risk | abs(entry_price - stop_price) |
| Raw units | risk_dollars / unit_risk |
| Max notional units | account_equity * max_notional_pct / entry_price |
| Final position | min(raw_units, max_notional_units) |
def position_units(equity, entry, stop, risk_pct=0.01, max_notional_pct=0.30):
risk_dollars = equity * risk_pct
unit_risk = abs(entry - stop)
if unit_risk <= 0:
return 0
raw_units = risk_dollars / unit_risk
max_units = (equity * max_notional_pct) / entry
return min(raw_units, max_units)
def apply_costs(df, fee_bps=4, slippage_bps=2, funding_col=None):
df = df.copy()
pos = df["position_next_bar"]
turnover = pos.diff().abs().fillna(pos.abs())
df["gross_ret"] = df["close"].pct_change().fillna(0) * pos.shift(1).fillna(0)
trading_cost = turnover * ((fee_bps + slippage_bps) / 10000)
if funding_col:
funding_cost = df[funding_col].fillna(0) * pos.shift(1).fillna(0)
else:
funding_cost = 0
df["net_ret"] = df["gross_ret"] - trading_cost - funding_cost
df["equity"] = (1 + df["net_ret"]).cumprod()
return df
btc = apply_costs(btc, fee_bps=4, slippage_bps=2, funding_col=None)
Funding can be bigger than the trade edge. A positive 0.10% funding rate every 8 hours is roughly 0.30% per day paid by longs, so a slow long-only perp system can look profitable before funding and dead after funding.
The crypto best strategy is not the one with the highest final equity. I rank systems by risk-adjusted return, drawdown behavior, cost sensitivity, and whether the same idea still works when Bitcoin changes regime.
def performance_metrics(df, periods_per_year=365 * 24):
r = df["net_ret"].dropna()
equity = df["equity"].dropna()
drawdown = equity / equity.cummax() - 1
years = len(r) / periods_per_year
cagr = equity.iloc[-1] ** (1 / years) - 1 if years > 0 else np.nan
sharpe = (r.mean() / r.std()) * np.sqrt(periods_per_year) if r.std() else np.nan
max_dd = drawdown.min()
profit_factor = r[r > 0].sum() / abs(r[r < 0].sum()) if (r < 0).any() else np.inf
trades = int(df["position_next_bar"].diff().abs().fillna(0).sum())
return {
"cagr": round(cagr, 4),
"sharpe": round(sharpe, 2),
"max_drawdown": round(max_dd, 4),
"profit_factor": round(profit_factor, 2),
"trades": trades
}
print(performance_metrics(btc))
| Metric | Minimum I care about |
|---|---|
| Profit factor | Above 1.3 after fees and slippage |
| Max drawdown | Below 25% for a swing system, lower for leverage |
| Trade count | 100+ trades before trusting the distribution |
| Cost stress | Still profitable with 50% higher costs |
| Out-of-sample behavior | No collapse on the last 25%-30% of data |
The most common mistake is accidental lookahead: entering at the same candle close that triggered the breakout. The second is pretending every order fills at mid price on a volatile alt, which is how a crypto best strategy turns into a live loss machine.
The honest risk caveat: a backtest can only reject bad ideas; it cannot guarantee a live edge. When exchange rules, fees, tick size, funding intervals, or liquidity change, retest before increasing size.
The key takeaway is simple: backtesting crypto strategy ideas is a risk filter, not a profit forecast. Build the test around execution first - next-bar fills, realistic fees, funding, slippage, and position sizing. If the system still works after cost stress and out-of-sample checks, paper trade it for 2-4 weeks before adding real size. That process will save you from most strategies that look sharp in Python and bleed once they hit the book.