84,120 做多。止损设在 84,036,止盈设在 84,271。下一分钟的 K 线来了:开盘 84,118,最高 84,290,最低 84,010,收盘 84,240。
两个价位都落在这根 K 线里。止损触发了,止盈也触发了,而 OHLC 这 4 个数字里,没有任何信息能说明谁先发生。你的回测还是给出了一个结果。在循环里的某个地方,有一行代码做了决定——很可能你写下它时,根本没把它当成建模假设。
在我见过的短周期策略里,这是制造虚假业绩的最大来源,排在手续费和滑点前面,因为它看起来不像一个假设,而像是普通的程序逻辑。
错误做法一:让 if 分支顺序替你决定
常见代码大致是这样:
if bar.high >= target:
exit(target, "tp")
elif bar.low <= stop:
exit(stop, "sl")
没人主动选择“先判定止盈”。只是止盈判断先写了下来,因为那是顺利获利的路径,也是当时你最先想到的。把两个分支调换,权益曲线就变了;光这一点就该让你意识到,策略的 P&L 有一部分取决于你在编辑器里的输入顺序。
我用 BTCUSDT 永续合约跑了一个刻意选得很普通的均值回归剥头皮策略,数据是 3 个月的 1 分钟 K 线,共 4,812 笔交易,止损和止盈分别设为入场价的 0.10% 和 0.18%。信号相同,手续费相同,只改变了平局时的判定规则。
八分之一的交易左右了整个结果。这就是止损止盈距离很近时的算术结果:那些有歧义的交易,正是价格双向波动的交易,也往往是最值得关注的交易;每一笔带来的收益差,都可能等于止损到止盈的全部距离,取决于那次“抛硬币”的结果。12.6% 的交易 × 0.28% 的价差,相当于每单位样本有 3.5% 的名义总成交额受此影响,远远超过策略本身的优势。
这个比例取决于 K 线周期相对于止损止盈间距的大小;K 线周期越长,问题就会迅速恶化。止损、止盈和信号都不变,只重新采样数据:
| K线周期 | 同一根 K 线内触及两个价位的交易占比 | Sharpe(先判定止盈) |
|---|---|---|
| 1s | 0.3% | 0.91 |
| 1m | 12.6% | 2.31 |
| 5m | 34% | 3.60 |
| 15m | 49% | 4.42 |
| 1h | 71% | 5.88 |
看看这张表真正说明了什么。K 线越粗,回测结果越好。研究者通常会觉得小时线更保守:噪声更少,也更不容易对市场微观结构过拟合。但如果 K 线内的判定规则总是对你有利,那么周期越长,只是给你一个更大的盒子,让你可以假定自己运气更好。在 1h K 线上,十笔里有七笔的结果纯粹由规则决定。这种回测测的不是策略,而是你把两个 `if` 语句的顺序写反复了 3,400 次。
错误做法二:假定止损按止损价成交
假设你修正了判定顺序:先触发止损,计入恰好 0.10% 的亏损再加 taker 手续费,于是觉得这下够严谨了。但还有两个地方不对。
第一个问题是,止损是触发条件,不是成交价。在 Binance USDⓈ-M 上,STOP_MARKET 订单一满足触发条件,就会变成市价单,然后吃掉订单簿上的可用挂单。在平静的一分钟里,滑点可能只有 1 到 2 个 tick。但真正触发止损的那一分钟——K 线实体有 40 点,下面还发生了连环强平——你要成交的那一侧订单簿恰恰最薄。我把止损触发价和逐笔成交数据匹配后发现,成交价中位数比触发价差 1.4 bps,第 95 百分位差 11 bps。止损只有 10 bps 时,尾部滑点会额外吞掉你以为自己设定的风险的十分之一。
第二个问题更隐蔽,而且是永续合约特有的:究竟哪个价格触发止损。Binance 的止损单默认由标记价格触发,而标记价格由指数价格加上平滑后的基差构成,并非该交易所的最新成交价。你的 OHLC 序列记录的是最新成交价。两者是不同的序列,而在触发止损的那些事件里,它们的偏离往往最大。
| 最新成交价(你的 K 线数据) | 标记价格(默认触发价) | |
|---|---|---|
| 来源 | 该交易所的成交记录 | 多个交易所的指数 + 基差 |
| 影线表现 | 完整波动 | 大幅平滑 |
| 常见偏离幅度 | 平静时 1–3 bps,连环强平时的一分钟内为 20–35 bps | |
| 对回测的影响 | 本不该触发的止损却触发了,反之亦然 | |
所以,最新成交价序列里出现 25 bps 的影线,回测可能就会判定你触发止损,但实时标记价格可能根本没有接近你的触发价 10 bps。或者反过来:指数价格变动时,你所在的交易所反应慢了一拍。如果把 workingType 设为 CONTRACT_PRICE,至少可以让实盘行为和数据保持一致。对研究者来说,这通常是合理选择,因为要如实模拟标记价格触发,就得在整个成交引擎里维护第二条价格序列。
我印象最深的一次是,有人把止盈从 0.18% 调到 0.21%,说是“改进”了策略。Sharpe 从 2.3 升到 3.1。策略并没有获得新的优势。只是止盈价移出了 1 分钟影线分布最密集的区域,于是落入歧义区、被代码悄悄判成获利的交易变少了。他们优化的其实是平局判定规则。
错误做法三:总是假定最坏情况,并把它称为保守
最直接的补救办法是悲观处理。如果两个价位都被触及,就按止损算。好了,再也不乐观了,可以上线了。
我以前也这么做。它比另一种做法好一些,但仍然不对,原因有两个。
它会淘汰本来没问题的策略。对 12.6% 的交易按悲观方式判定,让这个策略比按逐笔成交判定的 0.94 Sharpe 低了 2.1 个 Sharpe 点。如果真实值是 0.94,而你的规则只算出 0.18,你就会放弃这个思路,转而研究更差的东西。偏差达到 2 个 Sharpe 点的“保守”,不是保守,而是披着道德外衣的噪声。
更糟的是,它会扭曲优化结果。把采用悲观平局判定的参数扫描交给优化器,它就会学着避开歧义,因为歧义现在只会带来惩罚。它会倾向于更宽的止损和更近的止盈,或者选择止损止盈很少同时落入同一根 K 线的长周期数据,最后给出的参数优化的是你的成交规则,而不是市场。这和偏乐观的做法犯的是同一种错误,只是方向相反;在业绩报告里同样难以察觉。
我们会先看这条经验法则:如果 stop_distance + target_distance 小于该 K 线周期波幅的第 75 百分位数,那么 K 线内的假设对 P&L 的影响就大于你的信号。把这两个数都算出来。只要 4 行代码,却比任何其他单项检查都更能让策略评审叫停。
真正有效的做法
K 线内部的价格路径也是数据。去获取它;如果拿不到,就界定它带来的误差。
- 用你能拿到的最细粒度序列判定。 对相关分钟的 Binance aggTrades 来说,通常只有几百行数据,就足以彻底弄清楚:哪个价位先被触及,以及扫单以什么价格成交。你不需要整个回测都用逐笔数据,只需要处理有歧义的 K 线。我这个样本里,129,600 分钟中只有 606 分钟有歧义。下载量很小,不需要搭建一套基础设施。
- 如果拿不到逐笔数据,就把判定周期缩短 1 到 2 级。 信号用 15m K 线,离场用 1s 或 1m K 线判定。歧义比例会从 49% 降到不到 1%,剩下的部分小到可以如实忽略。
- 始终报告区间。 每个回测都跑两遍,分别采用乐观和悲观判定,然后把这两种 Sharpe 和按真实数据判定的结果并列打印。这个差值就是 K 线内的不确定性,应当和 Sharpe 本身的置信区间一起显示在业绩报告上。如果区间是 0.2–2.3,那么其中任何结论都不可靠。
- 把歧义比例作为核心指标追踪。 我们把它放在每张策略卡片最上方,和交易笔数、换手率并列。如果比例超过约 5%,那接受检验的就是离场逻辑,而不是入场逻辑。
- 把触发和成交分开建模。 按交易所实际使用的价格序列判断触发,再根据逐笔数据校准的滑点分布抽样成交价,而不是直接按触发价成交。
股票市场也有同样的问题,只是换了种表现形式。一只股票隔夜跳空,从 62.00 跌到 58.40,你设在 62.00 的止损不会按 62.00 成交,而会在开盘价以下的某个价位成交。如果日线回测把跳空止损的滑点记为 −$0.00,它会乐呵呵地告诉你,止损策略降低了回撤。事实并非如此。它只是从来没有在关键的那些交易日里接受检验。停牌也一样:止损实际成交在复牌集合竞价时,成交价不会体现在 K 线低点里。
这些都不是什么罕见情况。你只需要认识到,K 线是一种摘要,而止损止盈策略押注的是摘要已经丢弃的事件先后顺序。等纸面交易引擎终于开始用实时行情跑策略时,行情会告诉你事件的先后;它从来不在乎你把 if 语句的哪个分支写在前面。
← 全部文章


