A common method for testing a trading strategy is to run a single backtest over a long period. While useful, this can sometimes mask underlying weaknesses. A strategy might perform exceptionally well during a specific market regime (like a bull run) but fail miserably in others, yet the overall result might still look positive.
To gain a deeper, more honest understanding of a strategy’s performance, we can use a rolling backtest. This method involves testing a strategy with a fixed set of parameters over sequential, non-overlapping windows of time. It helps answer a critical question: “Does this strategy work consistently across different market conditions?”
This article breaks down a Python framework designed to perform
exactly this type of rolling analysis using backtrader
and
yfinance
.
The core of the system is the run_rolling_backtest
function. Instead of running one large backtest, it iterates through
time, running many smaller, independent backtests on consecutive
windows.
def run_rolling_backtest(
="BTC-USD",
ticker="2018-01-01",
start="2025-12-31",
end=3,
window_months=None
strategy_params
):"""
Runs a backtest on sequential, non-overlapping time windows.
"""
= strategy_params or {}
strategy_params = []
all_results = pd.to_datetime(start)
start_dt = pd.to_datetime(end)
end_dt = start_dt
current_start
while True:
# Define the end of the current window
= current_start + rd.relativedelta(months=window_months)
current_end if current_end > end_dt:
break
print(f"\nROLLING BACKTEST: {current_start.date()} to {current_end.date()}")
# Download data for the current window
= yf.download(ticker, start=current_start, end=current_end, progress=False)
data if data.empty or len(data) < 90: # Ensure sufficient data
print("Not enough data.")
+= rd.relativedelta(months=window_months)
current_start continue
if isinstance(data.columns, pd.MultiIndex):
= data.droplevel(1, 1)
data
# Set up and run a standard backtrader backtest for the window
= bt.feeds.PandasData(dataname=data)
feed = bt.Cerebro()
cerebro **strategy_params)
cerebro.addstrategy(AdaptiveKalmanFilterStrategy,
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
# Store the result of this window's backtest
all_results.append({'start': current_start.date(),
'end': current_end.date(),
'return_pct': ret,
})
print(f"Return: {ret:.2f}% | Final Value: {final_val:.2f}")
# Move the window forward
+= rd.relativedelta(months=window_months)
current_start
return pd.DataFrame(all_results)
The logic is straightforward but powerful:
while
loop
moves a time window (e.g., 3 months) from the specified
start
date to the end
date.backtrader
backtest on that isolated data using a
given strategy (in this case, AdaptiveKalmanFilterStrategy
)
with a fixed set of parameters.The final output is a pandas DataFrame where each row represents the performance of the strategy during a specific period, providing a clear history of its consistency.
A list of returns is just data. We need tools to transform it into insights. The framework provides two helper functions for this purpose.
def report_stats(df):
"""
Calculates and prints key performance statistics from the rolling results.
"""
= 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):
"""
Creates a histogram to visualize the distribution of returns.
"""
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()
These functions are critical for interpretation:
report_stats
: This function calculates
vital statistics across all the periods.
plot_return_distribution
: This
function creates a histogram, which is a powerful visual tool. It allows
you to quickly see:
The if __name__ == '__main__':
block demonstrates how to
use the framework. A user can specify their ticker, date range, window
size, and the parameters for the strategy they wish to test.
if __name__ == '__main__':
# Run the rolling backtest with default settings
# (3-month windows for BTC-USD from 2018 to present)
= run_rolling_backtest()
df
# Print the results table
print("\n=== ROLLING BACKTEST RESULTS ===")
print(df)
# Calculate and print summary statistics
= report_stats(df)
stats
# Visualize the return distribution
plot_return_distribution(df)
Bakctrader strategy class for the adaptive Kalman Filter that we have seen before:
class AdaptiveKalmanFilterStrategy(bt.Strategy):
# declare plot‐lines and subplots
= (
lines 'kf_price',
'kf_velocity',
'adaptive_R',
'adaptive_Q0',
'adaptive_Q1',
)= dict(
plotlines = dict(_name='KF Price', subplot=False),
kf_price = dict(_name='KF Velocity', subplot=True),
kf_velocity = dict(_name='R', subplot=True),
adaptive_R = dict(_name='Q[0,0]', subplot=True),
adaptive_Q0 = dict(_name='Q[1,1]', subplot=True),
adaptive_Q1
)
= dict(
params = 20,
vol_period = 1e-4,
delta = 0.1,
R_base = 1.0,
R_scale = 0.5,
Q_scale_factor = 1.0,
initial_cov = False,
printlog
)
def log(self, txt, dt=None, doprint=False):
if self.params.printlog or doprint:
= dt or self.datas[0].datetime.date(0)
dt print(f'{dt.isoformat()} {txt}')
def __init__(self):
# data
self.data_close = self.datas[0].close
# ——— Kalman state & matrices ———
self.x = np.zeros(2) # [level, velocity]
self.P = np.eye(2) * self.params.initial_cov
self.F = np.array([[1., 1.],
0., 1.]])
[self.H = np.array([[1., 0.]])
self.I = np.eye(2)
self.initialized = False
# Initialize Q and R so they'll exist before first next()
self.Q = np.eye(2) * self.params.delta
self.R = self.params.R_base
# ——— Indicators ———
# 1-bar log returns
self.log_returns = LogReturns(self.data_close, period=1)
# rolling volatility
self.volatility = bt.indicators.StandardDeviation(
self.log_returns.logret,
=self.params.vol_period
period
)
def _initialize_kalman(self, price):
self.x[:] = [price, 0.0]
self.P = np.eye(2) * self.params.initial_cov
self.initialized = True
self.log(f'KF initialized at price={price:.2f}', doprint=True)
def next(self):
= self.data_close[0]
price
# —— wait for enough bars to init KF & vol ——
if not self.initialized:
if len(self) > self.params.vol_period and not np.isnan(self.volatility[0]):
self._initialize_kalman(price)
return
= self.volatility[0]
vol # if vol or price is NaN, push NaNs to keep plot aligned
if np.isnan(vol) or np.isnan(price):
for ln in self.lines:
getattr(self.lines, ln)[0] = np.nan
return
# ——— Predict ———
self.x = self.F.dot(self.x)
self.P = self.F.dot(self.P).dot(self.F.T) + self.Q
# ——— Adapt Q & R ———
= max(vol, 1e-8)
vol self.R = self.params.R_base * (1 + self.params.R_scale * vol)
= self.params.delta * (1 + self.params.Q_scale_factor * vol**2)
qvar self.Q = np.diag([qvar, qvar])
# ——— Update ———
= price - (self.H.dot(self.x))[0]
y = (self.H.dot(self.P).dot(self.H.T))[0, 0] + self.R
S = self.P.dot(self.H.T) / S
K self.x = self.x + (K.flatten() * y)
self.P = (self.I - K.dot(self.H)).dot(self.P)
# ——— Record lines ———
self.lines.kf_price[0] = self.x[0]
self.lines.kf_velocity[0] = self.x[1]
self.lines.adaptive_R[0] = self.R
self.lines.adaptive_Q0[0] = self.Q[0, 0]
self.lines.adaptive_Q1[0] = self.Q[1, 1]
# ——— Trading: full long & short ———
= self.x[1]
vel if not self.position:
if vel > 0:
self.log(f'BUY (vel={vel:.4f})')
self.buy()
elif vel < 0:
self.log(f'SELL SHORT (vel={vel:.4f})')
self.sell()
elif self.position.size > 0 and vel < 0:
self.log(f'CLOSE LONG & SELL SHORT (vel={vel:.4f})')
self.close(); self.sell()
elif self.position.size < 0 and vel > 0:
self.log(f'CLOSE SHORT & BUY LONG (vel={vel:.4f})')
self.close(); self.buy()
def stop(self):
self.log(f'Ending Portfolio Value: {self.broker.getvalue():.2f}', doprint=True)
BTC-USD
ETH_USD
SOL-USD
Interpreting the Output:
When analyzing the results, you are looking for signs of a robust strategy:
By testing a strategy across many different market environments, this rolling backtest framework provides a much more rigorous and honest assessment of its potential, helping traders build more durable and reliable automated systems.