Effective algorithmic trading relies on thorough backtesting. While a single backtest over a long period can provide an overall picture, it might hide periods of poor performance or indicate that a strategy is over-optimized for specific historical conditions. Rolling backtests address this by evaluating a strategy’s performance over successive, overlapping (or non-overlapping) time windows, providing a more dynamic and reliable assessment of its consistency and adaptability.
Here we use a Python framework for performing rolling backtests using
backtrader
and yfinance
, exploring the idea of
a “Composite Price-Volume Momentum Strategy”.
We start by importing all necessary libraries for data handling,
plotting, and backtrader
functionalities. The
CompositePVMomentumStrategy
is chosen as our subject for
the rolling backtest.
import backtrader as bt
import yfinance as yf
import pandas as pd
import numpy as np
import dateutil.relativedelta as rd # For easier date arithmetic in rolling windows
import matplotlib.pyplot as plt
import seaborn as sns # For enhanced plotting
# The strategy to be tested in the rolling backtest
from CompositePVMomentumStrategy import CompositePVMomentumStrategy
= CompositePVMomentumStrategy strategy
Explanation:
dateutil.relativedelta as rd
: This is
a powerful library for performing date calculations, particularly useful
for adding or subtracting months, years, etc., making the rolling window
logic cleaner than simple timedelta
.seaborn as sns
: Used for creating
aesthetically pleasing statistical plots, like histograms.from CompositePVMomentumStrategy import CompositePVMomentumStrategy
:
This line imports our custom strategy, assuming it’s defined in a
separate file named CompositePVMomentumStrategy.py
. This
modularity keeps the main backtesting script clean.strategy = CompositePVMomentumStrategy
:
Assigns the imported strategy class to a variable for easy use in the
run_rolling_backtest
function.CompositePVMomentumStrategy
ExplainedThis strategy combines price-based and volume-based momentum signals, along with RSI and trailing stops, to identify entry and exit points.
import backtrader as bt
import yfinance as yf
import pandas as pd
class CompositePVMomentumStrategy(bt.Strategy):
= (
params 'rsi_period', 30),
('momentum_period', 30),
('volume_ma_period', 30),
('price_ma_period', 30),
('trailing_percent', 5.0), # Trailing stop percentage
('momentum_threshold', 0.01), # Price change threshold for momentum
('volume_threshold', 1.2), # Volume ratio threshold
('rsi_oversold', 30),
('rsi_overbought', 70),
(
)
def __init__(self):
# Data references
self.data_close = self.data.close
self.data_volume = self.data.volume
# Price indicators
self.rsi = bt.indicators.RSI(period=self.params.rsi_period)
self.momentum = bt.indicators.Momentum(period=self.params.momentum_period) # Backtrader's Momentum (current price / price N periods ago - 1)
self.price_ma = bt.indicators.SMA(self.data_close, period=self.params.price_ma_period)
# Volume indicators
self.volume_ma = bt.indicators.SMA(self.data_volume, period=self.params.volume_ma_period)
# Ratio of current volume to its moving average
self.volume_ratio = self.data_volume / self.volume_ma
# Custom price momentum calculation (percentage change over momentum_period)
self.price_momentum_calc = (self.data_close - self.data_close(-self.params.momentum_period)) / self.data_close(-self.params.momentum_period)
# Trailing stop variables
self.trailing_stop_long = None
self.trailing_stop_short = None
self.highest_price = None # Tracks highest price since long entry
self.lowest_price = None # Tracks lowest price since short entry
def next(self):
= self.data_close[0]
current_price
# Ensure enough data points for all indicators to be calculated
# The longest period amongst all indicators
= max(self.params.rsi_period,
min_required_len self.params.momentum_period,
self.params.volume_ma_period,
self.params.price_ma_period)
if len(self.data_close) < min_required_len:
return # Not enough data for indicators to be valid
# Update trailing stops for existing positions
if self.position.size > 0: # Long position
# Update highest price seen since entry
if self.highest_price is None or current_price > self.highest_price:
self.highest_price = current_price
# Calculate and update trailing stop
self.trailing_stop_long = self.highest_price * (1 - self.params.trailing_percent / 100)
# Check trailing stop exit
if current_price <= self.trailing_stop_long:
self.close()
self.highest_price = None # Reset for next trade
self.trailing_stop_long = None
elif self.position.size < 0: # Short position
# Update lowest price seen since entry
if self.lowest_price is None or current_price < self.lowest_price:
self.lowest_price = current_price
# Calculate and update trailing stop
self.trailing_stop_short = self.lowest_price * (1 + self.params.trailing_percent / 100)
# Check trailing stop exit
if current_price >= self.trailing_stop_short:
self.close()
self.lowest_price = None # Reset for next trade
self.trailing_stop_short = None
# Entry signals when no position
if self.position.size == 0:
# Composite momentum conditions using current bar values [0]
= current_price > self.price_ma[0]
price_above_ma = current_price < self.price_ma[0]
price_below_ma = self.volume_ratio[0] > self.params.volume_threshold
strong_volume = self.price_momentum_calc[0] > self.params.momentum_threshold
positive_momentum = self.price_momentum_calc[0] < -self.params.momentum_threshold
negative_momentum
# Long entry conditions
= (
long_signal and
price_above_ma and
positive_momentum and
strong_volume self.rsi[0] > self.params.rsi_oversold and # RSI not overbought, not oversold
self.rsi[0] < self.params.rsi_overbought
)
# Short entry conditions
= (
short_signal and
price_below_ma and
negative_momentum and
strong_volume self.rsi[0] < self.params.rsi_overbought and # RSI not overbought, not oversold
self.rsi[0] > self.params.rsi_oversold
)
if long_signal:
self.buy()
self.highest_price = current_price # Set initial high for trailing stop
self.trailing_stop_long = current_price * (1 - self.params.trailing_percent / 100)
elif short_signal:
self.sell()
self.lowest_price = current_price # Set initial low for trailing stop
self.trailing_stop_short = current_price * (1 + self.params.trailing_percent / 100)
Explanation of
CompositePVMomentumStrategy
:
params
: Defines configurable
parameters like periods for RSI, momentum, moving averages, trailing
stop percentage, and thresholds for volume and momentum.__init__(self)
:
close
and volume
data for easier access.self.rsi = bt.indicators.RSI(...)
: Calculates the
Relative Strength Index.self.momentum = bt.indicators.Momentum(...)
: Calculates
backtrader
’s Momentum indicator (close price today
vs. close price N periods ago).self.price_ma = bt.indicators.SMA(...)
: Simple Moving
Average of the close price.self.volume_ma = bt.indicators.SMA(...)
: Simple Moving
Average of volume.self.volume_ratio = self.data.volume / self.volume_ma
:
Calculates the ratio of current volume to its average, indicating strong
or weak volume.self.price_momentum_calc
: A custom
calculation for percentage price change over the
momentum_period
, distinct from backtrader
’s
Momentum
indicator which returns the difference.trailing_stop_long
, trailing_stop_short
,
highest_price
, lowest_price
are initialized to
manage dynamic stop-loss levels.next(self)
:
self.position.size > 0
(long position), it
updates self.highest_price
and calculates
self.trailing_stop_long
based on a percentage drop from
this highest price. If the current_price
falls below
self.trailing_stop_long
, the position is closed.self.position.size < 0
(short position), it
updates self.lowest_price
and calculates
self.trailing_stop_short
based on a percentage rise from
this lowest price. If the current_price
rises above
self.trailing_stop_short
, the position is closed.self.position.size == 0
, the strategy looks for new
entry opportunities.price_above_ma
,
price_below_ma
).strong_volume
).positive_momentum
,
negative_momentum
).self.buy()
order is placed, and initial trailing stop
levels are set. Similarly for self.sell()
with short
conditions.run_rolling_backtest
)This function orchestrates the rolling backtest process, dividing the total backtesting period into smaller, sequential windows.
def run_rolling_backtest(
="SOL-USD",
ticker="2018-01-01",
start="2025-06-24",
end=3, # Length of each rolling window in months
window_months=None
strategy_params
):= strategy_params or {} # Use provided params or an empty dict
strategy_params = []
all_results = pd.to_datetime(start)
start_dt = pd.to_datetime(end)
end_dt = start_dt
current_start
while True:
= current_start + rd.relativedelta(months=window_months)
current_end if current_end > end_dt:
# Adjust final window end to not exceed overall end date
= end_dt
current_end if current_start >= current_end: # Break if the window becomes invalid (e.g., current_start is already past end_dt)
break
print(f"\nROLLING BACKTEST: {current_start.date()} to {current_end.date()}")
# Download data for the current window
# Using auto_adjust=False and droplevel(axis=1, level=1) as per user's saved preference
= yf.download(ticker, start=current_start, end=current_end, auto_adjust=False, progress=False)
data
# Handle multi-level columns from yfinance as per user preference
if isinstance(data.columns, pd.MultiIndex):
= data.columns.droplevel(1)
data.columns # Ensure correct column names after droplevel for backtrader
={'Open': 'open', 'High': 'high', 'Low': 'low', 'Close': 'close', 'Volume': 'volume'}, inplace=True)
data.rename(columns
if data.empty or len(data) < 90: # Minimum data points required for meaningful indicators
print(f"Not enough data for {current_start.date()} to {current_end.date()}. Skipping window.")
+= rd.relativedelta(months=window_months)
current_start continue # Skip to the next window
= bt.feeds.PandasData(dataname=data)
feed = bt.Cerebro()
cerebro **strategy_params) # Pass strategy parameters
cerebro.addstrategy(strategy,
cerebro.adddata(feed)100000)
cerebro.broker.setcash(=0.001)
cerebro.broker.setcommission(commission=95)
cerebro.addsizer(bt.sizers.PercentSizer, percents
= cerebro.broker.getvalue()
start_val
cerebro.run()= cerebro.broker.getvalue()
final_val = (final_val - start_val) / start_val * 100
ret
all_results.append({'start': current_start.date(),
'end': current_end.date(),
'return_pct': ret,
'final_value': final_val,
})
print(f"Return: {ret:.2f}% | Final Value: {final_val:.2f}")
+= rd.relativedelta(months=window_months) # Move to the start of the next window
current_start
return pd.DataFrame(all_results)
Explanation of
run_rolling_backtest
:
ticker
, start
, end
: Define
the asset and overall date range.window_months
: Specifies the duration of each
individual backtesting window.strategy_params
: Allows passing parameters to the
strategy for each run.while True
loop iterates, creating new
current_start
and current_end
dates for each
window.rd.relativedelta
is used to increment dates reliably by
months.current_end
surpasses the overall
end_dt
.yf.download
fetches
data for the current window.
auto_adjust=False
and the
droplevel(1, 1)
for multi-index columns are applied here as
per your saved preference for yfinance
downloads.if data.empty or len(data) < 90
ensures
there’s enough data for meaningful backtesting within the window.backtrader
Setup per Window:
bt.Cerebro()
instance is created for
each window, ensuring that each backtest is independent and
results are not cumulative across windows.strategy
, data feed
,
broker cash
, commission
, and
sizer
are configured for the current window.cerebro.run()
executes the backtest for the current
window.all_results
, which is then
converted into a pandas.DataFrame
.These functions process the results from the rolling backtest to provide statistical summaries and insightful plots.
def report_stats(df):
= df['return_pct']
returns = {
stats 'Mean Return %': np.mean(returns),
'Median Return %': np.median(returns),
'Std Dev %': np.std(returns),
'Min Return %': np.min(returns),
'Max Return %': np.max(returns),
'Sharpe Ratio': np.mean(returns) / np.std(returns) if np.std(returns) > 0 else np.nan
}print("\n=== ROLLING BACKTEST STATISTICS ===")
for k, v in stats.items():
print(f"{k}: {v:.2f}")
return stats
def plot_return_distribution(df):
set(style="whitegrid")
sns.=(10, 5))
plt.figure(figsize'return_pct'], bins=20, kde=True, color='dodgerblue')
sns.histplot(df['return_pct'].mean(), color='black', linestyle='--', label='Mean')
plt.axvline(df['Rolling Backtest Return Distribution')
plt.title('Return %')
plt.xlabel('Frequency')
plt.ylabel(
plt.legend()
plt.tight_layout()
plt.show()
def plot_four_charts(df, rolling_sharpe_window=4):
"""
Generates four plots for rolling backtest analysis:
1. Period Returns
2. Cumulative Returns
3. Rolling Sharpe Ratio
4. Return Distribution
"""
= plt.subplots(2, 2, figsize=(12, 10)) # Adjusted figsize for better layout
fig, ((ax1, ax2), (ax3, ax4))
# Calculate period numbers (0, 1, 2, 3, ...)
= list(range(len(df)))
periods = df['return_pct']
returns
# 1. Period Returns (Top Left)
= ['green' if r >= 0 else 'red' for r in returns]
colors =colors, alpha=0.7)
ax1.bar(periods, returns, color'Period Returns', fontsize=14, fontweight='bold')
ax1.set_title('Period')
ax1.set_xlabel('Return %')
ax1.set_ylabel(=0, color='black', linestyle='-', alpha=0.3)
ax1.axhline(yTrue, alpha=0.3)
ax1.grid(
# 2. Cumulative Returns (Top Right)
= (1 + returns / 100).cumprod() * 100 - 100
cumulative_returns ='o', linewidth=2, markersize=4, color='blue') # Smaller markers
ax2.plot(periods, cumulative_returns, marker'Cumulative Returns', fontsize=14, fontweight='bold')
ax2.set_title('Period')
ax2.set_xlabel('Cumulative Return %')
ax2.set_ylabel(True, alpha=0.3)
ax2.grid(
# 3. Rolling Sharpe Ratio (Bottom Left)
= returns.rolling(window=rolling_sharpe_window).apply(
rolling_sharpe lambda x: x.mean() / x.std() if x.std() > 0 else np.nan, raw=False
# Use raw=False for proper pandas series handling
)
# Only plot where we have valid rolling calculations
= ~rolling_sharpe.isna()
valid_mask = [i for i, valid in enumerate(valid_mask) if valid]
valid_periods = rolling_sharpe[valid_mask]
valid_sharpe
='o', linewidth=2, markersize=4, color='orange') # Smaller markers
ax3.plot(valid_periods, valid_sharpe, marker=0, color='red', linestyle='--', alpha=0.5)
ax3.axhline(yf'Rolling Sharpe Ratio ({rolling_sharpe_window}-period)', fontsize=14, fontweight='bold')
ax3.set_title('Period')
ax3.set_xlabel('Sharpe Ratio')
ax3.set_ylabel(True, alpha=0.3)
ax3.grid(
# 4. Return Distribution (Bottom Right)
= min(15, max(5, len(returns)//2)) # Dynamic bin calculation for histogram
bins =bins, alpha=0.7, color='steelblue', edgecolor='black')
ax4.hist(returns, bins= returns.mean()
mean_return ='red', linestyle='--', linewidth=2,
ax4.axvline(mean_return, color=f'Mean: {mean_return:.2f}%')
label'Return Distribution', fontsize=14, fontweight='bold')
ax4.set_title('Return %')
ax4.set_xlabel('Frequency')
ax4.set_ylabel(
ax4.legend()True, alpha=0.3)
ax4.grid(
# Adjust subplots to give a nice fit
plt.tight_layout() plt.show()
Explanation of Reporting and Visualization:
report_stats(df)
:
plot_return_distribution(df)
:
seaborn
to create a histogram of the
return_pct
values from all rolling windows.plot_four_charts(df, rolling_sharpe_window=4)
:
rolling_sharpe_window
periods. This
helps to see how the risk-adjusted performance changes over time. A
np.nan
is used if standard deviation is zero to prevent
division by zero errors.plot_return_distribution
, but integrated into
the four-chart layout.This if __name__ == '__main__':
block demonstrates how
to run the rolling backtest and display the results.
if __name__ == '__main__':
# Run the rolling backtest
= run_rolling_backtest(ticker="SOL-USD", start="2018-01-01", end="2025-06-24", window_months=6) # Changed to 6-month window for example
df
print("\n=== ROLLING BACKTEST RESULTS ===")
print(df) # Print the DataFrame with results from each window
= report_stats(df) # Report overall statistics
stats # Plot the four charts for visual analysis
plot_four_charts(df)
# Example of a single backtest run with CompositePVMomentumStrategy
# This part shows how the CompositePVMomentumStrategy would be run independently
# run_strategy() # This function is defined below for a single backtest, not part of the rolling framework directly
Explanation:
df = run_rolling_backtest(...)
: Calls
the main rolling backtest function with chosen parameters. Here,
SOL-USD
is used as an example, with a 6-month rolling
window from 2018 to mid-2025.df
containing the results of each window is printed.report_stats(df)
and plot_four_charts(df)
are called to provide numerical
summaries and visualizations.run_strategy()
function (provided in your original
prompt) is for demonstrating a single full-period backtest of
the CompositePVMomentumStrategy
. It’s included here to show
its independent usage but is commented out by default as the focus is on
the rolling backtest.This comprehensive setup provides a robust way to evaluate trading strategies over varying market conditions, moving beyond a single historical snapshot to provide a more reliable measure of performance.