Multi-asset / portfolio backtests¶
QTrade supports portfolio backtests where a single strategy trades multiple assets sharing one cash pool. The single-asset workflow from Getting Started is unchanged — multi-asset is enabled by passing a dict of DataFrames instead of a single one.
Data¶
Pass a dict[str, pd.DataFrame] keyed by asset symbol. Every DataFrame
needs the standard OHLCV columns and must share an identical
DatetimeIndex (align/reindex up-front if your sources don’t):
import yfinance as yf
import pandas as pd
aapl = yf.download("AAPL", start="2023-01-01", end="2024-01-01",
interval="1d", multi_level_index=False)
nvda = yf.download("NVDA", start="2023-01-01", end="2024-01-01",
interval="1d", multi_level_index=False)
# Inner-join indexes to ensure both assets are present on every bar.
common_index = aapl.index.intersection(nvda.index)
data = {
"AAPL": aapl.loc[common_index],
"NVDA": nvda.loc[common_index],
}
Backtest validates each DataFrame independently and raises a clear error
if the indexes diverge.
Strategy¶
In multi-asset mode the strategy must specify which asset each order
targets. Iterate self.assets and use self.positions[symbol] /
self.data_by_asset[symbol]:
from qtrade.backtest import Strategy
class PortfolioMeanReversion(Strategy):
def prepare(self):
# Compute indicators per asset.
for asset, df in self._data.items():
df['ma'] = df['Close'].rolling(self.window).mean()
df['z'] = (df['Close'] - df['ma']) / df['Close'].rolling(self.window).std()
def on_bar_close(self):
for asset in self.assets:
df = self.data_by_asset[asset]
z = df['z'].iloc[-1]
if pd.isna(z):
continue
pos = self.positions[asset].size
if abs(z) < 0.3 and pos != 0:
self.close(asset)
elif z < -1.0 and pos <= 0:
self.close(asset)
self.buy(asset, size=10)
elif z > 1.0 and pos >= 0:
self.close(asset)
self.sell(asset, size=10)
API overview when running multi-asset:
Single-asset shorthand |
Multi-asset equivalent |
|---|---|
|
|
|
|
|
|
|
|
|
|
self.position and self.data raise AttributeError in multi-asset
mode with a message pointing at the dict accessor.
Running it¶
from qtrade.backtest import Backtest
from qtrade.core import PercentageCommission
bt = Backtest(
data,
PortfolioMeanReversion,
cash=200_000,
commission=PercentageCommission(percentage=0.0005),
margin_ratio=0.5,
trade_on_close=True,
)
bt.run(window=10)
bt.show_stats() # portfolio-level metrics
The Buy & Hold Return [%] shown in show_stats() is the
equal-weighted average across all assets — a sensible default
benchmark for portfolio strategies.
Per-asset breakdown¶
For trade-level statistics on each asset:
from qtrade.utils.stats import calculate_stats_per_asset
per = calculate_stats_per_asset(bt.broker)
for asset, stats in per.items():
print(f"--- {asset} ---")
print(f" Trades: {stats['Total Trades']}")
print(f" Win Rate: {stats['Win Rate [%]']:.1f}%")
print(f" Buy & Hold: {stats['Buy & Hold Return [%]']:.2f}%")
Aggregate metrics (Sharpe, Sortino, drawdown, equity curve) are inherently
portfolio-wide and only available via calculate_stats(broker) —
attribution to individual assets requires extra accounting that QTrade
intentionally doesn’t try to do.
Plot¶
bt.plot() renders a multi-panel layout:
Top: portfolio equity vs equal-weighted Buy & Hold (with drawdown overlay).
Middle: trade-returns scatter across all assets.
Bottom: one OHLC panel per asset, stacked vertically with linked x-axes.
Each per-asset panel shows the win/lose trade boxes and buy/sell markers filtered to that asset’s trades only.
Trade history¶
bt.get_trade_history() returns a DataFrame with an Asset column so
you can filter or group by symbol downstream:
df = bt.get_trade_history()
print(df.groupby('Asset')['Profit'].sum())