多资产 / 投资组合回测

QTrade 支持投资组合回测:单个策略同时交易多个资产,共用一个现金池。快速开始里的单资产用法保持不变 —— 启用多资产只需要把 DataFrame 换成 dict 传进去。

数据

传入一个以资产代码为键的 dict[str, pd.DataFrame]。每个 DataFrame 都需要标准的 OHLCV 列,并且必须共享完全一致的 DatetimeIndex(数据源不一致时请预先对齐 / reindex):

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 会单独验证每个 DataFrame,如果索引不一致会抛出清晰的错误。

策略

多资产模式下,策略必须指定每个订单针对哪个资产。遍历 self.assets,并使用 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 速查:

单资产简写

多资产对应写法

self.buy(size=10)

self.buy("AAPL", size=10)

self.sell(size=5)

self.sell("AAPL", size=5)

self.close()

self.close() —— 关掉所有资产;self.close("AAPL") 只关一个

self.position

self.positions["AAPL"]

self.data

self.data_by_asset["AAPL"]

在多资产模式下,self.positionself.data 会抛出 AttributeError,提示信息会指向对应的 dict 访问器。

运行回测

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

show_stats() 输出的 Buy & Hold Return [%] 是各资产的等权平均收益 —— 对组合策略而言这是一个合理的默认基准。

按资产拆分

查看每个资产的交易级统计:

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}%")

组合级指标(Sharpe、Sortino、回撤、净值曲线)本质上是组合范围的,只能通过 calculate_stats(broker) 获取 —— 把它们归因到单个资产需要额外的会计逻辑,QTrade 有意不去做这件事。

可视化

bt.plot() 渲染多面板布局:

  • 顶部:组合净值 vs 等权 Buy & Hold(叠加回撤区域)。

  • 中部:所有资产的交易收益散点图。

  • 底部:每个资产一个 OHLC 面板,纵向堆叠,X 轴联动缩放。

每个资产面板上的赢/输交易框和买卖标记仅显示该资产自身的交易。

交易历史

bt.get_trade_history() 返回的 DataFrame 包含 Asset 列,方便后续按代码筛选或分组:

df = bt.get_trade_history()
print(df.groupby('Asset')['Profit'].sum())