訂單簿回測成交對不上?NautilusTrader batch_deltas 與 F_LAST 排查
如果你使用 NautilusTrader 的 OrderBookDelta 進行 L2/L3 訂單簿回測,升級後發現 fills 或部位數和舊結果對不上,先不要急著調整策略參數。可能有兩個不同的原因:batch_deltas 改變了撮合流程能看到哪些中間盤口狀態;更隱蔽的是,查詢區間把事件開頭裁掉時,即使最後一筆還有 F_LAST,批次處理也可能沒有報錯。
第二個狀況尤其容易忽略。F_LAST 只標示某組增量已經結束,並不能保證你拿到的是從第一筆開始的完整事件。時間範圍或 filter_expr 可能濾掉開頭的 ADD,卻留下帶 F_LAST 的 DELETE;批次處理器依然有機會把這段殘缺資料視為已閉合。固定到 2.0.0rc7 的官方回測文件明確提醒過,事件起點被截斷可能無法被偵測。
我核對了 NautilusTrader v2.0.0rc7 的原始碼和上游測試,再以 Python 標準函式庫的 Toy Example 複算分組機制。你可以先在自己的 Catalog 用下方設定對照兩種回放,再從附錄理解 F_LAST 對結果的影響;Toy Example 本身不是 NautilusTrader 引擎實測。
先別改策略:四步找出成交差異
即使用的是同一份歷史 Catalog,框架升級後的結果也可能不一樣。排查時建議依照以下順序:
- 固定比較基準。 記下舊版與 rc7 的版本、Catalog 內容、時間範圍、商品精度、策略版本及委託與撮合設定。排查時不要一邊重新產生行情資料。
- 檢查事件完整性。 逐一檢查各
instrument_id的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 設定原始碼將預設值設為 true;Python 設定的上游測試也涵蓋預設、關閉與啟用。False 是診斷工具,不能修復本身就不完整的歷史行情。
F_LAST 可以發現尾端缺失,未必能發現起點缺失
假設同一個行情事件先新增 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 分別暫存待處理的 Delta,遇到 F_LAST 才輸出 OrderBookDeltas;輸入結束後檢查的是「還有沒有未結束的批次」。如果查詢早就刪掉第一筆 ADD,分組器並不知道原本少了資料。
「可以閉合」僅表示 F_LAST 這一層的檢查通過。不合法的 DELETE 或缺少快照仍可能在後續步驟出錯。比較可靠的做法是回到原始 Catalog 或供應商資料核對事件邊界,必要時往前擴大讀取區間,從可信的快照重新建立狀態。不能只統計 F_LAST 的數量就認定事件完整。
相同的 Delta,為什麼回測 fills 會變
即使增量資料一筆不漏,撮合時機改變也可能讓結果不同。假設有一筆價格 1000.00 的限價買單,原本最佳賣價是 1000.10。下一個事件先 ADD 一個 999.95 的賣價,再 DELETE 它,而 DELETE 才帶 F_LAST。
如果每筆 Delta 都立即嘗試撮合,買單會短暫看到 999.95;如果先套用整個事件再撮合,那個賣價已經消失。這是中間盤口狀態是否對撮合可見的差異,不只是效能最佳化。
| 回放方式 | 撮合當下看到的盤口 | Toy Example 的模擬成交 |
|---|---|---|
| 逐筆 Delta | 暫時存在的 999.95 | 1 |
完整 F_LAST 事件 | 999.95 已被刪除 | 0 |
上游 Rust 整合測試也建立了短暫賣價的情境,並以 #[case::batched(true, 0)] 與 #[case::individual(false, 1)] 定義 total_positions 的預期。上游測試使用的是部位數預期斷言;表格裡的 1/0 則是獨立 Toy Model 的模擬成交次數,並不是同一次執行的同一指標。
官方回放說明還指出,整批事件以最後一筆 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 通過,擴充的 unittest 邊界檢查 10/10 通過。
最重要的負向案例是「事件開頭已經遺失,剩下的片段仍能閉合」。測試成功重現這個漏洞,不代表損壞的歷史行情已被修復。兩套測試的部分斷言有重疊,也不應把十五個 PASS 當作十五次彼此獨立的市場觀察。
下面保留全部可執行原始碼與當時的真實輸出。第一份存成 book_batching_fixture.py,執行 python3 book_batching_fixture.py;第二份 edge_case_verification.py 放在同一目錄,執行 python3 -m unittest -v edge_case_verification。兩者只用標準函式庫與合成價位,不模擬真實委託佇列、手續費或交易所撮合。
原始 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 引擎執行、真實歷史行情或實盤回測。本文僅討論量化回放工程,不構成投資建議。