Se usi OrderBookDelta di NautilusTrader per il backtest di un order book L2/L3, può capitare che dopo un aggiornamento i fills o il numero di posizioni non corrispondano più ai risultati precedenti. Prima di modificare la strategia, separa due cause diverse: batch_deltas cambia il momento in cui il matching può vedere uno stato intermedio del book; inoltre, se la query elimina l’inizio di un evento, la presenza di F_LAST sull’ultima riga potrebbe non produrre alcun errore.

La seconda situazione è la più insidiosa. F_LAST indica la fine di un gruppo di delta, ma non dimostra che il gruppo sia stato letto a partire dal suo inizio. Un intervallo temporale o filter_expr può escludere l’ADD iniziale e lasciare soltanto il DELETE finale con F_LAST. Il batcher può chiudere comunque quel frammento. La documentazione ufficiale fissata alla versione v2.0.0rc7 avverte esplicitamente che il taglio all’inizio dell’evento può non essere rilevato.

Ho verificato il codice sorgente e i test upstream di NautilusTrader v2.0.0rc7 e ho riprodotto la semantica degli eventi con un Toy Example autonomo scritto usando la libreria standard Python. Per il tuo Catalog, usa il confronto di configurazione qui sotto; i programmi in appendice spiegano il raggruppamento per F_LAST con prezzi inventati, senza eseguire il motore NautilusTrader.

Prima di modificare la strategia: quattro controlli

Anche mantenendo lo stesso Catalog, un aggiornamento del framework può cambiare gli esiti simulati. Vale la pena seguire una sequenza precisa.

  1. Congela il riferimento. Registra entrambe le versioni, i file del Catalog, i limiti temporali, la precisione dello strumento, la strategia e la configurazione degli ordini e del matching. Non rigenerare i dati storici mentre cerchi il problema.
  2. Controlla la completezza degli eventi. Per ogni instrument_id verifica F_LAST e soprattutto che il primo evento dell’intervallo non parta a metà. Un marcatore di chiusura non prova la presenza delle righe iniziali.
  3. Cambia una sola opzione. Esegui dati, strategia e ipotesi di matching identici con batch_deltas=True e con False. Individua il primo fill o callback divergente e risali ai delta specifici.
  4. Solo dopo scegli il riferimento futuro. La granularità di replay deve riflettere la definizione di evento del fornitore di dati, non il rendimento più alto tra le due curve.

Parti dallo schema di BacktestDataConfig nella documentazione. Nel progetto esistente imposta esplicitamente batch_deltas=True e, per il confronto, cambia solo questo campo in False. Percorso del Catalog e instrument_id devono restare invariati:

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

Nella configurazione Rust di rc7 il valore predefinito è true. I test upstream della configurazione Python coprono valore predefinito, attivazione e disattivazione. False è un controllo diagnostico, non ripara una cronologia di mercato incompleta.

F_LAST può segnalare una fine mancante, ma non sempre un inizio mancante

Immagina un evento di mercato che aggiunge una proposta in vendita a 999.95 e poi la cancella. Soltanto il DELETE finale contiene F_LAST.

Finestra lettaDelta rimastiRisultato del raggruppamento
CompletaADD 999.95 → DELETE 999.95 (F_LAST)Due righe, un evento chiuso
Finale troncatoSolo ADD 999.95 senza F_LASTIl gruppo rimane aperto; possibile errore a fine input
Inizio troncatoSolo DELETE 999.95 (F_LAST)Un frammento incompleto di una riga può comunque chiudersi

L’implementazione di BookDeltasBatcher conserva i delta in sospeso separatamente per instrument_id ed emette OrderBookDeltas quando incontra F_LAST. Alla fine controlla se restano gruppi aperti. Non può recuperare l’ADD iniziale già eliminato dalla query.

«Può chiudersi» riguarda unicamente il controllo dei confini tramite F_LAST. Un DELETE non valido o uno snapshot mancante possono provocare ulteriori errori nelle fasi successive. In pratica bisogna verificare i confini degli eventi nel Catalog originale o presso il fornitore, allargare la lettura verso il passato se necessario e ripartire da uno snapshot affidabile. Contare le righe con F_LAST non equivale a certificare la completezza dei dati.

Perché gli stessi delta possono produrre fill diversi

I dati possono essere completi e identici, ma il punto in cui avviene il matching può cambiare. Supponi di avere un ordine limite di acquisto a 1000.00 mentre il miglior ask è 1000.10. Durante il successivo evento compare con ADD un ask a 999.95, immediatamente rimosso con DELETE; il DELETE porta F_LAST.

