Python量化投资回测利器:BT(Backtesting Toolkit)框架深度解析与实用教程
好的,这是一份关于 bt
(Backtesting Toolkit) Python 库的详细解析和教程,内容使用中文编写,并包含目录。
bt
是一个灵活、基于 Python 的开源回测框架,旨在帮助量化交易员和研究人员快速测试和评估交易策略。它构建在 Pandas 和 NumPy 等标准库之上,易于集成和扩展。
目录
- 简介
- 1.1 什么是 bt?
- 1.2 为什么选择 bt?
- 安装
- 核心概念
- 3.1 数据 (Data)
- 3.2 算法 (Algorithm)
- 3.3 策略 (Strategy)
- 3.4 回测 (Backtest)
- 3.5 结果 (Result)
- 3.6 树状结构 (Tree Structure)
- 快速入门:一个简单的例子
- 4.1 获取数据
- 4.2 定义策略
- 4.3 创建并运行回测
- 4.4 查看结果
- 关键组件详解
- 5.1 数据处理
- 获取数据 (
bt.get
) - 自定义数据
- 5.2 内置算法 (Algos)
- 时间驱动型 (Timing Algos)
- 选择型 (Selection Algos)
- 权重分配型 (Weighting Algos)
- 执行/再平衡型 (Execution/Rebalancing Algos)
- 其他辅助型
- 5.3 构建策略树
- 单个策略
- 嵌套策略 (父子策略)
- 5.4 配置回测 (
Backtest
) - 初始资本
- 佣金和滑点
- 整数股数
- 5.5 结果分析 (
Result
) - 常用统计指标 (
display
) - 绘制图表 (
plot
) - 获取详细数据
- 进阶主题
- 6.1 创建自定义算法
- 6.2 比较多个策略
- 6.3 处理更高频率的数据 (注意事项)
- 6.4 与其他库集成 (如 yfinance)
- 最佳实践与注意事项
- 7.1 数据质量
- 7.2 过拟合 (Overfitting)
- 7.3 前视偏差 (Lookahead Bias)
- 7.4 交易成本
- 7.5 现实性假设
- 总结
1. 简介
1.1 什么是 bt?
bt
是一个用于量化交易策略回测的 Python 库。它提供了一套结构化的工具,让用户可以方便地定义策略逻辑(何时买入/卖出、买入/卖出什么、买入/卖出多少),并使用历史数据模拟这些策略的执行情况,最终评估其表现。
1.2 为什么选择 bt?
bt
采用模块化设计,特别是其基于算法栈(Algo Stack)的策略构建方式,非常灵活。2. 安装
使用 pip 可以轻松安装 bt
:
pip install bt
建议同时安装一些常用的数据获取库(如 yfinance
)和绘图库(matplotlib
,bt
会自动调用):
pip install yfinance matplotlib
3. 核心概念
bt
的运作围绕以下几个核心概念构建:
3.1 数据 (Data)
回测的基础是历史市场数据。bt
主要使用 Pandas DataFrame 来存储时间序列数据,通常索引是日期时间(DatetimeIndex),列是不同的资产(如股票代码),值是价格(通常是收盘价)。
3.2 算法 (Algorithm / Algo)
算法是策略执行的具体逻辑单元。它们定义了交易决策的各个方面,例如:
3.3 策略 (Strategy)
策略是算法的容器和组织者。一个策略包含一个或多个算法,这些算法按照特定的顺序(栈)依次执行,共同定义了完整的交易逻辑。策略也可以包含子策略,形成一个树状结构。
3.4 回测 (Backtest)
回测是将策略应用于历史数据的过程。Backtest
对象负责管理这个模拟过程,包括跟踪投资组合价值、处理交易、计算费用等。
3.5 结果 (Result)
回测完成后,Result
对象存储了模拟的详细结果,包括每日的投资组合价值、交易记录、各种性能统计指标(如年化收益率、夏普比率、最大回撤等)以及绘图功能。
3.6 树状结构 (Tree Structure)
bt
的一个强大之处在于其策略可以组织成树状结构。一个父策略可以包含多个子策略。当回测运行时,数据和信号会从树的根节点流向叶节点,而权重等决策则可能从叶节点汇总回根节点。这使得构建复杂的多层策略(如资产配置策略下包含具体的选股子策略)成为可能。
4. 快速入门:一个简单的例子
让我们创建一个非常简单的策略:每月对 SPY 和 TLT(美国标普 500 ETF 和美国长期国债 ETF)进行等权重再平衡。
import bt
import pandas as pd
import matplotlib.pyplot as plt
# 设置 Matplotlib 在某些环境下正确显示图形
%matplotlib inline
# 4.1 获取数据
# 使用 bt 自带的 get 函数获取雅虎财经数据
# tickers = ['SPY', 'TLT']
# start_date = '2010-01-01'
# end_date = '2023-12-31'
# data = bt.get(tickers, start=start_date, end=end_date)
# 如果 bt.get 无法使用(有时会因雅虎财经接口变动),可以用 yfinance 替代
import yfinance as yf
tickers = ['SPY', 'TLT']
start_date = '2010-01-01'
end_date = '2023-12-31'
data = yf.download(tickers, start=start_date, end=end_date)['Adj Close']
# 确保数据没有缺失值(简单处理:向前填充)
data = data.ffill()
print("数据预览:")
print(data.head())
# 4.2 定义策略
# 策略名称: "EqualWeight_SPY_TLT"
# 算法栈:
# 1. RunMonthly(): 每月运行一次后续算法
# 2. SelectAll(): 选择所有可用的资产 (SPY, TLT)
# 3. WeighEqually(): 为选中的资产分配相等的权重
# 4. Rebalance(): 根据目标权重调整仓位
strategy = bt.Strategy('EqualWeight_SPY_TLT', [
bt.algos.RunMonthly(),
bt.algos.SelectAll(),
bt.algos.WeighEqually(),
bt.algos.Rebalance()
])
# 4.3 创建并运行回测
# 使用定义的策略和获取的数据创建回测对象
backtest = bt.Backtest(strategy, data)
# 运行回测
# bt.run() 可以接受一个或多个回测对象
results = bt.run(backtest)
# 4.4 查看结果
# 显示关键性能指标
print("\n回测结果统计:")
results.display()
# 绘制权益曲线和资产权重图
print("\n绘制图表:")
results.plot(title='Equal Weight SPY & TLT Strategy')
plt.show() # 确保图形显示
# 绘制安全权重变化图
results.plot_security_weights()
plt.show()
# 查看交易记录 (可选)
# print("\n交易记录:")
# print(results.get_transactions())
这个例子展示了 bt
的基本流程:获取数据 -> 定义策略(通过组合算法)-> 创建回测 -> 运行回测 -> 分析结果。
5. 关键组件详解
5.1 数据处理
获取数据 (bt.get
)
bt.get
是一个便捷函数,用于从雅虎财经等来源下载价格数据。
# 获取多个 tickers 的数据
data = bt.get('agg,eem,spy', start='2010-01-01')
# 获取单个 ticker
spy_data = bt.get('spy', start='2010-01-01', end='2022-12-31')
注意: bt.get
的稳定性取决于底层数据源(如雅虎财经)API 的稳定性。有时可能需要使用 yfinance
或 pandas_datareader
等其他库来获取数据。
自定义数据
任何符合格式(DatetimeIndex,列为资产,值为价格)的 Pandas DataFrame都可以用作 bt
的输入数据。
# 假设你有一个 CSV 文件 my_data.csv
# Date,AssetA,AssetB
# 2020-01-01,100,50
# 2020-01-02,101,51
# ...
# my_custom_data = pd.read_csv('my_data.csv', index_col='Date', parse_dates=True)
# backtest = bt.Backtest(some_strategy, my_custom_data)
# results = bt.run(backtest)
重要提示: 确保数据质量!处理缺失值(NaN)非常重要,常见的处理方法包括向前填充 (ffill
)、向后填充 (bfill
) 或删除包含 NaN 的行/列(可能导致数据损失)。
5.2 内置算法 (Algos)
算法是 bt
的核心。它们像积木一样组合起来构建策略。算法通常按类型分类:
时间驱动型 (Timing Algos)
决定策略逻辑何时执行。
RunOnce()
: 只在开始时运行一次。RunDaily()
: 每天运行。RunWeekly()
: 每周运行(默认周的最后一天)。RunMonthly()
: 每月运行(默认月最后一天)。RunQuarterly()
: 每季度运行。RunYearly()
: 每年运行。RunOnDate(*dates)
: 在指定的日期运行。RunAfterDays(days)
: 在策略启动 N 天后运行。RunPeriodically(months=0, weeks=0, days=0)
: 按指定的周期运行。选择型 (Selection Algos)
决定哪些资产被选中用于后续的权重分配。
SelectAll()
: 选择当前数据中的所有资产。SelectNone()
: 不选择任何资产(用于清仓或作为逻辑分支)。SelectThese(tickers)
: 选择指定的资产列表。SelectN(n, sort_descending=True, all_or_none=False, filter_selected=None)
: 选择基于某种标准排序后的前 N 个或后 N 个资产(默认按价格排序,通常需要与其他算法如 SelectMomentum
结合)。SelectWhere(signal, include_no_data=False)
: 基于一个布尔型的 DataFrame (signal
) 来选择资产。signal
DataFrame 的索引和列应与价格数据匹配,值为 True 的位置表示选中该资产。SelectMomentum(n, lookback=pd.DateOffset(months=6), lag=pd.DateOffset(days=0), sort_descending=True)
: 选择动量(过去一段时间收益率)最高/最低的 N 个资产。
n
: 选择的数量。lookback
: 计算动量的时间窗口。lag
: 动量计算与决策日之间的时间差(避免前视偏差)。sort_descending
: True 表示选动量最高的,False 表示选最低的。权重分配型 (Weighting Algos)
为选中的资产分配目标权重。
WeighEqually()
: 为所有选中的资产分配相等的权重。WeighSpecified(**weights)
: 为指定的资产分配固定的权重,未指定的默认为 0。WeighTarget(target_weights)
: 根据一个目标权重 DataFrame 或 Series 分配权重。这个 DataFrame/Series 的索引应是日期,列/索引应是资产名。这非常强大,可以实现复杂的动态权重逻辑。WeighInvVol(lookback=pd.DateOffset(months=3), lag=pd.DateOffset(days=0))
: 根据资产波动率的倒数分配权重(波动率越低,权重越高)。WeighERC(lookback=pd.DateOffset(months=3), initial_weights=None, risk_weights=None, covar_method='ledoit-wolf', risk_parity_method='ccd', maximum_iterations=100, tolerance=1e-08)
: 实现等风险贡献(Equal Risk Contribution)组合。执行/再平衡型 (Execution/Rebalancing Algos)
将计算出的目标权重转化为实际的持仓调整。
Rebalance()
: 核心算法!它比较当前持仓和目标权重,生成交易指令以达到目标权重。几乎所有策略都需要这个算法放在算法栈的末尾。CapitalFlow(amount)
: 在每次触发时向策略注入或提取固定金额的资金。其他辅助型
PrintDate()
: 打印当前回测日期,用于调试。PrintTempData()
: 打印传递给下一个算法的临时数据 (temp
),用于调试。SelectHasData(min_period=1)
: 选择在当前时间点有有效数据的资产。算法栈 (Algo Stack):
策略中的算法是按顺序执行的。前一个算法的输出(通常存储在 temp
变量中)会作为后一个算法的输入。例如,RunMonthly -> SelectMomentum(5) -> WeighEqually -> Rebalance
的流程是:
RunMonthly
: 检查今天是否是月末,如果是,则继续。SelectMomentum(5)
: 计算过去 N 个月的动量,选择动量最高的 5 个资产,并将列表放入temp['selected']
。WeighEqually
: 读取temp['selected']
中的资产列表,为它们计算等权重,并将目标权重放入temp['weights']
。Rebalance
: 读取temp['weights']
,与当前持仓比较,执行交易以匹配目标权重。
5.3 构建策略树
单个策略
如快速入门所示,一个简单的策略就是一个 Strategy
对象,包含一个算法列表。
sma_cross_strategy = bt.Strategy('SMACross', [
bt.algos.RunMonthly(),
# SelectWhere 需要一个布尔信号 DataFrame
# (假设 signal_df 是预先计算好的买入信号)
# bt.algos.SelectWhere(signal_df),
bt.algos.WeighEqually(), # 对信号为 True 的资产等权重
bt.algos.Rebalance()
])
嵌套策略 (父子策略)
可以将多个策略组合成一个父策略。这对于构建资产配置框架非常有用。
# 子策略 1: 美国股票动量
us_equity_momentum = bt.Strategy('USEquityMomentum', [
bt.algos.RunMonthly(),
bt.algos.SelectMomentum(n=3, lookback=pd.DateOffset(months=6)), # 选美股中动量前3
bt.algos.WeighEqually(),
bt.algos.Rebalance()
])
# 子策略 2: 国际股票等权重
intl_equity_equal = bt.Strategy('IntlEquityEqual', [
bt.algos.RunMonthly(),
bt.algos.SelectAll(), # 选择所有国际股票
bt.algos.WeighEqually(),
bt.algos.Rebalance()
])
# 父策略: 资产配置 (60% 美股动量, 40% 国际股票)
# 注意: 父策略的 SelectAll() 会将数据传递给子策略
# 父策略的 WeighSpecified() 会将权重分配给子策略
asset_allocation = bt.Strategy('AssetAllocation', [
bt.algos.RunMonthly(),
bt.algos.SelectAll(), # 确保子策略能接收到所有相关数据
bt.algos.WeighSpecified(USEquityMomentum=0.6, IntlEquityEqual=0.4), # 分配权重给子策略
bt.algos.Rebalance()
], children=[us_equity_momentum, intl_equity_equal]) # 指定子策略
# 假设 all_data 包含了美国和国际股票的数据
# us_tickers = ['SPY', 'QQQ', 'IWM']
# intl_tickers = ['VEA', 'VWO', 'EEM']
# all_tickers = us_tickers + intl_tickers
# all_data = bt.get(all_tickers, start='2010-01-01')
# 创建包含所有数据的 DataFrame (确保列名与子策略中的资产对应)
# ... 准备好 all_data ...
# 创建并运行回测 (使用父策略)
# backtest_aa = bt.Backtest(asset_allocation, all_data)
# results_aa = bt.run(backtest_aa)
# results_aa.display()
# results_aa.plot()
在这个例子中:
- 父策略
AssetAllocation
每月运行。 SelectAll
将所有资产数据传递下去。WeighSpecified
指定USEquityMomentum
子策略获得 60% 的资本,IntlEquityEqual
子策略获得 40%。- 数据和分配到的资本比例会传递给相应的子策略。
- 子策略内部执行各自的逻辑(选股、分配内部权重)。
Rebalance
在父策略层面执行,确保整体分配比例和子策略内部的持仓都得到调整。
5.4 配置回测 (Backtest
)
Backtest
对象用于将策略和数据结合起来进行模拟。
backtest = bt.Backtest(
strategy,
data,
name='MyBacktest',
initial_capital=100000.0,
commissions=lambda q, p: abs(q) * p * 0.001, # 0.1% 的佣金
# slippage=... # 可以定义滑点函数
integer_positions=False # 是否允许小数股数
)
初始资本
initial_capital
: 回测开始时的模拟资金,默认为 1,000,000。
佣金和滑点
commissions
: 一个函数,接收交易数量 (q
, 负数表示卖出) 和价格 (p
),返回交易成本。
slippage
: 类似地,一个函数定义滑点模型。
# 固定佣金示例: 每笔交易 5 美元
def fixed_commission(q, p):
return 5 if q != 0 else 0
# 百分比佣金示例: 交易额的 0.1%
def percentage_commission(q, p):
return abs(q * p) * 0.001
backtest_with_comm = bt.Backtest(
strategy,
data,
commissions=percentage_commission
)
整数股数
integer_positions
: 默认为 True
,表示只能交易整数股数。对于高价股或小额资本,这可能导致目标权重难以精确匹配。设为 False
允许小数股数,更接近某些现代券商的功能,但也可能不够现实。
5.5 结果分析 (Result
)
bt.run()
返回一个 Result
对象(或者当运行多个回测时,返回一个包含多个 Result
对象的列表)。
常用统计指标 (display
)
results.display()
打印出关键的性能指标,包括:
绘制图表 (plot
)
results.plot()
绘制权益曲线、回撤、月度回报率等图表。
results.plot_security_weights()
绘制资产权重随时间变化的图表。
results.plot_histograms()
绘制收益率分布直方图。
获取详细数据
Result
对象还包含许多有用的属性和方法来获取底层数据:
results.prices
: 策略的每日净值(类似价格)。results.equity
: 策略的每日总权益。results.turnover
: 策略的换手率。results.stats
: 包含 display()
中所有指标的 Pandas Series。results.get_transactions()
: 获取所有交易记录的 DataFrame。results.get_security_weights()
: 获取每日资产权重的 DataFrame。# 获取年化收益率
cagr = results.stats['cagr']
print(f"年化复合增长率: {cagr:.2%}")
# 获取最大回撤
max_dd = results.stats['max_drawdown']
print(f"最大回撤: {max_dd:.2%}")
# 获取交易记录
transactions = results.get_transactions()
print("\n部分交易记录:")
print(transactions.head())
6. 进阶主题
6.1 创建自定义算法
如果内置算法无法满足需求,可以创建自己的算法。需要继承 bt.Algo
并实现 __call__
方法。
__call__(self, target)
方法是算法的核心。它接收一个 target
对象(通常是 Strategy
或 Backtest
实例),并应返回 True
表示算法成功执行并希望继续执行后续算法,或 False
中断当前时间点的算法栈执行。
自定义算法可以通过 target.temp
字典与其他算法交互(读取输入、写入输出),也可以访问 target.now
(当前日期), target.data
(原始数据), target.universe
(当前可用资产数据), target.weights
(当前持仓权重) 等属性。
import bt
import pandas as pd
import numpy as np
# 示例:自定义算法 - 只在星期一进行交易
class RunOnMonday(bt.Algo):
"""
只在星期一触发后续算法执行的 Algo。
"""
def __init__(self):
super(RunOnMonday, self).__init__()
def __call__(self, target):
# target.now 是当前回测的日期
if target.now.weekday() == 0: # 0 代表星期一
# 如果是星期一,返回 True,让后续算法执行
return True
else:
# 如果不是星期一,返回 False,中断后续算法
return False
# 示例:自定义算法 - 基于外部信号文件分配权重
class WeighFromSignalFile(bt.Algo):
"""
根据外部 CSV 文件中的信号分配权重的 Algo。
假设 CSV 文件格式: Date, AssetA_weight, AssetB_weight, ...
"""
def __init__(self, signal_filepath):
super(WeighFromSignalFile, self).__init__()
# 在初始化时读取并处理信号文件
self.signals = pd.read_csv(signal_filepath, index_col='Date', parse_dates=True)
# 可能需要对齐索引和列名到主数据
def __call__(self, target):
# 获取当前日期的目标权重
if target.now in self.signals.index:
current_weights = self.signals.loc[target.now]
# 过滤掉权重为 NaN 或 0 的资产
current_weights = current_weights[current_weights > 0].dropna()
# 将权重存入 target.temp['weights']
target.temp['weights'] = current_weights
return True
else:
# 如果当天没有信号,可以选择不操作或维持旧权重 (取决于逻辑)
# 这里简单地不设置权重,后续 Rebalance 可能不会执行交易
target.temp['weights'] = pd.Series(dtype=float) # 设置空权重
return True # 仍然返回 True,让 Rebalance 处理空权重(即清仓或维持)
# --- 使用自定义算法 ---
# ticker_data = bt.get('SPY,AGG', start='2018-01-01')
# my_strategy = bt.Strategy('MondayTrader', [
# RunOnMonday(), # 使用自定义的时间算法
# bt.algos.SelectAll(),
# bt.algos.WeighEqually(), # 简单示例,可以在周一等权重
# bt.algos.Rebalance()
# ])
# backtest = bt.Backtest(my_strategy, ticker_data)
# results = bt.run(backtest)
# results.display()
6.2 比较多个策略
bt.run()
可以同时接收多个 Backtest
对象,方便直接比较它们的性能。
# 策略 1: 等权重 (来自前面的例子)
strategy1 = bt.Strategy('EqualWeight', [
bt.algos.RunMonthly(),
bt.algos.SelectAll(),
bt.algos.WeighEqually(),
bt.algos.Rebalance()
])
backtest1 = bt.Backtest(strategy1, data) # 使用之前获取的 data
# 策略 2: 只持有 SPY
strategy2 = bt.Strategy('BuyAndHold_SPY', [
bt.algos.RunOnce(),
bt.algos.SelectThese(['SPY']), # 只选 SPY
bt.algos.WeighSpecified(SPY=1.0), # 满仓 SPY
bt.algos.Rebalance()
])
backtest2 = bt.Backtest(strategy2, data)
# 策略 3: 60/40 SPY/TLT
strategy3 = bt.Strategy('60_40_SPY_TLT', [
bt.algos.RunMonthly(),
bt.algos.SelectAll(),
bt.algos.WeighSpecified(SPY=0.6, TLT=0.4),
bt.algos.Rebalance()
])
backtest3 = bt.Backtest(strategy3, data)
# 同时运行多个回测
all_results = bt.run(backtest1, backtest2, backtest3)
# 显示比较结果
print("\n策略比较:")
all_results.display()
# 绘制比较图表
all_results.plot(title='Strategy Comparison')
plt.show()
Result
对象现在会包含所有策略的表现,方便进行横向对比。
6.3 处理更高频率的数据 (注意事项)
bt
主要设计用于日线或更低频率(周线、月线)的回测。虽然理论上可以将更高频率(如小时线、分钟线)的数据输入 Pandas DataFrame 进行回测,但需要注意以下几点:
bt
的很多内置算法(如 RunMonthly
, SelectMomentum
的默认 lookback
)是基于日线逻辑设计的。在高频场景下,你可能需要:
RunEveryNMinutes
, RunHourly
等)。lookback
, lag
)。bt
的默认 Rebalance
机制可能过于简化。你可能需要创建非常复杂的自定义执行算法。总的来说,对于严肃的高频回测,可能需要考虑使用更专门的框架(如 Zipline、Backtrader 的某些特性,或者专门的高频库)。但在较低频率的日内策略(例如,每天开盘/收盘交易一次)或小时级别策略,bt
配合自定义算法仍然是可行的。
6.4 与其他库集成 (如 yfinance)
bt
的优势在于其输入是标准的 Pandas DataFrame。这意味着你可以使用任何你喜欢的方式来获取和处理数据,只要最终能转换成 bt
期望的格式即可。
import yfinance as yf
import bt
# 使用 yfinance 获取数据
tickers = ['AAPL', 'MSFT', 'GOOG']
start = '2015-01-01'
end = '2023-12-31'
yf_data = yf.download(tickers, start=start, end=end)
# 选择需要的价格列,通常是 'Adj Close' (已调整收盘价)
price_data = yf_data['Adj Close']
# 处理缺失值 (重要!)
price_data = price_data.ffill().dropna() # 先向前填充,再删除开头可能存在的 NaN
# 创建策略
tech_momentum_strategy = bt.Strategy('TechMomentum', [
bt.algos.RunMonthly(),
bt.algos.SelectMomentum(n=1, lookback=pd.DateOffset(months=6)), # 选择动量最好的 1 个
bt.algos.WeighEqually(), # 只有一个,权重为 1
bt.algos.Rebalance()
])
# 创建并运行回测
backtest_yf = bt.Backtest(tech_momentum_strategy, price_data)
results_yf = bt.run(backtest_yf)
# 显示结果
results_yf.display()
results_yf.plot()
plt.show()
7. 最佳实践与注意事项
进行有效的回测需要遵循一些原则,避免常见的陷阱:
7.1 数据质量
7.2 过拟合 (Overfitting)
策略在历史数据上表现极好,但在未来实盘中表现糟糕。这通常是因为策略过度拟合了历史数据的特定噪声或模式。
7.3 前视偏差 (Lookahead Bias)
在回测的某个时间点使用了未来的信息。这是最严重的错误之一。
bt
的 lag
参数有助于此。例如,在 T 日决策,动量计算应基于 T-1 日或更早的数据。7.4 交易成本
7.5 现实性假设
8. 总结
bt
是一个强大而灵活的 Python 回测框架。它通过模块化的算法栈和策略树结构,使得用户能够相对容易地构建、测试和比较各种量化交易策略。其与 Pandas 的紧密集成简化了数据处理流程。
要有效地使用 bt
(以及任何回测工具),关键在于理解其核心概念,掌握常用算法的用法,并注意避免回测中的常见陷阱,如过拟合和前视偏差,同时纳入对交易成本和现实限制的考量。
希望这份详细的解析和教程能帮助你开始使用 bt
进行量化策略的回测与研究。建议进一步探索 bt
的官方文档和示例以了解更多高级功能和细节。
作者:hiquant