先跑通再优化
用示例数据把「信号 → 成交 → 统计」这条链路跑通,确认每一环都能解释清楚,再替换成自己的标的与参数。顺序反了容易把数据问题误判成策略问题。
Backtesting.py / Architecture
Backtesting.py 的架构围绕两个核心类展开:Backtest 负责引擎(现金、佣金、撮合、统计),Strategy 负责策略逻辑(init 计算指标、next 逐 bar 交易)。把这两者的分工、调用顺序与可配置项看一遍,再读官方 Quickstart 或别人的脚本就不会卡在「这行代码归谁管」上。本页按官方 README 与文档整理,具体字段与行为以官方文档为准。
Backtesting.py 的模块划分:读脚本时最先要分清的是「这行代码属于引擎还是属于策略」。下表按官方 README 的模块划分整理,最后一列是实际用起来最容易忽略的地方。
| 模块 / 环节 | 负责什么 | 你怎么用 | 注意点 |
|---|---|---|---|
backtesting.Backtest | 回测引擎:初始资金、佣金、订单撮合、执行回测与汇总统计 | 用 Backtest(数据, 策略类, ...) 创建实例,再调用 run() / optimize() / plot() | 成交时点与成本假设由引擎决定,策略里改不了;假设差异要回到引擎参数上看 |
backtesting.Strategy | 策略基类:只描述「什么时候买、什么时候卖」,不做撮合 | 继承它并实现 init() 与 next(),把信号写成下单调用 | 策略里不要试图干预成交价与手续费,那是引擎的职责 |
Strategy.init() | 回测开始前调用一次,用于预计算指标 | 用 self.I(...) 注册指标,之后在 next() 里按下标取值 | 这里只做「准备」不做交易;把下单写进 init 会让回测在无行情的时刻就下单 |
Strategy.next() | 每根 bar 调用一次,在这里判断条件并下单 | self.buy() / self.sell() / self.position.close() | 此刻只能拿到「到当前 bar 为止」的信息,用未来数据会引入前瞻偏差 |
backtesting.lib | 组合式工具函数(如交叉判断),把常见写法封装成可复用函数 | from backtesting.lib import crossover 后在 next() 中使用 | 它只是工具集合,不含撮合与统计,别把它当成独立的回测入口 |
backtesting.test | 内置示例数据与示例指标,用来跑通流程 | from backtesting.test import SMA, GOOG | 示例数据只用于验证代码路径,不能拿它的结果说明任何策略优劣 |
Backtest.run() | 按当前配置执行一次完整回测并返回绩效结果 | stats = bt.run(),随后打印或取值 | 返回结果的字段命名与口径以官方文档与实现为准,跨版本可能调整 |
Backtest.optimize() | 内置优化器:按参数网格重复回测并给出对比结果 | 传入参数范围后取扫描结果,配合热力图观察 | 网格越密越容易在样本内挑出「看起来好」的参数,需配合样本外验证 |
下面五步是用 Backtesting.py 跑通一次回测的最小顺序,每步给出示例代码、一句话说明与预期结果;代码取自官方 Quickstart 的写法,替换标的与参数即可复现。
from backtesting.test import GOOG # 示例数据 print(GOOG.tail()) # 确认列名与时间索引
引擎只接受带时间索引、含开高低收(可选成交量)的单标的数据;先确认数据形状对不对,再谈策略。
预期结果:打印出以时间为索引、包含 Open/High/Low/Close/Volume 若干行的表格。
class SmaCross(Strategy):
def init(self):
self.ma1 = self.I(SMA, self.data.Close, 10)把指标计算集中放在 init,用 self.I() 注册;注册后的序列可以在 next 里按下标访问。
预期结果:回测启动时指标被算出一遍,next 里能直接取到 self.ma1[-1] 这类最新值。
def next(self):
if crossover(self.ma1, self.ma2):
self.buy()
elif crossover(self.ma2, self.ma1):
self.position.close()next 每根 bar 执行一次,只做「判断 + 下单」;是否成交、以什么价格成交交给引擎。
预期结果:出现交叉的 bar 上产生一笔记账订单,未交叉的 bar 不产生订单。
bt = Backtest(GOOG, SmaCross, cash=10000,
commission=.002, exclusive_orders=True)
stats = bt.run()资金、佣金、订单互斥等成本与撮合假设都在这一步设定,属于引擎参数。
预期结果:stats 返回一次回测的绩效汇总,可直接打印查看。
bt.plot() # 交互式图表 bt.optimize(...) # 需要时做参数扫描
先看权益与回撤曲线,再决定要不要做参数扫描;扫描结果要用样本外区间复核。
预期结果:输出可交互的图表对象(在 Notebook 或浏览器中查看),参数扫描时返回多组参数的对比结果。
Backtesting.py 的策略类只有两个关键方法,调试回测时最常见的问题是「这行代码到底跑了没有、跑在什么时候」。下表把调用时机与常见坑放在一起对照。
| 环节 | 调用时机 | 能做什么 | 常见坑 |
|---|---|---|---|
init() | 回测开始前,只调用一次 | 注册并预计算指标、准备常量与参数 | 在里面写交易逻辑:此时还没有逐 bar 的上下文,行为与预期不一致 |
next() | 每根 bar 调用一次 | 读取当前及历史指标值、判断信号、下单 | 把指标计算写在 next 里逐 bar 重算,既慢又容易和 init 里的版本不一致 |
| 取指标最新值 | next 执行过程中 | 用 [-1] 取最新、[-2] 取上一根,用于比较与交叉判断 | 把下标写错一位就等于用了未来数据,回测会异常好看 |
| 读行情 | next 执行过程中 | 通过 self.data 访问开高低收与成交量序列 | 直接用外部变量取全量数据做判断,会绕过「当前时点」约束 |
| 下单 | next 执行过程中 | 调用买入、卖出或平仓接口提交订单 | 默认撮合时点与「看见信号的那根 bar」并不相同,需对照引擎参数确认 |
| 读仓位与权益 | next 执行过程中 | 查看是否持仓、当前权益,用于加仓、减仓与风控判断 | 把权益变化当成收益结论:它包含了未平仓浮盈浮亏 |
| 回测结束 | run() 返回之后 | 读取绩效汇总与交易明细,做可视化 | 只看一个总收益数字就下结论,忽略区间、成本与交易笔数 |
同一个策略换一组 Backtesting.py 引擎参数,绩效数字会明显不同。下表列出最常被改动、也最容易被忽略的几项,参数名与默认值以官方文档为准。
| 配置 | 作用 | 典型写法 | 适用场景 | 注意点 |
|---|---|---|---|---|
cash | 初始资金,决定每笔能下多大 | cash=10000 | 单标的策略验证 | 资金过小时受最小交易单位限制,会出现「想买买不进」,结果失真 |
commission | 单边手续费率 | commission=.002 | 想把成本假设写进回测 | 只表达手续费;滑点与冲击成本需要自己折算进去,否则高频策略会虚高 |
exclusive_orders | 订单是否互斥,避免同方向重复挂单 | exclusive_orders=True | 信号密集、容易出现同向重复下单的策略 | 开启后新订单会取代未成交的旧订单,适合单一时点只持有一笔的写法 |
trade_on_close | 是否用当前 bar 收盘价成交 | trade_on_close=True | 希望缩短「出信号」到「成交」的延迟 | 与默认成交时点的假设不同,切换前要明确两种口径的差异,别把两种结果混着比较 |
| 传入的行情数据 | 决定回测标的与可用的数据列 | Backtest(data, SmaCross) | 单标的(股票 / 期货 / 加密)日线或分钟线 | 需要带时间索引的 OHLC(V) 结构;多资产组合不在支持范围内 |
| 策略类的参数 | 把策略里的窗口、阈值变成可扫描的属性 | 在策略子类上声明属性后交给 optimize() | 参数调优与稳健性检查 | 可扫描的参数写法与范围定义以官方文档为准;扫描出的较优参数需样本外复核 |
在 Backtesting.py 里跑完一次回测,返回值不只有一个总收益数字,还有交易明细与图表可以交叉核对。下表按「拿到什么 / 怎么用」整理,字段名与结构以官方文档与实际输出为准。
| 拿到什么 | 含义 | 怎么用 | 注意点 |
|---|---|---|---|
| 绩效汇总 | 一次回测的指标集合,run() 直接返回 | 打印查看,或取值与其它区间对照 | 指标口径与字段命名以官方文档为准;换区间这些数字会整体变化 |
| 交易明细 | 逐笔交易记录,以表结构保存 | 核对每笔的进出时点、手续费与持仓变化 | 列名与结构以官方实现为准;笔数太少时统计指标本身不稳 |
| 交互式图表 | 价格、买卖点、权益与回撤的可视化 | 目视检查回撤段、信号密集处与成本拖累 | 交互式输出,做静态报告需自行导出;图上「很好看」不代表策略稳健 |
| 参数扫描结果 | optimize() 给出的多组参数对比 | 找连成一片的稳定区间,而不是单点峰值 | 扫描越密越容易过拟合;结果必须在样本外区间复核 |
| 收益与回撤指标 | 历史区间的统计量 | 先看区间与成本假设,再看大小 | 历史表现不构成未来收益预期,也不构成投资建议 |
| 信号与成交的差异 | 信号时点与成交时点之间的偏差 | 结合 trade_on_close 与默认成交假设解释差异 | 差异来源与默认假设以官方文档说明为准,不要凭感觉归因 |
下面几种情况在 Backtesting.py 里不会报错,但会让结果系统性偏乐观或偏悲观;它们都出在「引擎与策略的分工」上。
| 情形 | 你会看到什么 | 修正方向 | 为什么 |
|---|---|---|---|
| 用未来信息做判断 | 回测曲线异常平滑、回撤极小 | 只用 [-1] 及更早的值,或把信号整体后移一根 bar | 当下时点拿不到尚未发生的行情,用到了就是前瞻偏差 |
| 指标在 next 里逐 bar 重算 | 跑得慢,且结果与 init 版本对不上 | 指标统一放 init,用 self.I() 注册一次 | 重复计算不仅慢,还容易在两处写出不同参数 |
| 成本假设缺失 | 交易越频繁,收益越漂亮 | 补上手续费,并把滑点折算进成本 | 真实交易每次进出都要付成本,缺了这项等于给高频策略额外加分 |
| 只看总收益 | 一个数字很好,但说不清怎么来的 | 同时看交易笔数、回撤与区间 | 总收益是区间与成本的函数,脱离这两项无法复核 |
| 参数在样本内挑较优 | 样本内漂亮、样本外立刻变差 | 留出样本外区间,优先选连成片的参数区间 | 单点峰值多半是历史噪声,不是可复现的规律 |
| 拿示例数据下结论 | 示例跑得通,结论却无法推广 | 换成自己的标的数据重跑一遍 | 内置示例只用于验证代码路径,不代表任何市场表现 |
下表覆盖用 Backtesting.py 写一个单标的策略所需的最小集合;写法示例基于官方 README,具体签名与返回值以官方文档为准。
| API | 含义 | 典型写法 | 注意点 |
|---|---|---|---|
self.data.Close | 当前标的收盘价序列 | 作为指标输入传入 self.I() | 另有开高低与成交量列,列名以官方实现为准 |
self.I() | 注册并预计算指标 | self.ma = self.I(SMA, self.data.Close, 10) | 只在 init 里注册;注册后按索引取值 |
self.buy() | 提交买入 | 在满足条件的 bar 上调用 | 是否成交、以什么价格成交由引擎决定 |
self.sell() | 提交卖出 | 同样在 next 中按条件调用 | 与 exclusive_orders 配合时要确认订单互斥行为 |
self.position | 当前持仓状态 | 判断是否已有仓位、是否多头或空头 | 用它做加仓判断时要注意与订单互斥设置的配合 |
self.position.close() | 平掉当前仓位 | 反向信号出现时调用 | 平仓同样会产生成本,需计入假设 |
self.equity | 当前权益(含未平仓浮盈亏) | 用于观察权益曲线或做风控判断 | 不是已实现收益;把它当收益结论会误判 |
crossover() | 判断两条序列是否发生交叉 | crossover(self.ma1, self.ma2) | 来自 backtesting.lib,属工具函数而非引擎能力 |
Backtesting.py 的能力边界和它的架构一样清晰:为「单标的 + 写得清楚」而生,超出这个范围就该换工具。
| 场景 | 适合吗 | 说明 | 更合适的方向 |
|---|---|---|---|
| 单标的均线 / 形态信号验证 | 适合 | Backtest + Strategy 两件套就能写完,接口少、可读性高 | — |
| 策略原型与教学演示 | 适合 | 几十行代码跑通完整流程,适合讲清「信号 → 成交 → 统计」的链路 | — |
| 小范围参数扫描与稳健性检查 | 适合 | 内置优化器 + 热力图即可观察参数邻域是否成片 | 网格规模过大时需分批运行 |
| 多资产组合与权重分配 | 不适合 | 架构面向单标的,组合层面的资金分配不在支持范围内 | 选择支持组合回测的框架,或自行拆分标的分别回测后汇总 |
| 分数股 / 精细资金管理 | 不适合 | 不做分数股交易,资金较小或需要按比例建仓时会有偏差 | 改用其它支持分数股假设的框架 |
| 高频与撮合细节研究 | 不适合 | 成交假设做了简化,不建模排队、部分成交与冲击成本 | 需要更细撮合模型时选低层事件驱动框架 |
用示例数据把「信号 → 成交 → 统计」这条链路跑通,确认每一环都能解释清楚,再替换成自己的标的与参数。顺序反了容易把数据问题误判成策略问题。
区间、手续费、成交时点是结论的一部分。换掉其中任一项,绩效数字都会变,只报收益率等于不给复核条件。
需要多资产组合、分数股或更细的撮合模型时,这套架构不是「加几行代码」能补的,换到对应的框架比打补丁更省时间。
init 在回测开始前调用一次,用来预计算指标;next 每根 bar 调用一次,用来判断信号并下单。判断标准很简单:只做「准备」的写进 init,涉及「当前时点该不该交易」的写进 next。两者的调用细节以官方文档为准。
它以逐 bar 迭代的方式运行:每个 bar 调用一次 next,因此更接近轻量级事件驱动写法,而不是一次性整块矩阵运算的向量化框架。这决定了它的可读性优势,也决定了它不适合超大规模的参数与标的批量运算。判断口径以官方实现为准。
self.I() 到底做了什么?在 Backtesting.py 中,它把指标函数与输入数据注册到策略上,在回测开始前统一计算一次,之后可以在 next 里通过注册后的变量按下标取值。这样做的好处是既避免逐 bar 重复计算,也让指标与信号的取值位置有明确约定。返回结构与内部实现以官方文档为准。
可以。数据与成本假设在 Backtest 一侧,交易逻辑在 Strategy 一侧,同一个策略类可以换标的、换区间、换参数重复使用;同理,同一个标的也可以换不同策略类做对照。边界在于策略类只能拿到引擎提供的数据与仓位视图,拿不到的字段以官方文档为准。
正常。手续费直接削减每笔交易的净收益,成交时点决定「看到信号」与「实际成交」之间隔了几根 bar,两者都会改变绩效数字。所以对照两版结果前,要先确认这两项是否一致;假设差异与默认行为以官方文档说明为准,本页不给出具体的收益差异数值。
多资产组合与分数股交易不在支持范围内,这是架构层面的边界而不是配置问题;实盘执行也不在它的职责里,它只负责回测与统计。需要组合或实盘时,通常是换框架、或在回测结论确认后自行对接交易通道,并自行评估风险。能力范围以官方 README 与文档为准。