Wer mit NautilusTrader und OrderBookDelta L2/L3-Orderbücher backtestet, kennt das unangenehme Symptom: Nach einem Upgrade stimmen fills oder Positionszahlen nicht mehr mit dem bisherigen Lauf überein. Bevor man die Strategie verändert, sollte man zwei Ursachen auseinanderhalten. batch_deltas bestimmt, ob das Matching vorübergehende Orderbuchzustände sieht. Und noch tückischer: Wird der Anfang eines Events durch den Abfragezeitraum abgeschnitten, kann der Rest trotz F_LAST ohne Fehler als geschlossen gelten.

F_LAST kennzeichnet lediglich das Ende eines Delta-Events; es beweist nicht, dass dessen Anfang noch vorhanden ist. Ein Zeitfilter oder filter_expr kann die erste ADD-Zeile entfernen, während die letzte DELETE-Zeile mit F_LAST übrig bleibt. Der Batcher kann dieses Fragment trotzdem abschließen. Die auf NautilusTrader v2.0.0rc7 fixierte Dokumentation weist ausdrücklich darauf hin, dass das Abschneiden des Eventanfangs unentdeckt bleiben kann.

Ich habe den versionierten Quellcode und die Upstream-Tests geprüft und die Ereignisgrenzen mit einem eigenständigen Python-Standardbibliotheksmodell nachgestellt. Für die eigenen Catalog-Daten eignet sich der folgende Konfigurationsvergleich; die Skripte im Anhang erklären die Mechanik mit künstlichen Preisen, ohne die NautilusTrader-Engine auszuführen.

Vor dem Ändern der Strategie: vier Schritte zur Eingrenzung

Auch bei einem vermeintlich unveränderten Daten-Catalog können sich die simulierten Ergebnisse nach einem Upgrade unterscheiden.

  1. Ausgangsbasis festhalten. Alte und neue Frameworkversion, Catalog-Dateien, Zeitfenster, Instrumentpräzision, Strategieversion und Matching-/Orderkonfiguration dokumentieren. Während der Fehlersuche keine historischen Daten neu aufbereiten.
  2. Vollständige Events prüfen. Pro instrument_id auf F_LAST achten und insbesondere kontrollieren, ob das erste Event im gewählten Zeitfenster nur ab seiner Mitte eingelesen wurde. Ein Abschlussmarker beweist keinen vollständigen Anfang.
  3. Genau eine Option ändern. Dieselben Daten und Annahmen einmal mit batch_deltas=True und einmal mit False durchlaufen lassen. Den ersten abweichenden Fill oder Callback finden und zu den zugrunde liegenden Deltas zurückgehen.
  4. Erst danach die neue Referenz wählen. Maßgeblich ist die Eventsemantik des Datenanbieters, nicht die Rückrechnung mit der attraktiveren Performancekurve.

Als Ausgangspunkt dient die offizielle BacktestDataConfig-Form. Im vorhandenen Backtest zunächst batch_deltas=True ausdrücklich angeben und für den Vergleich nur diese Option auf False setzen. Catalog-Pfad und instrument_id bleiben identisch:

data = BacktestDataConfig(
    data_type=NautilusDataType.OrderBookDelta,
    catalog_path="/data/catalog",
    instrument_id=instrument_id,
    batch_deltas=True,  # rc7 默认:按完整事件回放
)
# 对照组保持其余配置不变,仅将 batch_deltas 改为 False

In der fixierten Rust-Konfiguration von rc7 ist true der Standard. Die Upstream-Tests der Python-Konfiguration decken Standardwert, Ausschalten und Einschalten ab. False ist eine Diagnoseoption – keine Reparatur für fehlende Marktdaten.

F_LAST erkennt möglicherweise ein fehlendes Ende, aber keinen fehlenden Anfang

Ein einzelnes Marktdaten-Event fügt zunächst eine Ask-Stufe bei 999.95 hinzu und löscht sie sofort wieder. Nur das abschließende DELETE besitzt das Flag F_LAST.

Eingelesenes FensterVerbleibende Delta-ZeilenWirkung beim Gruppieren
VollständigADD 999.95 → DELETE 999.95 (F_LAST)Beide Zeilen bilden ein abgeschlossenes Event
Ende fehltNur ADD 999.95 ohne F_LASTOffene Gruppe bleibt zurück; am Eingabeende ist ein Fehler möglich
Anfang fehltNur DELETE 999.95 (F_LAST)Das unvollständige Ein-Zeilen-Fragment kann abgeschlossen werden

Die Implementierung von BookDeltasBatcher puffert Deltas getrennt nach instrument_id und gibt bei F_LAST ein OrderBookDeltas-Paket aus. Am Ende wird geprüft, ob Gruppen offen geblieben sind. Eine ADD-Zeile, die bereits bei der Abfrage herausgefiltert wurde, kann daraus nicht rekonstruiert werden.

„Abschließbar“ bezieht sich nur auf die F_LAST-Grenzprüfung. Ein ungültiges DELETE oder ein fehlender Snapshot kann später weitere Fehler auslösen. In der Praxis muss man die Ereignisgrenzen im ursprünglichen Catalog beziehungsweise beim Datenlieferanten kontrollieren, den Lesezeitraum gegebenenfalls nach vorne erweitern oder von einem verlässlichen Snapshot ausgehen. Die bloße Zahl der F_LAST-Zeilen reicht nicht als Integritätsnachweis.

Warum dieselben Deltas zu anderen Backtest-Fills führen

