Backtesting.py / 回测审计
Backtesting.py 回测可信度审计:未来函数、过拟合与成本怎么查
回测的价值不是证明策略一定赚钱,而是尽早发现它为什么可能失效。本文给出 11 项可执行的审计检查,并逐项附上本站实测结果:98 组参数在样本内 89 组赚钱、样本外 0 组赚钱;同一策略只改执行时点差 33.94 个百分点;用了未来信息的版本「更好看」28.70 个百分点。每项都写明怎么查、判据是什么、以及哪些只能做方法说明。
回测做完,Backtesting.py 的结论该用哪 11 项去查?
下表把 Backtesting.py 回测的可信度拆成可执行的检查项:每一项都写清「怎么查」,并给出本站实测的结果。前 6 项属于即使不写策略也能查的通用项,后 5 项需要你回到自己的策略代码。
| 检查项 | 怎么查 | 合格判据 | 本站实测结果 |
|---|---|---|---|
| 未来函数 / 前视偏差 | 指标在「截断到第 i 根」与「全序列」两种算法下是否一致 | 两种算法逐根一致 | 刻意用全序列信息后,收益「改善」28.70pp,且无任何报错 |
| 参数过拟合 | 样本内选参 → 样本外复跑,比较名次与收益 | 样本外仍为正、名次不塌陷 | 样本内收益最高的一组 461.23%,样本外 -22.86%;98 组里样本外正收益 0 组 |
| 交易成本 | 把佣金/印花税/过户费按你的口径重跑一遍 | 结论在合理费率区间内不反转 | 成本从 0 加到 20bp,收益 -64.44% → -78.22%,结论不变 |
| 执行时点假设 | 分别在默认与 trade_on_close=True 下跑同一策略 | 差异可解释且已声明 | 同一策略 -69.16% 与 -35.22%,差 33.94pp |
| 基准对比 | 确认「买入持有」的起点与口径 | 基准起点与策略起点口径一致并写明 | 把均线窗口换成 200 日后,基准从 117.19% 变 187.65% |
| 统计显著性 | 看交易笔数、SQN、盈亏比,而不是只看胜率 | 笔数足够且盈亏比 > 1 | 样本里出现 3 笔交易就下结论的案例(买入持有 +71052.85%) |
| 样本代表性 | 同一套参数换 5~10 个标的各跑一遍 | 不依赖单一标的 | 6 只 A 股里 0 只跑赢买入持有,5 只亏损 |
| 样本挑选偏差 | 检查是否只展示表现居前的标的 | 所有样本结果都公开 | 只贴宁德时代(+319.46%)就会掩盖其余 5 只的亏损 |
| 数据质量 | 查重复日期、缺日、价格越界、复权口径 | 体检项全部通过或已说明 | 港股不复权序列出现 12 行收盘价越界 |
| 规则可成交性 | 统计成交是否落在涨跌停/一字板/停牌日 | 已过滤或已声明近似 | 6 只样本有 4 笔买在涨停日、1 笔卖在跌停日 |
| 结论表述 | 是否同时给出基准、成本、区间与局限 | 四项齐全 | 本文所有数字均标注区间、成本与其他假设 |
Backtesting.py 有哪些未来函数陷阱?为什么它不会提醒你?
先说明一个容易误解的事实:backtesting.py 0.6.6 没有内建的未来函数检测——全包源码检索没有 LookaheadError 之类的机制。前视偏差不会报错、不会警告,只会让结果变得更好看。
| 常见写法 | 是否构成前视 | 为什么 | 怎么发现 |
|---|---|---|---|
| 指标用全序列统计量(如 (收盘 - 全序列均值) / 全序列标准差) | 是 | 每根 bar 都用到了未来的均值与波动 | 截断重算:两种算法结果不一致即命中 |
| self.I() 里对整段数据做归一化或分位数 | 是 | 归一化基准来自未来 | 同上;把全序列换成扩展窗口(expanding)再跑一次 |
| 信号与成交用同一根 bar 的收盘价(trade_on_close=True) | 风险点 | 决策与成交发生在同一价格上,隐含「能按刚看到的收盘价成交」 | 两种时点各跑一次,看差异是否可接受 |
| 在 next() 里访问 self.data.Close[-1] 做判断后立即下单 | 否 | 订单要等下一根 bar 才处理,属正常写法 | 确认 trades 的 EntryTime 晚于信号日期 |
| 多时间框架用 resample 聚合,未设 label='right' | 是 | 官方注释明确指出:默认 label='left' 会产生前视偏差 | 对照官方 resample_apply 文档的等价写法 |
| 用「本期已知的未来数据」做筛选(如按年报公布前的净利润选股) | 是 | 信息在现实中的可得时点晚于回测用到的时点 | 为每个字段记录现实发布日期,再对齐到交易日 |
# 截断重算:同一指标在「全序列」与「截断到第 i 根」下必须一致
def assert_no_lookahead(close, build, tail=30):
full = build(close)
bad = []
for i in range(len(close) - tail, len(close)):
part = build(close.iloc[:i + 1])
if abs(float(part[-1]) - float(full[i])) > 1e-9:
bad.append(i)
return bad # 实测:全序列 z-score 会命中,扩展窗口版本不会
Backtesting.py 参数优化后样本外失效,怎么判断是选参失误还是策略失效?
这两个结论的处理方式完全不同:前者回去重选参数,后者应该放弃这套逻辑。判据是「样本外的整体分布」,而不是某一个参数组合的表现。
| 观测项 | 样本内 2018-01-02~2022-12-30 | 样本外 2023-01-03~2026-09-18 | 说明 |
|---|---|---|---|
| 参数组合数 | 98 组(n1 3~30、n2 15~75,约束 n1<n2) | 同一批 98 组 | 网格大小一致,可比 |
| 正收益组合数 | 89 / 98 | 0 / 98 | 样本内「随便挑都赚」,样本外「怎么挑都亏」 |
| 收益最高的组合 | n1=30, n2=51 → +461.23%(17 笔) | 同一组 → -22.86%(19 笔) | 参数没变、代码没变 |
| 该组合在样本外的排名分位 | — | 98.0% | 它仍是样本外居前的一档,说明不是「挑错了」 |
| 样本内/外收益相关性 | Spearman 0.540 / Pearson 0.534 | 有正相关,但不足以挑出赢家 | |
| 样本外收益最高的组合 | n1=30, n2=57(样本内只有 +243.38%) | 仍为 -20.78% | 样本外挑选出的那一组也在亏 |
| 同期买入持有 | +148.50% | -19.95% | 样本外标的自身也在跌,必须分开看 |
Backtesting.py 的成本要设到多少,结论才不会被推翻?
成本的意义不是「让数字更保守」,而是找出结论的稳定区间:把费率从 0 一路加上去,看结论在什么位置翻转。下面是同一策略的 8 组对照(贵州茅台 600519)。
| 成本设置 | 区间收益 | 费用合计 | Sharpe | 是否推翻「跑输」结论 |
|---|---|---|---|---|
| 零成本 | -64.44% | 0 | -0.448 | 不推翻(买入持有 +107.61%) |
| 仅卖出印花税 5bp | -67.00% | 5243 | -0.482 | 不推翻 |
| 佣金 2.5bp 双边 | -66.97% | 5171 | -0.482 | 不推翻 |
| 佣金 2.5bp + 印花税 5bp(A 股常用) | -69.20% | 10119 | -0.515 | 不推翻 |
| 佣金 5bp + 印花税 5bp | -70.64% | 14875 | -0.536 | 不推翻 |
| 佣金 10bp + 印花税 5bp | -73.63% | 23468 | -0.587 | 不推翻 |
| 佣金 20bp 对称 | -78.22% | 34545 | -0.678 | 不推翻 |
| 佣金 20bp + 印花税 5bp | -79.46% | 37907 | -0.707 | 不推翻 |
Backtesting.py 的回测统计里哪些口径会悄悄改变结论?
库输出的 33 项统计都很方便,但每一项都有口径。下面是从 0.6.6 源码核验出的关键口径,以及两项实测会直接改变结论的差异。
| 字段 | 0.6.6 的实现口径 | 对结论的影响 | 你要做什么 |
|---|---|---|---|
| Buy & Hold Return [%] | 从「指标预热结束后的首根 bar」起算,不是数据首根 | 实测差 70.46pp(200 日均线策略:187.65% vs 117.19%) | 换窗口后基准会变,报告中写明起点 |
| Exposure Time [%] | 持有仓位的 bar 数 ÷ 总 bar 数 | 横盘标的与趋势标的不可直接比较 | 结合交易笔数一起看 |
| 年化交易日 | 周末 bar 占比 > 17% 用 365,否则用 252;周/月/年线用 52/12/1 | 加密货币(7×24)与 A 股(无周末)年化基数不同 | 跨市场比较前统一口径 |
| Sharpe / Sortino | Sharpe =(年化收益 − rf)/ 年化波动;rf 来自 Backtest 外部传入 | 风险口径不统一时排序不可比 | 记录 rf 取值 |
| Profit Factor | 用逐笔收益率求和:盈利和 ÷ |亏损和| | 与大额单笔盈亏强相关,笔数少时会剧烈波动 | 与交易笔数、SQN 一起看 |
| Win Rate [%] | 盈利交易笔数 ÷ 总交易笔数 | 高胜率可以同时是负期望——本次布林带反转平均胜率 40.4%、平均收益 -35.75% | 同时看 Avg. Trade 与盈亏比 |
| SQN | √n × mean(PnL) / std(PnL) | 对笔数敏感,是默认优化目标 | 报 SQN 时同时报笔数 |
| Alpha / Beta | 以买入持有为基准计算(非市场指数) | 「Alpha」不是行业常说的相对指数超额 | 不要直接当作券商报告里的 Alpha |
交易次数太少时,Backtesting.py 的结果还能信吗?
胜率、盈亏比、SQN 这些指标在笔数少时统计意义很弱。实测中有两类极端:一类 100+ 笔但胜率 34%,一类只有 3 笔却拿着「买入持有 +71052.85%」的对照。
| 案例(实测) | 交易笔数 | 胜率 | 收益 / 基准 | 能不能下结论 |
|---|---|---|---|---|
| 双均线 · 贵州茅台 | 123 | 33.3% | -69.16% / +107.61% | 笔数够,结论可信(策略确实无效) |
| 布林带反转 · 6 只均值 | 100(均值) | 40.4% | -35.75%(均值) | 笔数够,且胜率不低却仍亏——看盈亏比 |
| RSI(14) 反转 · 6 只均值 | 9(均值) | 72.9% | +71.47%(均值) | 笔数偏少,需看逐年分布 |
| 参数优化收益最高组 · 样本外 | 19 | — | -22.86% | 不宜据 19 笔否定整个策略族,需多标的验证 |
| FractionalBacktest · 内置 BTCUSD | 3 | — | -100.00% / +71052.85% | 不能下结论:笔数过少且基准量级失真 |
| 官方内置样例 · GOOG | 94 | 53.2% | +469.86% / +607.37% | 笔数够,但仍跑输基准 |
Backtesting.py 的样本挑选偏差怎么影响结论?为什么它最难自查?
同一套策略、同一区间、同样的成本,只改变「你选哪些标的」和「你决定展示哪一个」,结论可以从「策略赚钱」翻转成「策略全线亏损」。
| 标的(同策略同区间实测) | 策略收益 | 买入持有 | 交易笔数 | 单独展示会给人什么印象 |
|---|---|---|---|---|
| 宁德时代 300750 | +319.46% | +725.90% | 113 | 「策略翻三倍」——但实际跑输基准 406 个百分点 |
| 赛力斯 601127 | -22.33% | +149.16% | 116 | 「策略亏钱」 |
| 比亚迪 002594 | -33.15% | +318.78% | 124 | 「错过主升浪」 |
| 贵州茅台 600519 | -69.16% | +107.61% | 123 | 「在长牛股上亏 69%」 |
| 平安银行 000001 | -72.09% | +11.27% | 120 | 「策略几乎必然亏」 |
| 招商银行 600036 | -74.07% | +77.17% | 109 | 「策略在蓝筹上失效」 |
| 6 只整体 | 0 只跑赢买入持有 | 6 只全为正 | 平均 119 笔 | 「这套规则在 A 股上没有稳定优势」 |
回测与实盘之间还有哪些差距,Backtesting.py 管不到?
审计的目的不是让回测「像实盘」,而是把差距显式写出来。下面几项是库层面无法覆盖的,需要在结论里声明而不是靠参数调优弥补。
| 差距项 | 库的行为(0.6.6 核实) | 对结论的影响 | 处置建议 |
|---|---|---|---|
| 成交量与冲击成本 | 没有成交量模型,订单数量不受当日成交量限制 | 资金量越大偏差越大 | 限制单笔占比并写明假设 |
| 部分成交 | 订单按整数股一次性成交 | 大额订单会高估可执行性 | 拆分下单或降低单笔规模 |
| 涨跌停与停牌 | 不做可成交性判断 | 出现现实中无法成交的记录 | 见 A 股规则适配页的过滤写法 |
| 多资产与组合资金 | 单资产引擎;MultiBacktest 为各标的独立账户 | 无法验证组合层面的分散与再平衡 | 组合需求换支持组合的框架 |
| 做空与融券成本 | 支持空头,但没有融券费用与借券可得性模型 | 空头策略收益被高估 | 用 callable 佣金近似并声明 |
| 实盘执行与心理 | 不涉及,库本身不含券商下单通道 | 回测优势可能被执行延迟与情绪波动吞掉 | 把它当研究工具,而不是收益承诺 |
回测审计常见问题
backtesting.py 会不会提示我用了未来函数?
不会。0.6.6 全包源码检索没有内建的未来函数检测机制;官方仅有的一处相关提示是 resample_apply 的注释——多时间框架聚合时要设 label='right',否则会产生前视偏差。因此前视必须靠自查(截断重算)来发现,具体行为以官方文档与你安装的版本为准。
样本外亏损,是不是说明参数选得不够多?
不是。本次实测中样本内 98 组参数有 89 组赚钱,样本外 0 组赚钱,而且样本内表现居前的那一组在样本外仍排前 2%——扩大网格只会找到更多样本内的巧合。判据是「样本外整体分布」:当几乎所有组合在样本外都亏,问题属于策略族失效,而不是选参失误。
回测收益很高但交易次数很少,怎么处理?
先把笔数说清楚,再决定要不要下结论。笔数少于约 30 笔时,胜率与盈亏比的置信区间会宽到无法判断,此时应改成「多标的 + 分段」验证。实测中一个 3 笔交易的案例挂着一份 +71052.85% 的买入持有对照,这种量级的组合不适合用来支持任何结论。
为什么我的回测在加上手续费后由盈转亏?
常见于两类策略:交易频繁(手续费按次累积)和优势本来就薄(零成本下只跑赢基准几个百分点)。实测港股腾讯 00700 双均线零成本 +5.03%、计入费用后 -20.91%,就是被成本直接推翻的案例。处理办法不是「调小手续费」,而是把费率设在你真实可能拿到的水平,再看结论是否仍成立。
胜率很高的策略为什么还是亏?
因为胜率不含盈亏比信息。实测布林带反转平均胜率 40.4%、平均收益 -35.75%;RSI 反转平均胜率 72.9%、平均收益 +71.47%——但两者都跑输各自的买入持有基准。看统计时建议把「胜率 + 平均每笔收益 + 盈亏比 + 笔数」四项一起看,字段口径以官方源码为准。
怎么用 Walk-forward 替代「一次性分段」?
做法是滚动窗口:用一段时间训练选参,用紧跟其后的下一段验证,然后整体前移再重复,最后把所有验证段的结果拼起来看。它比单次样本内/外切分更接近真实使用方式,也更难被单段行情带偏。本页的 98 组对照是一次性切分,属更简单但更弱的版本。
这套审计清单能保证策略赚钱吗?
不能,它的目标是尽早排除不可信的结论:能过审计只说明「没有发现明显自欺的迹象」。本文所有数字都来自 2018-01-02~2026-09-18 的有限样本,只用于说明方法差异,不预测任何标的的未来表现,也不构成投资建议。