订单簿回测成交对不上?NautilusTrader batch_deltas 与 F_LAST 排查
如果你用 NautilusTrader 的 OrderBookDelta 做 L2/L3 盘口回测,升级后发现 fills 或持仓数与旧结果对不上,先别调整策略参数。这里有两个不同的原因:batch_deltas 改变了撮合能看到的中间盘口状态;还有一个更隐蔽的风险——查询窗口切掉事件开头时,即使最后一条仍有 F_LAST,批处理也可能没有报错。
这是最值得先排查的地方。F_LAST 只说明一组增量到此结束,不能替你证明它从正确的位置开始。如果数据被时间范围或 filter_expr 从事件中间切开,一条带结束标记的 DELETE 仍可能组成“已闭合”的批次。NautilusTrader 2.0.0rc7 固定源码对应的官方文档明确写到了这种头部截断可能无法被检测。
我核对了 NautilusTrader v2.0.0rc7 的回放源码与上游测试,再用 Python 标准库 Toy Example 复算事件分组。排障时可以先按下文配置在自己的 Catalog 上对照两种回放,再结合附录理解 F_LAST 为什么会影响结果。
先别改策略:四步定位回测成交差异
这个排查顺序也适用于“升级后明明用了同一个数据 Catalog,结果却变了”的场景。
- 先固定基线:旧版与 rc7 的版本号、Catalog 内容、查询起止、标的精度、策略与订单设置都存档,别一边排查一边重新生成历史数据。
- 先检查窗口完整性:逐标的核对
F_LAST和首批事件的起始边界;尾部有闭合标志,不代表查询没有从中途切入。 - 再只改一个配置:在相同数据、相同策略、相同撮合设置下,显式比较
batch_deltas=True和False;找到第一笔不同的成交,再回到对应的盘口事件。 - 最后决定基准:把实际数据源的事件约定与回放语义对齐,别挑收益看起来更高的模式当成标准答案。
参考官方回放说明里的 BacktestDataConfig 形式,在现有回测工程中显式设置 batch_deltas=True;之后只把它改成 False 作为对照,其余 Catalog 路径和 instrument_id 保持一致:
data = BacktestDataConfig(
data_type=NautilusDataType.OrderBookDelta,
catalog_path="/data/catalog",
instrument_id=instrument_id,
batch_deltas=True, # rc7 默认:按完整事件回放
)
# 对照组保持其余配置不变,仅将 batch_deltas 改为 False
固定的 rc7 Rust 配置将 batch_deltas 的默认值设为 true;Python 配置的上游单元测试也覆盖默认、关闭与开启。False 是排障对照,不是修复任何历史数据质量问题的万能选项。
F_LAST 能发现缺少结尾,却未必发现缺少开头
假设同一个行情事件先 ADD 一个 999.95 卖价,然后 DELETE 它,且只有第二条携带 F_LAST。
| 输入窗口 | 实际剩下的记录 | 事件分组结果 |
|---|---|---|
| 完整 | ADD 999.95 → DELETE 999.95(F_LAST) | 正常闭合的两条记录 |
| 尾部被裁 | 只有 ADD 999.95(无 F_LAST) | 剩余未完成批次,结束时会报错 |
| 头部被裁 | 只有 DELETE 999.95(F_LAST) | 仍可闭合,但缺了起始记录 |
为什么第三行容易被放过?BookDeltasBatcher 的源码按 instrument_id 保留待处理增量,读到带 F_LAST 的记录就封装 OrderBookDeltas;它在输入结束后检查的,是“是否存在没收尾的批次”。开头少掉一条却保留最后一条,这个检查未必知道。
这里的“可闭合”只指 F_LAST 分组层面,不是说后续订单簿操作一定成功;无效 DELETE 或缺少快照也可能触发另外的检查。更实际的处理是从原始 Catalog 或数据供应商确认事件边界,必要时把查询窗口向前扩大,覆盖完整事件或可靠快照。不能只统计多少条记录带 F_LAST 就认定数据完整。
同样的订单簿 Delta,为什么回测 fills 会不同
数据没有丢失,结果也可能变化。假设你先挂了一张价格 1000.00 的限价买单,盘口卖一原本是 1000.10。同一次事件里,999.95 的卖价先被 ADD、接着 DELETE,且 DELETE 才带 F_LAST。
如果每来一条 Delta 就尝试撮合,买单会短暂见到 999.95;如果整个事件应用完成以后再撮合,999.95 已经消失。这是一种撮合可见性差异,不能仅用“提高批处理性能”解释。
| 处理方式 | 撮合时看到的盘口 | Toy Example 结果 |
|---|---|---|
| 逐条 Delta | 999.95 还在 | 1 次模拟成交 |
按 F_LAST 完整事件 | 999.95 已撤销 | 0 次模拟成交 |
官方 Rust 集成测试源码也设置了临时卖价,并用 #[case::batched(true, 0)] 和 #[case::individual(false, 1)] 声明 total_positions 的预期。上游集成测试比较的是 total_positions,下方 Toy Example 统计的是模拟成交次数。两种指标不同,但都在解释盘口中间状态是否参与撮合。
回测文档还说明了另两处影响:完整批次取最后一条 Delta 的时间戳 ts_init 回放,策略 on_book_deltas 回调和流式 chunk_size 也会按批次改变。因此比对时要看首个出现分歧的事件和回调,而不只看最终 PnL。
我检查的 v2.0.0rc6 配置文件尚无 batch_deltas 字段;它证明了配置接口变化,不代表所有旧版路径都可以简单等同于 False。
把机制复算出来:完整脚本与负向测试
此前用 Python 3.13.5 标准库复算了一个简化事件撮合模型:逐条模式得到 individual=1,整事件模式得到 batched=0。另补充了事件尾部缺 F_LAST、两个标的增量交错、相同时间戳下不同事件独立等检查。原始脚本 5/5 通过,扩展边界测试 10/10 通过。
最值得留下的是扩展测试里那条**“头部截断仍可闭合”**的反例。它测试通过,说明缺失的开头没有被 F_LAST 分组器捕获,而不是说明损坏的数据已经恢复正常。两个测试集有部分重复;没必要在正文连续罗列十五条“绿色结果”。
源码和原始输出都保留在下面两个展开区,可直接复制到本地执行。先保存 book_batching_fixture.py,执行 python3 book_batching_fixture.py;再保存 edge_case_verification.py 到同一目录,执行 python3 -m unittest -v edge_case_verification。这两个脚本仅依赖 Python 标准库,使用人为构造的盘口价格,不包含订单队列、手续费或真实行情。
完整 5/5 合成模型:代码和实际输出
"""Self-authored toy model of F_LAST event boundaries, NOT NautilusTrader runtime."""
from dataclasses import dataclass
from decimal import Decimal
@dataclass(frozen=True)
class Delta:
instrument: str
action: str
price: Decimal
last: bool
ts_init: int
def d(instrument, action, price, last, timestamp):
return Delta(instrument, action, Decimal(price), last, timestamp)
def group_by_last(rows):
pending = {}
for row in rows:
event = pending.setdefault(row.instrument, [])
event.append(row)
if row.last:
yield tuple(event)
del pending[row.instrument]
if pending:
detail = ", ".join(sorted(pending))
raise ValueError(f"missing F_LAST for {detail}")
def toy_fills(rows, batch_deltas):
"""One pending BUY at 1000; match after each replay item, not each event row."""
asks = set()
buy_pending = True
fills = 0
replay_items = group_by_last(rows) if batch_deltas else ((r,) for r in rows)
for item in replay_items:
for row in item:
if row.action == "ADD":
asks.add(row.price)
elif row.action == "DELETE":
asks.discard(row.price)
if buy_pending and asks and min(asks) <= Decimal("1000.00"):
buy_pending = False
fills += 1
return fills
def run():
# The same synthetic event shape as the concept behind the pinned upstream test.
rows = [
d("SIM", "ADD", "1000.10", True, 1),
d("SIM", "ADD", "999.95", False, 2),
d("SIM", "DELETE", "999.95", True, 2),
]
groups = list(group_by_last(rows))
assert [len(g) for g in groups] == [1, 2]
print("PASS 1/5 event boundaries: 3 deltas -> groups [1, 2]")
individual, batched = toy_fills(rows, False), toy_fills(rows, True)
assert (individual, batched) == (1, 0)
print(f"PASS 2/5 toy fills: individual={individual}, batched={batched}")
try:
list(group_by_last([d("SIM", "ADD", "1.00", False, 3)]))
except ValueError as exc:
assert "missing F_LAST for SIM" in str(exc)
else:
raise AssertionError("unterminated event did not fail")
print("PASS 3/5 unterminated event rejected")
interleaved = [
d("A", "ADD", "1", False, 4),
d("B", "ADD", "2", True, 4),
d("A", "DELETE", "1", True, 5),
]
received = list(group_by_last(interleaved))
assert [[r.instrument for r in group] for group in received] == [["B"], ["A", "A"]]
print("PASS 4/5 interleaved instruments remain isolated")
same_time = [d("SIM", "ADD", "1", True, 6), d("SIM", "ADD", "2", True, 6)]
assert [len(g) for g in group_by_last(same_time)] == [1, 1]
print("PASS 5/5 equal-timestamp completed events stay separate")
print("SUMMARY: 5/5 PASS, self-authored stdlib model only; no upstream engine run")
if __name__ == "__main__":
run()
PASS 1/5 event boundaries: 3 deltas -> groups [1, 2]
PASS 2/5 toy fills: individual=1, batched=0
PASS 3/5 unterminated event rejected
PASS 4/5 interleaved instruments remain isolated
PASS 5/5 equal-timestamp completed events stay separate
SUMMARY: 5/5 PASS, self-authored stdlib model only; no upstream engine run
完整 10/10 边界测试:代码和实际输出
"""Independent negative-control suite for the self-authored F_LAST toy model.
Not NautilusTrader tests and not an exchange/market backtest.
"""
import unittest
from book_batching_fixture import d, group_by_last, toy_fills
class OrderBookEventBoundaryControls(unittest.TestCase):
def test_01_trailing_incomplete_event_is_rejected(self):
rows = [d("SIM", "ADD", "999.95", False, 10)]
with self.assertRaisesRegex(ValueError, "missing F_LAST for SIM"):
list(group_by_last(rows))
def test_02_head_truncated_event_is_not_detected_by_f_last(self):
full_event = [
d("SIM", "ADD", "999.95", False, 10),
d("SIM", "DELETE", "999.95", True, 10),
]
complete = list(group_by_last(full_event))
filtered = list(group_by_last(full_event[1:]))
self.assertEqual([len(g) for g in complete], [2])
# This is a NEGATIVE result: F_LAST alone cannot validate the missing head.
self.assertEqual([len(g) for g in filtered], [1])
self.assertEqual(filtered[0][0].action, "DELETE")
def test_03_unbatched_mode_accepts_unclosed_intermediate_row(self):
rows = [d("SIM", "ADD", "999.95", False, 10)]
self.assertEqual(toy_fills(rows, batch_deltas=False), 1)
with self.assertRaises(ValueError):
toy_fills(rows, batch_deltas=True)
def test_04_persistent_ask_matches_in_both_modes(self):
rows = [d("SIM", "ADD", "999.95", True, 10)]
self.assertEqual(toy_fills(rows, False), 1)
self.assertEqual(toy_fills(rows, True), 1)
def test_05_transient_ask_exposes_event_granularity(self):
rows = [
d("SIM", "ADD", "1000.10", True, 10),
d("SIM", "ADD", "999.95", False, 20),
d("SIM", "DELETE", "999.95", True, 20),
]
self.assertEqual((toy_fills(rows, False), toy_fills(rows, True)), (1, 0))
def test_06_equal_timestamps_do_not_merge_closed_events(self):
rows = [d("SIM", "ADD", "1", True, 10), d("SIM", "ADD", "2", True, 10)]
self.assertEqual([len(g) for g in group_by_last(rows)], [1, 1])
def test_07_interleaved_symbols_keep_independent_buffers(self):
rows = [
d("A", "ADD", "1", False, 10),
d("B", "ADD", "2", True, 10),
d("A", "DELETE", "1", True, 11),
]
self.assertEqual([[r.instrument for r in g] for g in group_by_last(rows)], [["B"], ["A", "A"]])
def test_08_partial_symbol_does_not_disappear_at_end(self):
rows = [d("A", "ADD", "1", False, 10), d("B", "ADD", "2", True, 11)]
iterator = iter(group_by_last(rows))
self.assertEqual([x.instrument for x in next(iterator)], ["B"])
with self.assertRaisesRegex(ValueError, "missing F_LAST for A"):
next(iterator)
def test_09_empty_input_is_accepted(self):
self.assertEqual(list(group_by_last([])), [])
def test_10_closing_delta_has_last_timestamp_in_toy(self):
rows = [d("SIM", "ADD", "1", False, 10), d("SIM", "DELETE", "1", True, 12)]
grouped = list(group_by_last(rows))
self.assertEqual(grouped[0][-1].ts_init, 12)
self.assertEqual(len(grouped[0]), 2)
if __name__ == "__main__":
unittest.main(verbosity=2)
test_01_trailing_incomplete_event_is_rejected (edge_case_verification.OrderBookEventBoundaryControls.test_01_trailing_incomplete_event_is_rejected) ... ok
test_02_head_truncated_event_is_not_detected_by_f_last (edge_case_verification.OrderBookEventBoundaryControls.test_02_head_truncated_event_is_not_detected_by_f_last) ... ok
test_03_unbatched_mode_accepts_unclosed_intermediate_row (edge_case_verification.OrderBookEventBoundaryControls.test_03_unbatched_mode_accepts_unclosed_intermediate_row) ... ok
test_04_persistent_ask_matches_in_both_modes (edge_case_verification.OrderBookEventBoundaryControls.test_04_persistent_ask_matches_in_both_modes) ... ok
test_05_transient_ask_exposes_event_granularity (edge_case_verification.OrderBookEventBoundaryControls.test_05_transient_ask_exposes_event_granularity) ... ok
test_06_equal_timestamps_do_not_merge_closed_events (edge_case_verification.OrderBookEventBoundaryControls.test_06_equal_timestamps_do_not_merge_closed_events) ... ok
test_07_interleaved_symbols_keep_independent_buffers (edge_case_verification.OrderBookEventBoundaryControls.test_07_interleaved_symbols_keep_independent_buffers) ... ok
test_08_partial_symbol_does_not_disappear_at_end (edge_case_verification.OrderBookEventBoundaryControls.test_08_partial_symbol_does_not_disappear_at_end) ... ok
test_09_empty_input_is_accepted (edge_case_verification.OrderBookEventBoundaryControls.test_09_empty_input_is_accepted) ... ok
test_10_closing_delta_has_last_timestamp_in_toy (edge_case_verification.OrderBookEventBoundaryControls.test_10_closing_delta_has_last_timestamp_in_toy) ... ok
----------------------------------------------------------------------
Ran 10 tests in 0.001s
OK
要不要拿新结果替换旧回测基准
我不建议看到 batch_deltas=True 少成交一笔,就断定这是“更真实的利润”,也不建议为了延续旧曲线直接永久设成 False。先让数据源的事件定义决定回放粒度,再核对成交差异。 如果上游约定几条 Delta 才构成一个事件,就应该按这个完整事件来解释行情状态;如果起始数据本身被裁坏,先补数据,不能指望换回放模式纠正它。
在保留旧基准的前提下,从第一处不同的 OrderBookDelta、F_LAST 和 ts_init 回头排查,才值得进一步比较手续费、队列、成交成本以及策略输出。这个问题和vectorbt 的 CVaR 样本分母以及反复入选股票的胜率口径颇像:谈量化结果之前,先查清楚究竟哪一组数据才算“一个完整的观察”。
附注 / 版本与验证时间:NautilusTrader v2.0.0rc7 固定源码提交 55c0e82053142bce3a5cba4de4221d3929c2d3bc;Python 3.13.5 的合成模型和 5+10 组边界测试记录于 2026 年 10 月 18 日。Toy Example 为教学数据,未执行 NautilusTrader 引擎、真实历史行情或实盘回测。本文仅讨论量化回测数据与工程实现,不构成投资建议。