# ¿No coinciden las ejecuciones del backtest? NautilusTrader, batch_deltas y F_LAST

> Canonical: https://vxi.me/es/posts/nautilustrader-orderbook-batch-deltas-f-last/
> Locale: es-es
> Published: 2026-10-18
> Updated: 2026-10-18
> Markdown: https://vxi.me/es/posts/nautilustrader-orderbook-batch-deltas-f-last.md

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](https://github.com/nautechsystems/nautilus_trader/blob/55c0e82053142bce3a5cba4de4221d3929c2d3bc/docs/concepts/backtesting/apis-and-runs.md#L154-L187) 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.

1. **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.
2. **Comprueba que los eventos están completos.** Revisa `F_LAST` por `instrument_id` y, 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.
3. **Cambia una sola opción.** Ejecuta los mismos datos y la misma estrategia con `batch_deltas=True` y `False`. Busca la *primera* diferencia en una ejecución o callback y vuelve a sus deltas concretos.
4. **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](https://github.com/nautechsystems/nautilus_trader/blob/55c0e82053142bce3a5cba4de4221d3929c2d3bc/docs/concepts/backtesting/apis-and-runs.md#L176-L187) 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`:

```python
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](https://github.com/nautechsystems/nautilus_trader/blob/55c0e82053142bce3a5cba4de4221d3929c2d3bc/crates/backtest/src/config.rs#L838-L844) el valor por defecto es `true`. Las [pruebas de configuración Python](https://github.com/nautechsystems/nautilus_trader/blob/55c0e82053142bce3a5cba4de4221d3929c2d3bc/crates/backtest/src/python/config.rs#L1080-L1119) 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`](https://github.com/nautechsystems/nautilus_trader/blob/55c0e82053142bce3a5cba4de4221d3929c2d3bc/crates/backtest/src/node.rs#L744-L829) 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](https://github.com/nautechsystems/nautilus_trader/blob/55c0e82053142bce3a5cba4de4221d3929c2d3bc/crates/backtest/tests/integration/backtest_node.rs#L2169-L2240) 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](https://github.com/nautechsystems/nautilus_trader/blob/55c0e82053142bce3a5cba4de4221d3929c2d3bc/docs/concepts/backtesting/apis-and-runs.md#L154-L168) 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`](https://github.com/nautechsystems/nautilus_trader/blob/v2.0.0rc6/crates/backtest/src/config.rs) 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.

<details>
<summary>Modelo sintético original, 5/5: código íntegro y salida registrada</summary>

```python
"""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()
```

```text
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
```

</details>

<details>
<summary>Controles negativos ampliados, 10/10: código íntegro y salida registrada</summary>

```python
"""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)
```

```text
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
```

</details>

## ¿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](/posts/vectorbt-nan-cvar-tail-sample-test-assertions/) y la [tasa de aciertos al seleccionar repetidamente una acción](/posts/quant-winrate-repeated-stock-picks/): 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.*
