功能
稳健性与过拟合
用基线、置换检验和修正夏普比率,检查回测是否只是运气
回测中的优势是真实的,还是搜索只是找到了最幸运的参数?VBT 在 Python 中通过基线、参数敏感性、打乱数据的置换检验,以及概率夏普比率和修正夏普比率,检验回测过拟合。
from itertools import product
data = vbt.YFData.pull("BTC-USD", start="2020-01-01", end="2025-01-01")
pairs = list(product(range(10, 55, 5), range(60, 220, 20)))
def best_sharpe(close):
sharpe = []
for fast_window, slow_window in pairs:
fast = close.rolling(fast_window).mean()
slow = close.rolling(slow_window).mean()
pf = vbt.PF.from_signals(
close, fast > slow, fast < slow, fees=0.001, freq="1D"
)
sharpe.append(pf.sharpe_ratio)
return pd.concat(sharpe, axis=1).max(axis=1)
real_best = best_sharpe(data.close.to_frame()).iloc[0]
returns = data.close.pct_change().iloc[1:]
shuffled = pd.DataFrame(np.tile(returns.values[:, None], 1000), index=returns.index)
shuffled = shuffled.vbt.shuffle(seed=42)
perm_best = best_sharpe(data.close.iloc[0] * (1 + shuffled).cumprod())
p_value = ((perm_best >= real_best).sum() + 1) / (len(perm_best) + 1)
print(round(real_best, 3), round(perm_best.median(), 3), round(p_value, 3))1.406 1.162 0.154真实比特币价格上的最佳交叉策略夏普比率达到 1.41。在打乱收益重建的价格上搜索同样 72 组参数时,15% 的情况找到了更好结果。p 值为 0.15,意味着搜索无法证明交叉择时在比特币本身上涨之外增添了价值。
fast = vbt.MA.run(data.close, window=[f for f, _ in pairs], short_name="fast")
slow = vbt.MA.run(data.close, window=[s for _, s in pairs], short_name="slow")
pf = vbt.PF.from_signals(data, fast.ma_above(slow), fast.ma_below(slow), fees=0.001)
best = pf.sharpe_ratio.idxmax()
print(round(pf.deflated_sharpe_ratio[best], 3), round(pf.prob_sharpe_ratio[best], 3))0.995 0.737修正夏普比率给出 99.5% 的概率,认为最佳组合真实夏普比率超过 72 次无技巧试验中最幸运者可能达到的水平,但它未与持有比较。概率夏普比率则将同一组合与买入并持有比特币比较,认为胜出的概率为 74%。每项检验回答不同问题,因此单个检验往往不够。
选择稳健性检验
不同检验帮助解释不同弱点。您可以使用相同策略函数,在带标签的 pandas 表格中比较结果。
| 您想检查什么 | 比较什么 |
|---|---|
| 入场择时是否增添价值 | 买入持有与随机入场 |
| 精确设置是否驱动结果 | 相邻参数 |
| 额外规则是否有帮助 | 逐个开启和关闭过滤器的策略 |
| 执行假设是否驱动结果 | 信号延迟和更高成本 |
| 搜索是否可能偶然找到类似优胜者 | 在打乱数据上重复搜索和调整后的夏普指标 |
每次比较都应保持日期、规模和执行假设一致,除非它们正是测试对象。除收益外,还应检查回撤及交易次数和敞口,了解发生了什么变化。
基线
每个结果都需要一个待超越的参照。vbt.PF.from_holding 一次买入后持有,vbt.PF.from_random_signals
则以随机种子随机入场和退出,可使用固定信号数量或每根 K 线的概率。
hold = vbt.PF.from_holding(data, fees=0.001)
n_trades = pf.trades.count()[best]
rand = vbt.PF.from_random_signals(data, n=[n_trades] * 1000, seed=42, fees=0.001)
print(n_trades, round(hold.sharpe_ratio, 3), round(rand.sharpe_ratio.median(), 3))8 1.126 1.015最佳组合超过了直接持有,也超过全部 1,000 个同样进行 8 笔交易的随机投资组合。但它仍是从 72 种策略中事后选出的一个,不能就此定论。上方置换检验在每段打乱历史上重复整个搜索,考虑了搜索本身的影响。
参数敏感性
稳健策略应有表现平台:相邻参数表现接近最佳组合。被较差结果包围的孤峰通常意味着一笔幸运交易或一段幸运时期。按参数排列夏普比率,即可看清结果属于哪种情况。
pf.sharpe_ratio.unstack("slow_window").iloc[:5, :6].round(2)slow_window 60 80 100 120 140 160
fast_window
10 1.23 1.17 1.37 1.32 1.17 1.29
15 1.25 1.12 1.37 1.16 1.17 1.17
20 1.30 1.14 1.22 1.15 1.22 1.25
25 1.21 1.24 1.41 1.20 1.32 1.27
30 1.02 1.19 1.32 1.20 1.20 1.2725 和 100 周围八个邻近组合得分为 1.14 至 1.32,因此该结果并非孤立尖峰。同样排列也适用于消融检验:将策略各过滤器作为开关参数传入,比较启用和禁用时的策略。参数优化页面介绍热图与大型参数网格。
检验哪些过滤器有帮助
额外过滤器可能仅通过减少交易,就改善主要收益指标。消融检验显示每条规则的贡献。为每种变体构建一列信号,在相同价格、退出和手续费下共同回测。
这个小示例使用虚构价格和成交量,比较核心入场规则分别搭配趋势过滤器、成交量过滤器和两个过滤器的情况。所有信号均延后一个 K 线再交易。
close = pd.Series([100, 102, 105, 103, 99, 101, 106, 110, 107, 102, 104, 109, 114, 111, 106, 108, 115, 118, 113, 110])
volume = pd.Series([90, 110, 80, 120, 90, 130, 140, 80, 110, 90, 120, 80, 140, 100, 90, 120, 80, 130, 110, 90])
core = close > close.shift(1)
trend_filter = close > close.rolling(4).mean()
volume_filter = volume >= 100
entries = pd.DataFrame({
"Core": core,
"Trend": core & trend_filter,
"Volume": core & volume_filter,
"Both": core & trend_filter & volume_filter,
}).vbt.signals.fshift(1)
exits = (close < close.shift(1)).vbt.signals.fshift(1)
variants = vbt.PF.from_signals(close, entries, exits, fees=0.001, freq="1D")
comparison = pd.DataFrame({
"Return [%]": variants.total_return * 100,
"Max drawdown [%]": variants.max_drawdown * 100,
"Closed trades": variants.trades.status_closed.count(),
})
comparison.round(2) Return [%] Max drawdown [%] Closed trades
Core -16.28 -16.28 4
Trend -20.11 -20.11 3
Volume -16.28 -16.28 4
Both -14.32 -14.32 3准确观察各规则贡献:仅成交量过滤器不改变结果,而两个过滤器一起使用在此样本上产生最小亏损。使用同样比较测试更大策略中的入场规则,将过滤器开关作为参数,并重复前向滚动测试区间。
执行敏感性
通过延迟信号和提高成本,对执行进行压力测试。比较策略面对一根 K 线延迟、两根 K 线延迟和不同手续费水平的反应。每个延迟为一列,每个手续费水平为一个参数值,因此整个网格作为一次回测运行。
entries = fast.ma_above(slow)[best]
exits = fast.ma_below(slow)[best]
delays = pd.Index([0, 1, 2], name="delay")
pf_exec = vbt.PF.from_signals(
data,
pd.concat([entries.vbt.signals.fshift(d) for d in delays], axis=1, keys=delays),
pd.concat([exits.vbt.signals.fshift(d) for d in delays], axis=1, keys=delays),
fees=vbt.Param([0.001, 0.002, 0.005], name="fees"),
)
pf_exec.sharpe_ratio.unstack("fees").round(2)fees 0.001 0.002 0.005
delay
0 1.41 1.40 1.38
1 1.33 1.33 1.31
2 1.32 1.31 1.29只有 8 笔交易时,即使 0.5% 手续费也几乎不影响结果,而一根 K 线延迟的代价超过全部手续费。交易越频繁,手续费每增加一步的成本越大。回测真实性页面介绍滑点、订单延迟和其他执行假设。
扰动与置换数据
只在调参所用的准确价格历史上有效的策略十分脆弱。下面每种方法以不同方式改变数据:
- 打乱收益率。
.vbt.shuffle(seed=...)分别打乱各列,如第一个示例,在保留收益分布的同时破坏原始顺序。 - 噪声。 向价格添加少量随机噪声,例如用
np.random.default_rng(seed)并按每根 K 线范围缩放,检验信号是否依赖精确价格水平。 - 镜像价格。
data.mirror_ohlc()将上涨变成下跌,并保持各根 K 线高低价一致,检验仅做多结果是否依赖市场方向。 - 分块自助法。
vbt.Splitter.from_n_random抽取随机窗口,shuffle_splits配合replace=True对已有窗口重采样,同时保留块内价格顺序。 - 合成价格。
vbt.GBMOHLCData和vbt.RandomOHLCData根据指定漂移和波动率生成固定随机种子价格路径,详见合成数据页面。
每种变体增加一列,因此数百段扰动历史可以作为一次回测运行。
统计检验
收益率访问器和投资组合都能计算 López de Prado 与 Bailey 提出的夏普比率检验:
| 方法 | 回答的问题 |
|---|---|
sharpe_ratio_std | 在给定样本长度、偏度和峰度下,夏普比率有多大不确定性 |
prob_sharpe_ratio | 真实夏普比率超过买入持有等基准的可能性有多大 |
deflated_sharpe_ratio | 夏普比率超过已考虑跨列搜索影响的调整基准的可能性有多大 |
投资组合的 prob_sharpe_ratio 与自身基准比较,默认基准为买入并持有交易资产,除非传入 bm_returns。如需改为与零夏普比率比较,请在
pf.get_returns_acc(bm_returns=False) 上调用。
deflated_sharpe_ratio 将每列视为一次试验,因此应在您测试过的每种组合的收益率上调用,而非只在获胜者上调用。单列返回 NaN,分组投资组合则将每组视为一次试验。
回测过拟合概率
VBT 提供切分器和带标签结果,用于在研究流程中计算回测过拟合概率(PBO)。使用一半折叠训练、一半测试,在组合切分上比较所有参数组合的样本内外结果。可通过
vbt.Splitter.from_purged_kfold
构建切分,详见前向滚动与交叉验证。PBO
是训练半区最佳组合在测试半区排名低于中位数的切分占比。接近或高于 0.5 意味着这种选择不优于随机挑选组合。
相关试验
修正夏普比率假设试验相互独立。同一策略的相邻参数高度相关,因此 72 组参数网格实际相当于少得多的独立试验。置换示例通过在每段打乱历史上重新运行同一相关参数网格,对此形成补充。
教程
会员专属的交叉验证(英文)教程将每期获胜参数带入下一期,并在样本外与全部其他选择比较。
版权所有 © 2021–2026 Oleg Polakow。保留所有权利。
本站内容与文档供使用和评估 VectorBT PRO 以及教育用途。任何其他用途,包括开发或支持竞争产品或服务,均须事先取得书面同意。