¿No coinciden las ejecuciones del backtest? NautilusTrader, batch_deltas y F_LAST
Si utilizas OrderBookDelta de NautilusTrader para hacer backtesting de un libro de órdenes L2/L3 y, tras actualizar la versión, cambian los fills o el número de posiciones, no ajustes todavía la estrategia. Hay dos causas distintas que conviene separar: batch_deltas cambia cuándo puede ver el motor de cruce un estado intermedio del libro; y, si la consulta elimina el comienzo de un evento, puede que no aparezca ningún error aunque la última fila conserve F_LAST.
La segunda situación es especialmente traicionera. F_LAST marca el final de un grupo de deltas; no demuestra que se hayan leído todas sus filas desde el principio. Un intervalo temporal o un filter_expr puede excluir el ADD inicial y dejar únicamente el DELETE final con F_LAST. El agrupador todavía puede cerrar ese fragmento. La documentación oficial fijada a v2.0.0rc7 advierte expresamente de que el recorte al comienzo de un evento puede pasar inadvertido.
He inspeccionado el código y las pruebas upstream de NautilusTrader v2.0.0rc7 y he reproducido los límites de los eventos con un Toy Example independiente, escrito solo con la biblioteca estándar de Python. Para tus datos reales, compara las dos configuraciones sobre tu propio Catalog; el código del apéndice sirve para entender la agrupación por F_LAST, no es una ejecución del motor NautilusTrader.
Antes de retocar la estrategia: cuatro comprobaciones
Incluso con el mismo Catalog, una actualización puede alterar los resultados simulados. El orden de diagnóstico importa.
- Congela la referencia. Anota la versión anterior y rc7, el contenido del Catalog, el rango temporal, la precisión del instrumento, la estrategia y la configuración de órdenes y cruce. No regeneres los históricos durante la investigación.
- Comprueba que los eventos están completos. Revisa
F_LASTporinstrument_idy, sobre todo, si el primer evento del intervalo comienza a mitad de su secuencia. Que el final esté marcado no prueba la presencia de la cabecera. - Cambia una sola opción. Ejecuta los mismos datos y la misma estrategia con
batch_deltas=TrueyFalse. Busca la primera diferencia en una ejecución o callback y vuelve a sus deltas concretos. - Decide después qué resultado usarás como referencia. La granularidad correcta depende de cómo el proveedor define un evento, no de cuál de las dos curvas muestra más rentabilidad.
Adapta el patrón de BacktestDataConfig de la documentación en tu proyecto. Primero fija batch_deltas=True; después cambia únicamente ese valor a False para comparar, manteniendo idénticos catalog_path e instrument_id:
data = BacktestDataConfig(
data_type=NautilusDataType.OrderBookDelta,
catalog_path="/data/catalog",
instrument_id=instrument_id,
batch_deltas=True, # rc7 默认:按完整事件回放
)
# 对照组保持其余配置不变,仅将 batch_deltas 改为 False
En la configuración Rust fijada de rc7 el valor por defecto es true. Las pruebas de configuración Python abarcan el caso predeterminado y los estados activado y desactivado. False sirve como control de diagnóstico: no recupera un libro histórico incompleto.
F_LAST puede detectar un final ausente, pero no necesariamente un principio ausente
Imagina un único evento de mercado que añade una oferta de venta a 999.95 y luego la elimina. Solo el último DELETE lleva F_LAST.
| Ventana consultada | Filas Delta conservadas | Resultado del agrupador |
|---|---|---|
| Completa | ADD 999.95 → DELETE 999.95 (F_LAST) | Las dos filas forman un evento cerrado |
| Falta el final | Solo ADD 999.95 sin F_LAST | Queda un lote abierto; al terminar la entrada puede haber error |
| Falta el principio | Solo DELETE 999.95 (F_LAST) | Puede cerrarse un fragmento incompleto de una sola fila |
La implementación de BookDeltasBatcher mantiene una lista de deltas pendientes por instrument_id y emite OrderBookDeltas al encontrar F_LAST. Al terminar comprueba si quedan grupos sin cerrar. No tiene forma de adivinar que una consulta descartó previamente la primera fila ADD.
«Puede cerrarse» se refiere únicamente al control del límite F_LAST. Un DELETE inválido o la falta de una instantánea podrían provocar un error posterior por otra vía. La solución práctica consiste en comprobar los límites del evento en el Catalog original o en la fuente de datos, ampliar la lectura hacia atrás si hace falta y partir de una instantánea fiable. Contar las filas con F_LAST no equivale a validar la integridad del flujo.
Por qué los mismos deltas pueden producir otros fills
El conjunto de datos puede ser idéntico y completo, pero cambiar el instante en que se intenta el cruce. Supongamos una orden limitada de compra a 1000.00, mientras que el mejor precio vendedor es 1000.10. En el siguiente evento aparece una venta a 999.95 mediante ADD y desaparece con DELETE; el DELETE lleva F_LAST.
Si se intenta cruzar después de cada delta, la compra puede encontrarse con esa oferta fugaz a 999.95. Si primero se aplica el evento completo, cuando llega el cruce la oferta ya no existe. La diferencia es de visibilidad del estado intermedio, no simplemente una optimización de rendimiento.
| Modo de reproducción | Estado del libro al cruzar | Fills del Toy Example |
|---|---|---|
| Delta por delta | La oferta temporal 999.95 está presente | 1 |
Evento completo hasta F_LAST | La oferta 999.95 ya se eliminó | 0 |
La prueba de integración Rust upstream crea una situación comparable y declara #[case::batched(true, 0)] y #[case::individual(false, 1)] como valores esperados de total_positions. Son aserciones del código de pruebas upstream. El 1/0 de esta tabla es el recuento de ejecuciones simuladas de un modelo propio que sí se ejecutó; no son la misma métrica ni el mismo experimento.
La documentación del backtest explica también que el lote se reproduce con el ts_init del delta de cierre y que cambian las unidades de los callbacks on_book_deltas y de chunk_size en streaming. Al investigar una discrepancia interesa localizar el primer evento divergente, no mirar solamente el PnL final.
El archivo de configuración de v2.0.0rc6 que examiné aún no contenía el campo batch_deltas. Esto acredita un cambio en la interfaz de configuración, pero no demuestra que todas las rutas antiguas se comportaran exactamente como False.
Reproducción del mecanismo: scripts completos y controles negativos
Construí con la biblioteca estándar de Python 3.13.5 un libro deliberadamente sencillo y una comprobación de cruce. La reproducción individual devuelve individual=1 y la agrupada por eventos batched=0. También verifiqué la ausencia de un F_LAST final, la intercalación de instrumentos y eventos independientes con igual marca de tiempo. El script inicial superó 5/5 comprobaciones y la suite unittest ampliada superó 10/10.
El control negativo más valioso demuestra que un evento cuyo principio falta todavía puede cerrarse. Que el test pase significa que hemos reproducido un punto ciego de la validación; no que se haya reparado un flujo de mercado corrupto. Los dos conjuntos de pruebas se solapan parcialmente, por lo que quince PASS tampoco son quince observaciones independientes del mercado.
Conservo abajo el código ejecutable íntegro y las salidas originales observadas. Guarda el primer archivo como book_batching_fixture.py y ejecuta python3 book_batching_fixture.py. Coloca edge_case_verification.py junto a él y ejecuta python3 -m unittest -v edge_case_verification. Ambos utilizan solamente precios inventados y la biblioteca estándar: sin colas de órdenes, comisiones ni datos de una bolsa real.
Modelo sintético original, 5/5: código íntegro y salida registrada
"""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
Controles negativos ampliados, 10/10: código íntegro y salida registrada
"""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
¿Deberías sustituir la referencia histórica del backtest?
Un fill menos con batch_deltas=True no implica automáticamente «un beneficio más realista». Tampoco tiene sentido volver a False solo para conservar una curva histórica atractiva. Primero hay que establecer los límites del evento definidos por la fuente de datos; después se interpretan las diferencias de ejecución. Si varios deltas forman un solo evento lógico, aplicarlos como unidad respeta esa convención. Si falta el principio de la ventana, ninguna opción booleana puede recuperar la información perdida.
Mantén la referencia anterior y, en el primer OrderBookDelta que difiera, examina F_LAST, ts_init y su agrupación original. Solo entonces merece la pena analizar comisiones, prioridad de cola, costes de transacción o rentabilidad. El mismo principio aparece al estudiar el denominador de muestras en CVaR con vectorbt y la tasa de aciertos al seleccionar repetidamente una acción: antes de interpretar una cifra cuantitativa, hay que definir qué constituye una observación completa.
Nota de versión y verificación: El código de NautilusTrader v2.0.0rc7 está fijado al commit 55c0e82053142bce3a5cba4de4221d3929c2d3bc. El modelo sintético de Python 3.13.5 y las 5+10 pruebas de límites se registraron el 18 de octubre de 2026. El Toy Example es material didáctico: no es una ejecución de NautilusTrader ni un backtest con datos históricos reales o negociación en vivo. Se trata de ingeniería de reproducción, no de asesoramiento de inversión.