2026年九月2日 · 微观结构

你的限价单没有成交:关于排队位置、做市回测,以及你虚构出来的 Sharpe

你的限价单没有成交:关于排队位置、做市回测,以及你虚构出来的 Sharpe

你上周把 notebook 发给我:信号相同、标的范围相同、同样是 14 个月的 BTCUSDT 永续合约数据。唯一变化是执行方式。你不再主动吃单,而是把买单挂在价差内侧 1 个 tick,Sharpe 就从 0.42 升到了 2.14。你问我这结果是不是真的,还是哪里出了问题。

确实出了问题。我想带你仔细看看问题出在哪里,因为这个 bug 就在一行代码里,但背后的教训远不止这一行。

这是从你的引擎里复制出来的成交规则:

if bar.low <= limit_price: fill(limit_price)

这段规则的意思是:如果这一分钟内市场有成交价达到或低于我的价格,我就按我的价格成交。它实际编码的只是价格触及了你的价位。触及不等于成交。两者之间隔着一条队列,而你的模型把队列当成了空的。

排在你前面的是谁

当你在 BTCUSDT 永续合约上以 84,120.0 挂出买单时,你会排到当时该价位所有挂单的末尾。在这个合约的最优档位,排在你前面的量通常在 4 到 30 BTC 之间,具体取决于时段;你的样本窗口中位数约为 12 BTC。你的订单只有 0.4 BTC。要让你的订单成交,卖方主动单就得以足够的总量打到这个价位,清掉所有比你先到的人,而且必须赶在该档位被撤空或市场价格向上离开之前。

所以,你的回测该问的不是“价格有没有到过 84,120.0”,而是“我的订单挂在那里时,是否至少有 12.4 BTC 的市价卖单在 84,120.0 成交”。这是截然不同的两种情况。在你的数据里,两者相差约 3 倍。

12.4 BTC触及时排在你前方的中位挂单量
100%你的回测假设的成交率
31%触及后实际清空你前方队列的比例
4,180笔交易最终只剩约 1,300 笔

3 种成交规则,3 种不同策略

我用同一信号、相同入场,套上 3 种执行模型重新跑了一遍。alpha、手续费和资金费率都相同,变化的只有成交逻辑。

成交规则成交笔数成交后 60s 平均边际收益Sharpe
触及:low <= limit4,180+2.6 bp2.14
严格穿越:low < limit - 1 tick1,712+0.4 bp0.61
按价位成交量模拟队列1,306+0.9 bp0.77

中间这一行是大家最先想到的粗略修正:只有当市场价格严格穿过你的价位时才算成交,理由是价格既然越过了你,就一定消耗了你的挂单。方向上没错,也能戳破大部分幻想。但它还有个相当严重的偏差,稍后我会解释。

底下一行才是你该实现的方案。它不需要 L3 行情,也不需要逐笔重建订单簿。你手头的数据已经够用了。

用 aggTrades 就能搭建的队列模拟

拉取聚合成交流,而不是 klines。每条成交记录都带有价格、数量、时间戳和 maker 方向标记,可以据此判断主动方是买方还是卖方。这些信息足以模拟你自己的被动挂单:

  1. 你的订单挂出时,记录该价位的在簿挂单量。如果你只有 100ms 的订单簿行情,就用最近一次快照;与正在修正的误差相比,这点误差很小。
  2. 将 queue_ahead = resting_size 设为该挂单量。按悲观情况处理,假设你排在最后。除非是你自己创建了这个价位,否则你就是排在最后。
  3. 遍历成交流。每笔价格等于或低于你买价的主动卖出成交,都按成交量递减 queue_ahead。当它降到负数时,你就在该时间戳按你的价格成交。
  4. 如果价格离开你一个 tick,不要把队列重置为零,而要让它逐渐衰减。部分排在你前面的订单会在该价位失去热度时撤单,另一些则不会。与我的重建结果相比,价格离开触及状态后的每整秒衰减 30-40%,比假设队列完全不变或立即清空都更贴近实际。
  5. 如果你的策略会撤单并重新挂单,就要把重新挂单建模为新价位队列末尾的一笔新订单。很多人会漏掉这一步,剩下的幻想就藏在这里。

你可能会想反驳第 2 步。没错,有时你确实排在前面,因为你在该价位刚形成时就挂了单。可以,那就记录下来,不要凭空假定。记录挂单时前方的挂单量,让数据告诉你有多少订单真的来得早。在你的策略里,这个比例是 11%,因为你的信号是在价格变动之后才触发的,这意味着你加入的价位早已形成,上面也早就挤满了其他挂单。

