函数名叫 enter_pack。这就该是警告了。一个研究 agent 写了一套 BTC 期现套利:永续比现货贵出超过 8 个基点时,买入现货、做空永续,然后坐着不动。资金费率和基差会把剩下的事做完。回测在同一毫秒、按中间价、再加一跳礼貌滑点,把两条腿都成交了。权益曲线是一段楼梯。没人会说它刺激。这正是卖点。
周一,我们把它推上了模拟盘。
周一:总会成交的组合
第一个信号在 11:17 UTC 落地。屏幕上的基差是 9.4 bps,永续偏贵。回测里,enter_pack 返回两笔成交,残差为 0。模拟盘上,它返回的是现货在卖一成交了 0.40 BTC,以及一笔挂在永续上、并未成交的空单。
现货付了 4 bp 的 taker 手续费,外加大约 1 bp 的价差。我们想要的永续价格还在盘口上,然后就不在了。20 秒后,报价基差变成了 5 bps,因为我们刚刚吃了现货。40 秒后永续成交了,已经是行情走完之后。我们组合成交了。我们组合成交了,但基差已经不够覆盖进出成本。
回测会记一笔「+9.4 bps 持有收益,残差 0」。成交记录上是 4 bps 的现货 taker 成本、40 秒没有对冲的多头,以及大约 5 的入场缺口。我只有 1 个样本,却仍然对那段楼梯过于自信。
周二:我们打开了那个函数
enter_pack 接收一组腿和一个目标中间价。它循环,按 mid * (1 ± slip) 成交每一条腿,然后写入一行组合持仓。没有第二个时间戳。没有「第二条腿不存在」的分支。单元测试断言的是 residual == 0。
那是一种让你觉得自己像个成年人的测试。它永远不会失败。
我们把组合拆成两张各自有状态的订单:已提交、部分成交、已成交、已取消。未完成变成了一等对象。历史回放仍然没有真实盘口,于是我们作弊。用从周一等待时间里抽一次的结果来延迟第二腿成交(均值 26 秒,有一条我不喜欢的尾部),再按那个窗口里现货的走幅去漂移它的价格。粗糙,但午饭前楼梯上已经长出了缺口:有的组合以 3 bps 入场,有的组合从未成为组合。
周三:3 秒,然后平掉
如果第二条腿在 3 秒内没有上账,就把第一条腿平掉。我们本该从这里起步。听起来很保守,直到你给中止定价。平仓意味着你吃现货进去,再吃现货出来。2 笔 taker 手续费、2 段价差,没有持有收益,没有资金费率。在改写后的回放里,这些中止并不罕见。它们扎堆出现在基差看起来最「真实」的那些 K 线上,而那些 K 线通常正是已经有东西在现货里流动的时候。
到周三夜里,我们已经有 17 个模拟信号可以争论了。
| 回测,周一上午 | 模拟盘,截至周三 | |
|---|---|---|
| 入场 | 两条腿都在中间价,同一个时间戳 | 现货在卖一成交;永续 5–40 秒后,或者永远不成交 |
| 残差 | 永远是 0 | 17 个信号里有 10 个没有对冲 |
| 在手交易的手续费 | 2 笔 taker | 2 笔 taker,中止则是 4 笔 |
| PnL 驱动因素 | 基差 + 资金费率 | 等待期间现货怎么走 |
一位同事还有一本 notebook,把组合当成一只上市产品,BTC_CARRY,外加一笔固定 2 bp 的「执行税」。我喜欢那本 notebook。它产出的楼梯和 agent 做出来的一样。税是一个均值。孤儿单是一个分布。均值不会在流动性稀薄的一小时里被碾过;剩下的 0.40 BTC 现货可以。
周四:残留,然后咖啡
一旦还要排队等待,3 秒几乎把所有东西都中止了。我们把模拟盘放宽到 15 秒。周四 02:11 UTC,现货成交了,永续没有。我还处在「它会挂着,它会被吃掉」的哀悼阶段,就去煮咖啡了。
02:11:08 成交 BTCUSDT 0.40 买入
02:11:08 已提交 BTCUSDT-PERP 0.40 卖出,post-only
02:11:41 仍在挂单
02:26:00 残差盯市 −18.1 bps
BTC 在一段安静的窗口里跌了 18 bps。残差就是整笔交易。资金费率打印出来时,贷记的是我们并没有持有的永续。现货只是坐在那里做多。原始回测的隔夜路径里从未出现过单腿仓位,所以它从未问过我们问每一本其他账本的那个问题:这段残留能打出的最差一根 K 线是什么,以及一笔我们本不打算持有的仓位,止损该放在哪里?
那 7 个完整组合也不是英雄。等到第二条腿成交时,我们自己吃现货已经压到了指数上,入场基差比触发信号的那张快照差了 3–5 bps。8 bps 的阈值只是屏幕上的一个数字。
下次我们不会再做的事
我们不会再把多腿当成一个单一成交函数上线。同样的规则适用于永续-现货套利、日历价差,或者 agent 用两张限价单提交、然后在两边都按中间价「成交」时庆祝的看涨垂直价差。
- 每条腿的订单状态,包括部分成交。把组合当成两张订单的父单。
- 一个真正会触发的最大孤儿计时器,以及一条出场要付两段价差的平仓路径。
- 残差按盘口盯市,止损按残留规模来设。组合净头寸是错误的名义本金。
- 两条腿都上账之前,不贷记持有收益,不计提资金费率。未完成就是未完成。
- 凡是有两张订单的东西,拒绝把中间价对中间价当作默认。如果必须猜测,就往薄的那条腿猜得更差,往第二腿成交猜得更晚。
而且我们不会让 test_pack_is_atomic 进代码库。原子性是撮合引擎的属性,不是辅助函数的属性。BTC 现货和 BTC 永续是两本有时押韵的账本。模拟盘是它们停止押韵的地方,而这正是那段楼梯曾经有趣的唯一原因。