Se il matching viene eseguito dopo ogni delta, l’acquisto può vedere brevemente la proposta a 999.95. Se invece si applica l’intero evento prima del matching, quel prezzo è già scomparso. È una differenza di visibilità dello stato intermedio del book, non soltanto una questione di prestazioni.

Modalità di replayBook visto al momento del matchingFill del Toy Example
Delta singoliAsk temporaneo a 999.95 presente1
Evento completo fino a F_LASTAsk 999.95 già cancellato0

Il test d’integrazione Rust upstream costruisce una situazione analoga e dichiara #[case::batched(true, 0)] e #[case::individual(false, 1)] come valori attesi di total_positions. Sono assertion definite nel sorgente del test upstream. L’1/0 della tabella è invece il conteggio di fill simulati da un Toy Model eseguito separatamente. Illustrano lo stesso meccanismo, ma non misurano la stessa cosa né derivano dalla stessa esecuzione.

La documentazione del backtest spiega inoltre che il batch viene riprodotto al ts_init del delta conclusivo e che i callback on_book_deltas e il contatore chunk_size dello streaming diventano relativi ai batch. Nell’analisi di un’anomalia conviene quindi trovare il primo evento divergente, non confrontare solo il PnL finale.

Nel file di configurazione di v2.0.0rc6 che ho esaminato non compare il campo batch_deltas. Questo documenta un cambiamento nell’interfaccia, ma non dimostra che tutti i percorsi di replay precedenti fossero identici a False.

Riprodurre il meccanismo: script completi e test negativi

Con la libreria standard Python 3.13.5 ho costruito un book semplificato e una singola verifica di matching. Il replay per riga restituisce individual=1 e quello per eventi batched=0. Ho controllato anche l’assenza di F_LAST a fine input, l’interleaving di strumenti e gli eventi separati con lo stesso timestamp. Lo script iniziale ha superato 5/5 verifiche, la suite unittest estesa 10/10.

Il controllo negativo più importante mostra che un evento senza le righe iniziali può comunque chiudersi grazie al F_LAST rimasto. Un test riuscito significa che la lacuna di validazione è stata riprodotta; non che il feed danneggiato sia stato corretto. Alcune verifiche sono comuni alle due suite, quindi quindici PASS non rappresentano quindici osservazioni indipendenti di mercato.

Di seguito mantengo il codice eseguibile completo e gli output originali osservati. Salva il primo file come book_batching_fixture.py ed esegui python3 book_batching_fixture.py. Metti edge_case_verification.py nella stessa cartella ed esegui python3 -m unittest -v edge_case_verification. Entrambi usano solo la libreria standard e prezzi inventati: nessuna coda di ordini reale, commissione o dato di un exchange.

Modello sintetico originale, 5/5: codice completo e output registrato
"""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
Controlli negativi estesi, 10/10: codice completo e output registrato
"""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

Sostituire il riferimento del backtest storico con i nuovi risultati?

Avere un fill in meno con batch_deltas=True non implica automaticamente un «profitto più realistico». E tornare a False soltanto per mantenere una curva passata migliore non è un criterio valido. Prima si stabilisce come il fornitore definisce un evento, poi si interpretano le differenze di esecuzione. Se diversi delta rappresentano un solo evento logico, applicarli insieme rispetta la semantica della fonte. Se manca l’inizio dell’evento, nessun valore booleano può ricostruire quello stato.

Conserva il risultato precedente e, nel primo OrderBookDelta che diverge, verifica F_LAST, ts_init e la suddivisione originaria prima di analizzare commissioni, priorità nella coda, costi di trading o rendimento della strategia. La stessa lezione ricorre nel denominatore dei campioni CVaR con vectorbt e nelle percentuali di successo quando si seleziona ripetutamente lo stesso titolo: prima di interpretare un numero quantitativo occorre definire che cosa costituisce un’osservazione completa.

Nota su versione e verifiche: i sorgenti di NautilusTrader v2.0.0rc7 sono fissati al commit 55c0e82053142bce3a5cba4de4221d3929c2d3bc. Il modello sintetico Python 3.13.5 e i 5+10 controlli sui confini sono stati registrati il 18 ottobre 2026. Il Toy Example è materiale didattico, non un’esecuzione del motore NautilusTrader né un backtest con prezzi storici reali o operazioni dal vivo. È un approfondimento tecnico, non una consulenza finanziaria.