你成交的往往是那些你宁愿没成交的单

接下来讲真正关键的部分,也解释了为什么严格穿越规则存在偏差。

想想你的买单在什么情况下会被完全吃掉。只有卖压大到足以吃完整个价位的挂单时才会发生。也就是说,按定义,这恰好是市场向下穿过你价格的时候。你最确定能成交的那些单,往往是成交后立刻处于不利位置的单。

按成交方式拆分你的成交记录,并计算 60 秒 mark-out:

成交类型成交占比60s mark-out
该价位成交,价格随后反弹38%+3.1 bp
该价位成交,价格持平21%+0.2 bp
价格穿过该价位至少 2 个 ticks41%−2.4 bp

你的朴素回测把这 3 类成交全都算了进去,还把它们全当成免费成交。严格穿越规则几乎只会给你第 3 类,这就是它的边际收益为什么比队列模拟还低。两者都不对。队列模拟能给出更真实的组合,而这个组合就是策略的全部关键:被动执行让你赚取价差,同时也让你承担不利选择;两者之间的比例才是策略的真实表现。

老牌股票交易台的说法依然成立:maker 订单相当于你卖给市场的一份免费期权。当这份期权对别人有利可图时,对方就会行权。你的回测收了期权费,却忘了期权还有一条收益支出曲线。

没成交的交易改变的是策略本身,而不只是成本

这一点我最希望你认真想想。taker 执行模型做得不好时,你仍会得到正确的交易,只是价格不对,修正手续费就能解决大部分问题。maker 执行模型做得不好时,你得到的会是完全错误的一组交易。你 4,180 笔入场里,约有 2,900 笔从未发生。其中一些本来是你的最佳信号,出现在先急涨后反转的 K 线上;这种形态恰好意味着市场已经跑远,没等到你成交就离开了。

所以,你需要为未成交分支加入真实逻辑。信号失效时入场单还没成交,策略会怎么做?追价用 taker 订单成交,支付价差和冲击成本?把订单挂得更低,接受不同的入场基准?放弃这笔交易,保持空仓?每种选择都会带来实质不同的权益曲线,没有一种是“假设已经成交”。在我们的测试中,加入诚实的追价规则(未成交 20 秒后主动吃单,滑点上限为 3 bp),找回了约三分之一的漏失交易,也弥补了朴素模型与队列模拟 Sharpe 差距的大约一半。这是个很有意思的结果,只有当成交模型足够真实、让这个问题真正有意义时,它才会显现出来。

简单的合理性检查,只要 10 分钟:拿出实盘模拟交易日志和同一时间窗口的回测,比较成交率,不要比较 PnL。如果回测里挂单的成交率是 100%,而模拟盘只有 34%,你比较的就不是策略,而是成交模型出了 bug。先修好这个,再看任何收益数字。

再补充两件小事

post-only 拒单。如果你用 post-only 来确保 maker 费率档位,而订单簿在你做出决定到交易所确认之间发生了变化,订单就会被拒绝,而不是留在簿上。在我们的模拟盘日志里,正常时段 BTCUSDT 的尝试中有 3-6% 会被拒;美国 CPI 数据公布后的那一分钟,比例超过 15%。被拒订单既不是成交订单,也不是仍在簿上但未成交的订单;它是一笔从未存在过的交易。如果你的回测没有这个状态,那么你最关心的市场环境里,交易数就会被高估。

自成交防护和你自己的市场影响。你下 0.4 BTC 的单,不足以推动 BTCUSDT,所以暂时不用管。但你提过想把策略用在一只中盘山寨币永续合约上,那里的最优档名义金额常常不到 $15k。在那里,你的订单确实会占队列中相当大的比例,而你记录的前方排队量也包含了你上一笔订单带来的影响。一旦你的下单量超过在簿量的约 10%,模拟就得考虑其他参与者会对你作出反应。说实话,到那个阶段,我会更相信模拟盘,而不是你我能写出的任何模拟。

用队列模拟重新跑一遍,然后把按成交类型拆分的 mark-out 表发给我。如果反弹那一档仍然贡献了大部分边际收益,而穿越那一档没有把它全部吃掉,你或许真的有值得放进模拟盘验证的东西。如果策略表现全靠那 2,900 笔你根本不可能成交的单子撑着,那最好现在就发现,总比看着模拟账户连续 4 周都没按 notebook 里的承诺运行要好。

限价单排队位置不利选择回测加密货币期货
← 全部文章