如果你使用 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,框架升級後的結果也可能不一樣。排查時建議依照以下順序:

  1. 固定比較基準。 記下舊版與 rc7 的版本、Catalog 內容、時間範圍、商品精度、策略版本及委託與撮合設定。排查時不要一邊重新產生行情資料。
  2. 檢查事件完整性。 逐一檢查各 instrument_id 的 F_LAST,尤其注意查詢視窗中的第一個事件是否從中途開始。尾端有結束標記,不等於開頭沒有被裁掉。
  3. 只切換一個設定。 在資料、策略及撮合條件完全相同時,分別使用 batch_deltas=True 與 False。先找第一個不同的成交或回呼,再追溯那組訂單簿增量。
  4. 最後才決定新基準。 根據資料來源對「一次事件」的定義選擇回放粒度,而不是看哪種模式的報酬比較高。

參考官方 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.951
完整 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 引擎執行、真實歷史行情或實盤回測。本文僅討論量化回放工程,不構成投資建議。