オーダーブックのバックテスト約定が合わない?NautilusTraderのbatch_deltasとF_LASTを検証
NautilusTraderのOrderBookDeltaを使ってL2/L3オーダーブックをバックテストし、アップグレード後にfillsやポジション数が以前と一致しなくなった場合、すぐに戦略パラメーターを調整するのは避けたいところです。切り分けるべき問題は二つあります。batch_deltasはマッチングが途中の板状態を参照するタイミングを変えます。そして、イベントの先頭が読み込み範囲から欠落しても、末尾にF_LASTが残っていればエラーにならないことがあります。
後者は特に見落とされやすい落とし穴です。F_LASTが表すのはイベントの「終了」であり、「先頭から欠けずに受信した」ことの証明ではありません。時間範囲やfilter_exprによって冒頭のADDが落ち、F_LAST付きのDELETEだけが残っていても、バッチャーはその断片を閉じられます。v2.0.0rc7に固定した公式バックテスト文書も、イベント先頭の切断を検出できない可能性を明記しています。
以下ではNautilusTrader v2.0.0rc7のソースと上流テストを確認し、Python標準ライブラリだけのToy Exampleでイベント境界を再現します。ご自身のCatalogでは先に設定値を変えた対照実行を行い、添付の合成コードはF_LASTの仕組みを理解するために使ってください。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をADDした直後にDELETEするとします。F_LASTが付くのは最後のDELETEだけです。
| 読み込み範囲 | 残るDelta | バッチの結果 |
|---|---|---|
| 全体 | ADD 999.95 → DELETE 999.95(F_LAST) | 二行を一つの完全なイベントとして閉じる |
| 末尾が欠損 | F_LASTのないADD 999.95のみ | 未完了のバッチが残り、終了時にエラーを報告できる |
| 先頭が欠損 | F_LAST付きDELETE 999.95のみ | 不完全な一行でも閉じることができる |
BookDeltasBatcherの実装は銘柄ごとにDeltaを蓄積し、F_LASTを見てOrderBookDeltasを出力します。入力の最後に確認するのは閉じられなかったバッチの有無です。抽出条件で先頭のADDを捨ててしまっても、その情報はバッチャーに届きません。
これは**F_LASTによる境界チェックだけ**の話です。存在しない価格のDELETEや不足したスナップショットは、その後の処理で別のエラーを起こす可能性があります。元のCatalogまたは配信元のイベント定義を確認し、必要なら読み込み開始点を前に戻すか信頼できるスナップショットから復元します。F_LASTの件数だけで完全性を判定してはいけません。
同じオーダーブックDeltaでも約定数が変わる理由
データが完全でも、マッチングのタイミングが変われば結果は変わり得ます。1000.00で買い指値を置き、最良売り気配が1000.10だとします。次のイベントの途中で999.95の売り気配がADDされ、F_LAST付きのDELETEで消えます。
一行ずつマッチングすれば買い注文は一時的な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フィールドがありません。設定APIの変更は確認できますが、旧版のすべての経路がFalseと同じ挙動だったとまでは言えません。
再現用の全コードとネガティブテスト
Python 3.13.5の標準ライブラリを使い、単純化した板とマッチング判定を実装しました。個別リプレイはindividual=1、イベント単位はbatched=0になります。末尾のF_LAST欠損、複数銘柄の交互入力、同じタイムスタンプを持つ別イベントも確認しています。最初のスクリプトは5/5、追加のunittestは10/10で通過しました。
追加テストで重要なのは、先頭が欠けたイベントが、それでもF_LASTで閉じられるという反例です。このテストが通ることは検出の穴を再現できたという意味で、壊れたデータが直ったという意味ではありません。二つのテスト群には重複する確認もあるため、15件のPASSを15件の独立した市場観測と数えるべきではありません。
元の実行可能なコードと実測出力を次に保存します。最初のブロックを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本体の実行、実際の市場データや実運用のバックテストではありません。投資助言を目的としたものでもありません。