In the volatile world of cryptocurrency, “HODLing” is often not enough. To survive bear markets and capitalize on bull runs without emotional bias, we need a systematic approach.
This article walks through the construction of a robust Python backtesting script using Backtrader. We will build a strategy that combines Multi-Factor Trend Following (to know what to buy) with Risk Parity & Volatility Sizing (to know how much to buy).
The system is built on three pillars:
Data Ingestion: Fetching clean history from
Binance via CCXT.
The Alpha Engine: A composite scoring system using ADX, RSI, and VWAP.
The Risk Engine: Position sizing based on Volatility (ATR) and Risk Parity.
Before we trade, we need data. We use CCXT to fetch data
because it handles the API complexity of hundreds of exchanges.1
Exchanges often limit how many data points (candles) you can get in one request (e.g., Binance limits to 1000 candles).
Our BinanceDataFetcher class loops through time to
stitch together a complete dataset.
Python
def fetch_ohlcv(self, symbol: str, timeframe: str = '1d', days_back: int = 365):
# Calculate start time
start_time = datetime.now() - timedelta(days=days_back)
since = self.exchange.parse8601(start_time.isoformat())
all_ohlcv = []
# Pagination Loop
while True:
ohlcv = self.exchange.fetch_ohlcv(
symbol, timeframe=timeframe, since=since, limit=1000
)
if not ohlcv: break
all_ohlcv.extend(ohlcv)
# Move the 'since' cursor to the next millisecond
since = ohlcv[-1][0] + 1
if len(ohlcv) < 1000: break # We reached the end
Key Takeaway: Always handle pagination when fetching historical data, or your backtest will be missing recent price action.
Instead of relying on a single indicator (which generates many false signals), we use a Weighted Composite Score. We assign points to different market factors.
Trend Strength (ADX): Is the market actually moving?
Direction (+DI/-DI): Is it moving up or down?
Fair Value (VWAP): Are we paying a premium or discount relative to volume?
Momentum (RSI): Is the trend healthy or overextended?
Here is how we calculate the score for every asset, every day:
Python
def calculate_position_score(self, symbol: str) -> float:
ind = self.indicators[symbol]
score = 0
# 1. Trend Strength (40% Weight)
# ADX measures intensity. We scale the score by how strong the ADX is.
if ind['adx'][0] > self.p.adx_threshold:
score += 0.4 * (ind['adx'][0] / 100)
# 2. Direction (30% Weight)
# Using Directional Indicators to determine Bull vs Bear
if ind['plus_di'][0] > ind['minus_di'][0]:
score += 0.3 # Bullish
else:
score -= 0.3 # Bearish
# 3. Price vs VWAP (20% Weight)
# Buying above VWAP signifies strong demand
if self.symbol_data[symbol].close[0] > ind['vwap'][0]:
score += 0.2
else:
score -= 0.2
return score
The Logic:
If the total score > 0, the asset is a
BUY candidate.
If the total score < 0, the asset is a
SELL candidate.
This creates a “Spectrum of Conviction” rather than a binary Yes/No.
This is where amateurs lose money and pros manage risk. We don’t just put “10% in everything.” We use a dual-constraint method.
Getty Images
We want to divide our exposure equally among the assets we are currently trading.
Formula:
Target Weight = 50% / Number of Active Assets
Why? If we have 2 assets, we allocate 25% to each. If we have 5, we allocate 10%. We keep a cash buffer (the remaining 50%) to reduce system volatility.
We calculate the Average True Range (ATR). We limit our position size so that if the price moves 2 ATRs against us, we only lose 2% of our total capital.
Python
def calculate_position_size(self, symbol: str, score: float) -> float:
capital = self.broker.getvalue()
atr = self.indicators[symbol]['atr'][0]
# 1. Calculate Risk Parity Size
active_count = max(len(self.active_positions), 1)
weight = min(0.5 / active_count, 0.2) # Cap at 20% per asset
size_parity = (capital * weight) / data.close[0]
# 2. Calculate Volatility Risk Size
# How much can we buy so that 2*ATR loss = 2% of Account?
risk_dollars = capital * self.p.risk_per_trade
size_volatility = risk_dollars / (atr * 2)
# TAKE THE SMALLER OF THE TWO (Conservative Approach)
return min(size_parity, size_volatility)
The strategy runs inside the next() method, which
Backtrader executes for every new candle.2
We implement “Hard Stops.” If the price moves 10% down, we cut it. If it moves 30% up, we take the profit. This removes the psychological difficulty of selling.
Python
# Inside next() loop
entry = self.entry_prices.get(symbol)
if entry:
# Stop Loss Logic
if data.close[0] < entry * (1 - self.p.stop_loss_pct):
self.close(data=data)
# Take Profit Logic
elif data.close[0] > entry * (1 + self.p.take_profit_pct):
self.close(data=data)
Crypto markets change fast. Every 30 days, we perform a “Health Check.”
Recalculate scores for all assets.
Sell anything with a negative score.
Buy the top assets with positive scores.
To fix the dependency issues with the library empyrical,
we wrote a custom RiskMetrics class. This allows us to
calculate institutional-grade metrics without version conflicts.
The Sharpe Ratio Calculation:
The Sharpe ratio tells us if the returns were worth the stress (volatility).
Python
@staticmethod
def sharpe_ratio(returns, risk_free=0.0, periods=365):
# Calculate excess return over the risk-free rate
excess_returns = returns - (risk_free / periods)
std = excess_returns.std()
if std == 0: return 0.0
# Annualize the result
return np.sqrt(periods) * excess_returns.mean() / std
The backtest covered the period of 2025, a year characterized by significant volatility in the crypto markets. The strategy demonstrated its core value proposition: capital preservation during bear trends.
| Metric | Result | Interpretation |
|---|---|---|
| Total Return | +4.74% | Positive profitability in a year where the benchmark (Grey Line) ended negative. |
| Max Drawdown | -19.71% | The system hit its risk limits late in the year. This falls just within the 20% max portfolio risk parameter we set. |
| Sharpe Ratio | 0.296 | A lower score indicating that for every unit of risk taken, the return was modest. This is typical for trend-following strategies during choppy/sideways years. |
| Final Value | $104,744 | The portfolio grew capital while the buy-and-hold benchmark lost value (ending below $90k). |
The most striking feature of the performance is visible in Q1 2025 (January - March).
The Benchmark (Grey): Suffered a massive crash, dropping from ~$100k to ~$80k.
The Strategy (Red): The equity curve stayed perfectly flat.
Why? The Alpha Engine (ADX + VWAP) correctly identified the negative trend. The system effectively moved to cash (stablecoins), sidestepping the crash entirely. This validates the “Trend Filter” logic.
From April to September, as the market recovered, the strategy re-entered. You can see the Red line aggressively catching up to the Grey line. The Momentum component worked perfectly here, capturing the bulk of the mid-year rally (July alone returned +8.4%).
The strategy struggled in Q4 (October - November).
What happened: The market likely exhibited “fake-outs”—brief rallies that triggered buy signals, followed immediately by reversals.
The Cost: This resulted in successive losses (Oct: -5.2%, Nov: -7.3%), leading to the max drawdown of -19.71%. This highlights the classic weakness of trend-following systems in choppy, range-bound markets.
The strategy successfully performed its primary job: it outperformed the benchmark by not losing money when the market crashed.
While a 4.74% return may seem modest compared to a bull market frenzy, it is infinitely better than the significant loss a “Buy and Hold” investor would have suffered in 2025. The system proved it acts as a volatility shield, effectively “flattening” the downside while still attempting to capture the upside. ## Summary
This script provides a professional framework for crypto backtesting. By decoupling the Signal (Composite Score) from the Sizing (ATR/Risk Parity), we create a system that can capture upside trends while surviving the inevitable crypto crashes.