一个回测能赚钱,和"你明天真的能按它赚到钱",中间隔着一条常被忽略的鸿沟。这篇文章讲的,就是回测里最常见的一种自欺:把"买不到"的样本也算了进去。
现象
一个策略在"次日收盘买入"的口径下表现良好:
次日收盘买入策略
平均收益: +1.82%
胜率: 61%
样本: 4,200
看起来既简单又可行——不用盯盘,不用抢单,第二天收盘价买入就行。很多人到这里就准备上线了。
根因
问题在于"收盘能买到"这个假设本身是假象。
如果某个标的在当日已经涨到当天价格上限(比如涨停),那么它在收盘这个时点同样买不到——不是"收盘价能不能成交"的问题,而是"这个标的现在根本没有可成交的卖盘"。挂单排在那里的资金根本轮不到你。
而回测通常怎么处理的?它默认所有信号标的都能在收盘价成交,于是把这批**"根本买不到"的样本**也一视同仁地算了进去。而这批标的恰恰是表现最好的那批(因为它们涨得最猛才涨停),于是回测收益被这些"幻影成交"抬高了。
把"买不到"的样本显式剔除之后,结论由正翻负,而且两个不同的时间窗口都一致——说明这不是偶然。
剔除当日无法成交样本后
平均收益: -0.34% (此前 +1.82%)
胜率: 47% (此前 61%)
解决
任何"可成交口径"的回测,都必须做两件事:
第一,显式剔除"买入日无法成交"的样本。 判断标准要贴合真实交易规则,而不是简单看"涨幅是否很大":
def can_buy_at_close(row):
"""买入日收盘时点是否真的可以成交。
以下情形视为不可成交(按需调整):
- 当日封住涨停/跌停(无可成交对手盘)
- 当日停牌
- 成交量为 0
"""
if row["suspended"]:
return False
if row["volume"] == 0:
return False
if row["close"] >= row["limit_up"]:
return False
if row["close"] <= row["limit_down"]:
return False
return True
executable = df[df.apply(can_buy_at_close, axis=1)]
第二,同时报告被剔除那一部分自身的表现。 这一步是很多人漏掉的,但它决定了你剔除得对不对:
dropped = df[~df.apply(can_buy_at_close, axis=1)]
print(f"可成交样本: {executable['ret'].mean():.4f} (n={len(executable)})")
print(f"被剔除样本: {dropped['ret'].mean():.4f} (n={len(dropped)})")
如果被剔除的样本表现特别好,那说明你剔掉的是"赢家",你的结论就要额外解释:这些赢家本来也买不到,所以它们的好处本来就不属于你——这没问题。但如果被剔除的样本表现平平、只是噪声,那剔除只是个去噪动作。
延伸与预防
可执行性的陷阱有一整个家族:涨停买不到、跌停卖不掉、停牌无法交易、流动性不足(挂单量远小于你的下单量)、滑点被忽略……它们的共同点是:回测假设了一个完美的成交环境,而真实市场只在部分时刻提供这种环境。
预防的方法是把可执行性当成回测的一等公民,而不是事后补丁:
- 在建回测框架时,就为每一笔成交记录一个"可成交性"字段;
- 报告结果时,**永远同时给出"理想口径"和"可执行口径"**两个数字;
- 对可执行口径,明确写出剔除了哪些样本、剔掉了多少比例。
一句话:回测的可执行性,取决于你剔除了什么,以及你是否诚实地报告了它。 一个不报告剔除规则的回测,无论数字多漂亮,都不值得信任。