In real-world quantitative trading, managing a portfolio of multiple
assets is far more common than trading a single instrument. A
multi-asset approach is essential for implementing strategies that rely
on diversification, cross-asset signals, or specific capital allocation
rules. To rigorously test such strategies, portfolio-level backtesting
is indispensable. This article outlines how to perform multi-asset
backtesting in Python using the robust backtrader
framework, from data preparation to comprehensive analysis.
Traditional backtests often focus on applying a strategy to one instrument at a time. While this is useful for initial development, it neglects the complexities introduced by managing multiple positions simultaneously. A portfolio-level framework allows you to:
backtrader
provides the infrastructure to simulate this
portfolio behavior, making it a strong choice for multi-asset
quantitative research.
To backtest a multi-asset strategy, you first need to acquire and add
data for each asset to the Cerebro
engine. A practical way
to do this is by using the yfinance
library.
Following your preferred data retrieval method, we will download data
with auto_adjust=False
to preserve the original OHLCV data
and then drop the multi-level index.
import backtrader as bt
import yfinance as yf
import pandas as pd
# Define the symbols and the date range
= ['AAPL', 'MSFT', 'GOOG']
symbols = '2020-01-01'
start_date = '2025-01-01'
end_date
# Download data for all symbols and process it
= {}
data_frames for symbol in symbols:
= yf.download(symbol, start=start_date, end=end_date, auto_adjust=False)
df # The droplevel(axis=1, level=1) command is used to flatten the column index
= df.columns.droplevel(level=1)
df.columns = 'datetime'
df.index.name = df
data_frames[symbol]
# Initialize the Cerebro engine
= bt.Cerebro()
cerebro 100000.0)
cerebro.broker.setcash(
# Add each data feed to the Cerebro engine with a unique name
for symbol, df in data_frames.items():
= bt.feeds.PandasData(dataname=df)
data =symbol) cerebro.adddata(data, name
Each asset is added with a unique name. Naming is crucial as it allows you to reference the data clearly within the strategy logic.
Once multiple data feeds are loaded, the strategy must be able to access and differentiate between them.
class MultiAssetStrategy(bt.Strategy):
def __init__(self):
# Access each data feed by its name
self.aapl_data = self.getdatabyname('AAPL')
self.msft_data = self.getdatabyname('MSFT')
self.goog_data = self.getdatabyname('GOOG')
# Create indicators for each asset
self.aapl_sma = bt.indicators.SMA(self.aapl_data.close, period=20)
self.msft_rsi = bt.indicators.RSI(self.msft_data.close, period=14)
self.goog_ema = bt.indicators.EMA(self.goog_data.close, period=50)
# The self.datas iterable contains all data feeds in the order they were added
# This is useful for dynamic logic (see next section)
print(f"Number of data feeds loaded: {len(self.datas)}")
You can access any asset’s data using its name via
self.getdatabyname()
. Alternatively,
self.datas[n]
can be used by index.
Within the next()
method, strategy logic can be applied
individually to each asset. Each order must specify the relevant data
feed to ensure the correct asset is targeted.
def next(self):
# Logic for AAPL
if not self.getposition(self.aapl_data):
if self.aapl_data.close[0] > self.aapl_sma[0]:
self.buy(data=self.aapl_data)
elif self.aapl_data.close[0] < self.aapl_sma[0]:
self.close(data=self.aapl_data)
# Logic for MSFT
if not self.getposition(self.msft_data):
if self.msft_rsi[0] < 30:
self.buy(data=self.msft_data)
elif self.msft_rsi[0] > 70:
self.close(data=self.msft_data)
For strategies that apply the same rules to all assets, looping
through self.datas
is a much cleaner and more scalable
approach.
class DynamicMultiAssetStrategy(bt.Strategy):
= (('sma_period', 20),)
params
def __init__(self):
self.smas = {d._name: bt.indicators.SMA(d.close, period=self.p.sma_period) for d in self.datas}
def next(self):
for data in self.datas:
= self.getposition(data)
position = self.smas[data._name]
sma
if not position:
if data.close[0] > sma[0]:
self.buy(data=data)
else:
if data.close[0] < sma[0]:
self.close(data=data)
This dynamic approach is highly flexible and easy to maintain.
backtrader
’s broker simulates a unified portfolio across
all data feeds. It manages cash, equity values, and open positions for
all assets. All built-in analyzers operate on this consolidated
portfolio.
# Add standard and trade-specific analyzers
='sharpe')
cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='drawdown')
cerebro.addanalyzer(bt.analyzers.DrawDown, _name='trades')
cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name
# Run the backtest and retrieve results
= cerebro.run()
results
# Access the portfolio-level results
= results[0].analyzers.sharpe.get_analysis()['sharperatio']
sharpe_ratio = results[0].analyzers.drawdown.get_analysis()['max']['drawdown']
max_drawdown = results[0].analyzers.trades.get_analysis()
trade_metrics
print(f"Portfolio Sharpe Ratio: {sharpe_ratio:.2f}")
print(f"Portfolio Maximum Drawdown: {max_drawdown:.2f}%")
For more in-depth portfolio analysis, the PyFolio
analyzer can be used to export returns for a comprehensive report.
As per your preference, trailing stops are a critical component of
risk management. backtrader
makes it easy to add them to
your multi-asset strategies. Trailing stops can be configured for each
position upon entry, and the broker will automatically manage them as
the price moves.
class TrailingStopStrategy(bt.Strategy):
# ... (other init logic) ...
def next(self):
# A simple example: buy and add a 5% trailing stop
for data in self.datas:
if not self.getposition(data):
self.buy(data=data, exectype=bt.Order.Market)
# Add a trailing stop to the newly opened position
self.trailing_stop(data=data, trailpercent=0.05)
The trailing_stop()
method handles the creation and
management of the trailing stop order for the specified asset.
The cerebro.plot()
function generates a comprehensive
visual report. It displays subplots for each asset and overlays buy/sell
markers. Portfolio-level metrics, such as cash and equity, are shown on
separate plots.
# To plot the results, showing indicators and trades on each asset
='candlestick', barup='green', bardown='red') cerebro.plot(style
Note that with many assets, the plot can become crowded. In such cases, you might consider plotting a subset of the assets for clarity.
backtrader
aligns all feeds by timestamp. If data for one asset is missing on a
specific date, the next()
method will not execute unless
all feeds are aligned.bt.sizers.PercentSizer
allocates a percentage of the total
portfolio equity to a new trade. This is a crucial distinction for
multi-asset strategies.cerebro.broker.setcommission
.Portfolio-level backtesting is indispensable for realistic strategy
evaluation. By using backtrader
to simulate multi-asset
strategies, you can rigorously test a holistic trading approach. From
properly managing data feeds with yfinance
to implementing
dynamic logic and incorporating risk management tools like trailing
stops, you can gain deeper insights into the benefits of
diversification, the efficacy of capital allocation, and the overall
robustness of your trading models. This approach transitions your
research from single-stock theory to a more practical, real-world
application.