Die Daten können vollständig und identisch sein, während sich dennoch der Matching-Zeitpunkt ändert. Angenommen, eine Limit-Kauforder zu 1000.00 liegt im Buch; der beste Ask steht zunächst bei 1000.10. Innerhalb des nächsten Events erscheint kurz ein Ask zu 999.95 durch ADD und verschwindet durch DELETE wieder. Erst DELETE setzt F_LAST.

Wird nach jeder einzelnen Delta-Zeile gematcht, kann die Kauforder die kurzlebige 999.95 sehen. Wenn erst das gesamte Event eingespielt wird, existiert dieser Ask zum Matching-Zeitpunkt nicht mehr. Es geht um die Sichtbarkeit eines Zwischenzustands, nicht nur um bessere Batch-Performance.

Replay-ModusSichtbarer Orderbuchzustand beim MatchingSynthetische Toy-Fills
Deltas einzelnTemporärer Ask 999.95 vorhanden1
Komplettes F_LAST-EventAsk 999.95 bereits entfernt0

Der Rust-Integrationstest im Upstream konstruiert ebenfalls eine vorübergehende Ask-Stufe und definiert mit #[case::batched(true, 0)] sowie #[case::individual(false, 1)] die erwarteten Werte für total_positions. Das sind Testassertionen aus dem Quellcode. Die 1/0 in der Tabelle messen hingegen Fills eines separat ausgeführten Toy-Modells. Beide illustrieren dieselbe Eventgrenze, aber nicht dieselbe Messgröße oder denselben Programmlauf.

Die Backtesting-Dokumentation beschreibt außerdem, dass der Batch zum ts_init der abschließenden Delta-Zeile abgespielt wird und sich die Granularität von on_book_deltas-Callbacks und Streaming-chunk_size ändert. Deshalb ist das erste abweichende Event aussagekräftiger als ein bloßer Vergleich der End-PnL-Zahlen.

In der inspizierten Konfiguration von v2.0.0rc6 fehlt die Option batch_deltas. Das belegt eine Schnittstellenänderung, aber nicht, dass alle älteren Replay-Wege exakt False entsprachen.

Mechanik nachrechnen: vollständige Skripte und Negativtests

Mit der Python-3.13.5-Standardbibliothek habe ich ein bewusst einfaches Orderbuch und einen Matching-Schritt modelliert. Beim Einzelschritt-Replay ergibt sich individual=1, beim Event-Batching batched=0. Getestet wurden außerdem ein fehlendes abschließendes F_LAST, verschachtelte Instrumente und unabhängige Events mit gleichem Zeitstempel. Das ursprüngliche Skript bestand 5/5, die ergänzende unittest-Suite 10/10 Prüfungen.

Der wichtigste Negativtest zeigt, dass ein Event ohne Anfang dennoch über sein vorhandenes F_LAST geschlossen werden kann. Ein erfolgreicher Test bedeutet hier, dass die Erkennungslücke reproduziert wurde – nicht, dass beschädigte Marktdaten wiederhergestellt wurden. Mehrere Prüfungen überlappen sich, deshalb sind fünfzehn PASS-Meldungen keine fünfzehn unabhängigen Marktbeobachtungen.

Hier bleiben die vollständigen ausführbaren Dateien und die ursprünglichen tatsächlichen Ausgaben erhalten. Den ersten Block als book_batching_fixture.py speichern und python3 book_batching_fixture.py ausführen. Die Datei edge_case_verification.py daneben ablegen und python3 -m unittest -v edge_case_verification starten. Es handelt sich ausschließlich um Standardbibliothek und fiktive Preise, ohne Gebühren, echte Börsenfeeds oder Warteschlangenpositionen.

Ursprüngliches synthetisches Modell – 5/5: Code und aufgezeichnete Ausgabe
"""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
Erweiterte Negativtests – 10/10: Code und aufgezeichnete Ausgabe
"""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

Soll die neue Rechnung die alte Backtest-Referenz ersetzen?

Weniger Fills mit batch_deltas=True bedeuten nicht automatisch „realistischeren Gewinn“. Ebenso wenig sollte man False nur deshalb beibehalten, weil die frühere Kapitalkurve besser aussah. Zuerst die Eventdefinition des Datenanbieters klären, danach die Fills beurteilen. Bilden mehrere Deltas ein logisches Event, ist eine atomare Verarbeitung im Sinne dieser Daten. Fehlt der Anfang des Events, kann keine der beiden Boolean-Optionen die verlorene Information zurückholen.

Die bisherige Referenz aufbewahren und beim ersten abweichenden OrderBookDelta dessen F_LAST, ts_init und ursprüngliche Gruppierung prüfen, bevor Gebühren, Matching-Queue, Handelskosten und Strategieertrag analysiert werden. Ein verwandtes Grundproblem zeigen die Stichprobenzahl beim vectorbt-CVaR und die Gewinnquote bei mehrfach ausgewählten Aktien: Vor der Interpretation quantitativer Werte muss feststehen, was eine vollständige Beobachtung ist.

Versions- und Verifikationshinweis: NautilusTrader v2.0.0rc7 ist auf Commit 55c0e82053142bce3a5cba4de4221d3929c2d3bc fixiert. Das synthetische Python-3.13.5-Modell und die 5+10 Grenztests wurden am 18. Oktober 2026 protokolliert. Das Toy Example ist Lehrmaterial, kein Test der NautilusTrader-Engine und kein Backtest mit realen historischen Marktdaten oder Live-Trades. Es handelt sich um technische Erläuterungen, nicht um Anlageberatung.