← Back to Home
Portfolio Backtesting in Python - How to Simulate Multi-Asset Strategies Using Backtrader

Portfolio Backtesting in Python - How to Simulate Multi-Asset Strategies Using Backtrader

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.

Why Portfolio Backtesting Matters

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.

Step 1: Preparing and Adding Multiple Data Feeds

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
symbols = ['AAPL', 'MSFT', 'GOOG']
start_date = '2020-01-01'
end_date = '2025-01-01'

# Download data for all symbols and process it
data_frames = {}
for symbol in symbols:
    df = yf.download(symbol, start=start_date, end=end_date, auto_adjust=False)
    # The droplevel(axis=1, level=1) command is used to flatten the column index
    df.columns = df.columns.droplevel(level=1)
    df.index.name = 'datetime'
    data_frames[symbol] = df

# Initialize the Cerebro engine
cerebro = bt.Cerebro()
cerebro.broker.setcash(100000.0)

# Add each data feed to the Cerebro engine with a unique name
for symbol, df in data_frames.items():
    data = bt.feeds.PandasData(dataname=df)
    cerebro.adddata(data, name=symbol)

Each asset is added with a unique name. Naming is crucial as it allows you to reference the data clearly within the strategy logic.

Step 2: Accessing Data Feeds in the Strategy

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.

Step 3: Writing Logic for Multiple Assets

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.

Individualized Logic

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)

Dynamic Logic (Advanced)

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):
    params = (('sma_period', 20),)

    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:
            position = self.getposition(data)
            sma = self.smas[data._name]
            
            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.

Step 4: Tracking Portfolio-Level Performance

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
cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='sharpe')
cerebro.addanalyzer(bt.analyzers.DrawDown, _name='drawdown')
cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name='trades')

# Run the backtest and retrieve results
results = cerebro.run()

# Access the portfolio-level results
sharpe_ratio = results[0].analyzers.sharpe.get_analysis()['sharperatio']
max_drawdown = results[0].analyzers.drawdown.get_analysis()['max']['drawdown']
trade_metrics = results[0].analyzers.trades.get_analysis()

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.

Step 5: Implementing Trailing Stops

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.

Step 6: Visualizing Results

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
cerebro.plot(style='candlestick', barup='green', bardown='red')
Pasted image 20250805214729.png

Note that with many assets, the plot can become crowded. In such cases, you might consider plotting a subset of the assets for clarity.

Implementation Considerations

Conclusion

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.