第 8 章 · 03 backtrader 事件驱动 本节摘要:本节把回测的逼真度拉上一个台阶——用 backtrader 做事件驱动回测。与上一节「信号乘收益」的向量化不同,backtrader 逐根 K 线喂给你的策略,策略发订单给经纪商,经纪商撮合、扣手续费、记账,像真实交易一样按事件顺序推进。本节讲清四件事:backtrader 的 Cerebro(大脑)架构由哪些对象组成;如何自定义数据加载器( )、自定义手续费( )、自定义策略( );策略如何在 里按预测排名调仓、在 里记账;以及如何把结果接到 pyfolio 出绩效报告。读完本节,你能用 backtrader 把一个多空策略跑成事件驱动的回测。 内容来源:原项目 ,汉化并套用体系化模板。
本节摘要:本节把回测的逼真度拉上一个台阶——用 backtrader 做事件驱动回测。与上一节「信号乘收益」的向量化不同,backtrader 逐根 K 线喂给你的策略,策略发订单给经纪商,经纪商撮合、扣手续费、记账,像真实交易一样按事件顺序推进。本节讲清四件事:backtrader 的 Cerebro(大脑)架构由哪些对象组成;如何自定义数据加载器(
SignalData)、自定义手续费(FixedCommisionScheme)、自定义策略(MLStrategy);策略如何在next()里按预测排名调仓、在notify_order()里记账;以及如何把结果接到 pyfolio 出绩效报告。读完本节,你能用 backtrader 把一个多空策略跑成事件驱动的回测。
内容来源:原项目
08_ml4t_workflow/03_backtesting_with_backtrader.ipynb,汉化并套用体系化模板。
⚠️ 学习提示:事件驱动回测比向量化慢一个数量级,但更接近实盘。它会把滑点、手续费、订单未成交一个个扣回去,策略夏普通常会显著衰减——这是好事,说明你在看更接近真相的数字。
阅读完本节,你应当能够:
bt.Strategy 子类的 next() 与 notify_order() 方法。addanalyzer(PyFolio) 把回测结果接进 pyfolio。backtrader 由 Daniel Rodriguez 自 2015 年起开发,是 Python 生态里最流行的本地回测库之一。它的核心是 Cerebro(西班牙语「大脑」),把回测的关键角色抽象成可扩展的 Python 对象,由一个 Cerebro 实例统一调度。
| 对象 | 职责 | 本节示例 |
|---|---|---|
Cerebro |
总调度,加载数据、运行策略、收结果 | cerebro = bt.Cerebro() |
Data Feeds |
喂入 OHLCV + 自定义列 | SignalData(dataname=df) |
Strategy |
你的策略逻辑,逐根 K 线被调用 | MLStrategy(bt.Strategy) |
Broker |
接收订单、撮合、扣手续费、记账 | cerebro.broker.setcash(10000) |
Analyzers |
计算绩效指标 | addanalyzer(PyFolio) |
💡 核心心法:backtrader 的「事件驱动」体现在「策略代码被引擎逐根 K 线调用」——每一根 K 线到来都是一个事件,你的
next()在那个事件里读最新数据、做决策、发订单。这跟实盘里「每收到一个 tick 都重新评估」的逻辑一致,所以更接近真实。
backtrader 默认只认 OHLCV,但我们的 ML 策略还需要一列 predicted(模型预测)。继承 PandasData 加一条 lines:
OHLCV = ['open', 'high', 'low', 'close', 'volume'] class SignalData(PandasData): cols = OHLCV + ['predicted'] lines = tuple(cols) # 新增一条叫 predicted 的「线」 params = {c: -1 for c in cols} # -1 表示「列名与 lines 同名」 params.update({'datetime': None}) params = tuple(params.items())
lines 是 backtrader 的核心概念——每条 line 就是一条按时间索引的数值序列(类似 pandas 的 Series)。OHLCV 各是一条 line,我们再加一条 predicted,策略就能在每根 K 线上读到当天的预测值。
backtrader 的手续费体系高度可定制。这里实现一个简单的「每股固定 2 分钱」方案:
class FixedCommisionScheme(bt.CommInfoBase): params = (('commission', .02), ('stocklike', True), ('commtype', bt.CommInfoBase.COMM_FIXED),) def _getcommission(self, size, price, pseudoexec): return abs(size) * self.p.commission
stocklike=True 表示这是股票(非期货,无保证金逻辑),COMM_FIXED 表示按手数固定收费。_getcommission 接成交手数与价格,返回实际费用。
策略继承 bt.Strategy,关键参数 n_positions(目标持仓数)和 min_positions(最少持仓数,低于它就不调):
class MLStrategy(bt.Strategy): params = (('n_positions', 10), ('min_positions', 5), ('verbose', False), ('log_file', 'backtest.csv'))
next() 是策略的心脏。典型逻辑是:每根 K 线到来时,从所有 data feed 收集预测值、排名、决定哪些该买哪些该卖,然后发 order_target 订单把每只股票调到目标权重。order_target 是 backtrader 的便捷接口——你告诉它「想让这只股票占总资产的 X%」,它自己算需要买/卖多少股。
订单发出后会有生命周期:Submitted → Accepted → Completed / Canceled / Margin / Rejected。notify_order 在状态变化时被调用:
def notify_order(self, order): if order.status in [order.Submitted, order.Accepted]: return if order.status in [order.Completed]: p = order.executed.price if order.isbuy(): self.log(f'{order.data._name},BUY executed,{p:.2f}') elif order.issell(): self.log(f'{order.data._name},SELL executed,{p:.2f}') elif order.status in [order.Canceled, order.Margin, order.Rejected]: self.log(f'{order.data._name},FAIL')
注意几个关键状态:
| 状态 | 含义 | 策略应做什么 |
|---|---|---|
Submitted / Accepted |
已提交/已接受,未成交 | 直接 return,不打日志 |
Completed |
已成交 | 记账、打日志 |
Margin |
现金不足被拒 | 通常意味着仓位超限 |
Rejected |
经纪商拒绝 | 参数或资金问题 |
⚠️ order.Margin 的含义:当现金不足以支撑订单时,broker 会拒绝并返回
Margin状态。这是 backtrader 告诉你「策略想买的比钱多」的方式——在向量化回测里你永远看不到这个,因为那里假设资金无限。
把数据、策略、分析器装到 Cerebro 里运行:
cerebro = bt.Cerebro() cash = 10000 cerebro.broker.setcash(cash) # 给每只股票加一个数据 feed for ticker in tickers: df = data.loc[idx[ticker, :], :].droplevel('ticker', axis=0) df.index.name = 'datetime' cerebro.adddata(SignalData(dataname=df), name=ticker) # 加分析器和策略 cerebro.addanalyzer(bt.analyzers.PyFolio, _name='pyfolio') cerebro.addstrategy(MLStrategy, n_positions=25, min_positions=20, verbose=True, log_file='bt_log.csv') results = cerebro.run() ending_value = cerebro.broker.getvalue()
最后 cerebro.broker.getvalue() 给出期末组合净值,results 含分析器输出的 pyfolio 输入。整套流程是「装数据 → 装策略 → 装分析器 → run → 取结果」五步。
💡 为什么不用 cerebro.plot():notebook 特意禁用了
cerebro.plot(),因为每只股票单独画一条会刷屏。实务里通常直接用 pyfolio 出标准绩效报告,更清晰。
| 维度 | 向量化 | 事件驱动(backtrader) |
|---|---|---|
| 速度 | 快(秒级) | 慢(分钟级) |
| 逼真度 | 低(无撮合) | 高(逐根 K 线) |
| 滑点/手续费 | 难以建模 | 原生支持 |
| 订单未成交 | 不存在 | 会发生 |
| 适合阶段 | 信号初筛 | 上线前评估 |
| 代码量 | 十行 | 百行起步 |
工作流上,先用向量化筛掉明显没希望的信号,再用 backtrader 把幸存者放到事件驱动里压测——这是上一节与本节的协同关系。
next() 每根 K 线被调一次,策略发订单给 Broker,Broker 撮合并回调 notify_order()。PandasData 加一条 predicted line,让策略每根 K 线能读到模型预测。Margin 是「钱不够」的信号。addanalyzer(PyFolio) 把回测输出直接接进 pyfolio 出标准绩效报告。下一节,我们看 Zipline 回测集成——用 Quantopian 出品的工业级框架,把 ML 训练与回测端到端串起来,完成 ML4T 的完整闭环。