Add direct Python Cache top-of-book access without cloning the full OrderBook

Author: VasiliiRumiantcevCreated Aug 29, 2026Updated Sep 18, 2026
Labelsenhancement

Feature Request

  • I've searched existing issues and discussions to avoid duplicates.

Problem statement

The Cache.order_book() Python binding returns an owned snapshot by cloning the resident OrderBook:

rust
#[pyo3(name = "order_book")]
fn py_order_book(&self, instrument_id: InstrumentId) -> Option<OrderBook> {
    self.0.borrow().order_book(&instrument_id).cloned()
}

This matches the documented CacheApi ownership model: order_book() provides an owned snapshot, which is useful when the caller needs the full book. The same documentation suggests preferring lighter methods in hot paths when a full snapshot is not needed.

For callers that already have a managed order book maintained by the engine and only need best bid/ask price and size, there is currently no direct lightweight accessor for those values. order_book() clones the full resident book, so a strategy reading BBO through this path on every book update pays a cost that grows with depth.

The existing cache.quote() path is lightweight, but it requires quote data to be present, or emit_quotes_from_book=True to derive quotes from the book.

Measurement

The repro_v2.py below is self-contained and generates its own synthetic L2 data. Each run consists of one seed batch plus 10,000 single-level update batches (10,001 book callbacks) at a fixed resident depth. The reported time is the total engine.run() time divided by the number of book callbacks. The reported figures are medians of three complete script runs on the same machine.

On 2.0.0rc4.dev20260829+18016:

depth per side empty callback order_book() + BBO order_book() discarded cache.quote()
50 0.90 us 7.39 us 5.20 us 1.97 us
500 0.93 us 58.80 us 53.41 us 2.10 us
2000 1.26 us 255.34 us 250.91 us 2.30 us
8000 2.51 us 1755.34 us 1705.33 us 3.09 us

The discard case closely matches the BBO case. At 8,000 levels per side, order_book() takes 1705.33 us when the returned book is immediately discarded and 1755.34 us when BBO is also read. This indicates that the dominant depth-dependent cost is obtaining the owned snapshot rather than reading top-of-book from it.

2.0.0rc3 from PyPI shows the same shape (7.43 / 60.07 / 272.09 / 1921.89 us).

repro_v1.py on 1.230.0 is the v1 control, reading the same four values, and does not show the same depth-dependent scaling: 2.84 / 2.89 / 3.41 / 4.97 us across the same four depths, against an empty callback of 3.41 / 2.68 / 3.02 / 4.56 us.

Proposed solution

One possible approach would be to keep Cache.order_book() unchanged and add a lightweight top-of-book accessor to the Python cache API. For example:

rust
#[pyo3(name = "top_of_book")]
fn py_top_of_book(
    &self,
    instrument_id: InstrumentId,
) -> Option<(Price, Quantity, Price, Quantity)> { ... }

The implementation could take one short immutable cache borrow and return best bid price/size and best ask price/size without cloning the full book. A single accessor would require only one cache borrow and one Python/PyO3 call for the complete top-of-book value.

Example usage

python
top = self.cache.top_of_book(self.instrument_id)
if top is not None:
    bid_price, bid_size, ask_price, ask_size = top

Alternatives considered

DataEngineConfig(emit_quotes_from_book=True) with cache.quote() works today and is the last column above, approximately flat with depth. This requires enabling derived quote generation in the DataEngine, while a direct accessor would provide a point read from the managed book without requiring that additional pipeline behavior.

subscribe_book_at_interval can reduce how often a full book is delivered, but dispatch_on_book still clones the OrderBook for the Python callback, so it does not provide a lightweight point read.

Happy to open a PR if this direction looks right. I'm also happy to use a different API shape if there is a better fit with the existing cache design.

repro_v2.py
python
"""Minimal reproducible example: Cache.order_book() cost scales with book depth.

Self-contained: generates its own synthetic L2 data, no market data files needed.
Run against nautilus_trader 2.0.0rc3 or later.

    python repro_v2.py
"""

from __future__ import annotations

import hashlib
import time
from decimal import Decimal

from nautilus_trader.backtest import BacktestEngine, BacktestEngineConfig
from nautilus_trader.common import LoggerConfig
from nautilus_trader.model import (
    AccountType,
    BookAction,
    BookOrder,
    BookType,
    Currency,
    CurrencyPair,
    InstrumentId,
    Money,
    OmsType,
    OrderBookDelta,
    OrderBookDeltas,
    OrderSide,
    Price,
    Quantity,
    StrategyId,
    Symbol,
    TraderId,
    Venue,
)
from nautilus_trader.trading import Strategy, StrategyConfig

VENUE = Venue("SIM")
INSTRUMENT_ID = InstrumentId(Symbol("BTCUSDT"), VENUE)
START_NS = 1_700_000_000_000_000_000
F_LAST = 1 << 7
BTC, USDT = Currency.from_str("BTC"), Currency.from_str("USDT")
N_EVENTS = 10_000


class Probe(Strategy):
    """Reads best bid/ask on every book update, exactly as a quoting strategy would."""

    def __init__(self, mode: str) -> None:
        super().__init__(StrategyConfig(strategy_id=StrategyId("PROBE-001")))
        self.mode = mode
        self.callbacks = 0
        self.digest = hashlib.sha256()

    def on_start(self) -> None:
        self.subscribe_book_deltas(INSTRUMENT_ID, BookType.L2_MBP, managed=True)

    def on_book_deltas(self, deltas: OrderBookDeltas) -> None:
        del deltas
        self.callbacks += 1
        mode = self.mode
        if mode == "empty":
            return
        if mode == "discard":  # ablation: clone only, never read BBO
            self.cache.order_book(INSTRUMENT_ID)
            return
        if mode == "quote":  # workaround: emit_quotes_from_book + cache.quote()
            q = self.cache.quote(INSTRUMENT_ID)
            if q is not None:
                self.digest.update(
                    f"{q.bid_price}|{q.bid_size}|{q.ask_price}|{q.ask_size}\n".encode()
                )
            return
        book = self.cache.order_book(INSTRUMENT_ID)  # mode == "book"
        if book is not None:
            values = (
                book.best_bid_price(),
                book.best_bid_size(),
                book.best_ask_price(),
                book.best_ask_size(),
            )
            if None not in values:
                self.digest.update("|".join(str(v) for v in values).encode() + b"\n")


def make_instrument() -> CurrencyPair:
    return CurrencyPair(
        instrument_id=INSTRUMENT_ID,
        raw_symbol=Symbol("BTCUSDT"),
        base_currency=BTC,
        quote_currency=USDT,
        price_precision=2,
        size_precision=6,
        price_increment=Price.from_str("0.01"),
        size_increment=Quantity.from_str("0.000001"),
        ts_event=START_NS,
        ts_init=START_NS,
        margin_init=Decimal(0),
        margin_maint=Decimal(0),
        maker_fee=Decimal(0),
        taker_fee=Decimal(0),
    )


def build_data(depth: int, n_events: int) -> list[OrderBookDeltas]:
    """One seeding batch installing `depth` levels per side, then single-level updates."""
    mid, tick, seq, ts = 50_000.00, 0.01, 1, START_NS
    out: list[OrderBookDeltas] = []

    seed: list[OrderBookDelta] = []
    for i in range(depth):
        for side, px in (
            (OrderSide.BUY, round(mid - tick * (i + 1), 2)),
            (OrderSide.SELL, round(mid + tick * (i + 1), 2)),
        ):
            seed.append(
                OrderBookDelta(
                    INSTRUMENT_ID, BookAction.ADD,
                    BookOrder(side, Price(px, 2), Quantity(1.0, 6), 0),
                    0, seq, ts, ts,
                )
            )
            seq += 1
    last = seed[-1]
    seed[-1] = OrderBookDelta(
        INSTRUMENT_ID, last.action, last.order, F_LAST, last.sequence, ts, ts
    )
    out.append(OrderBookDeltas(INSTRUMENT_ID, seed))

    for k in range(n_events):
        ts += 1_000_000
        px = round(mid - tick * ((k % depth) + 1), 2)
        out.append(
            OrderBookDeltas(
                INSTRUMENT_ID,
                [
                    OrderBookDelta(
                        INSTRUMENT_ID, BookAction.UPDATE,
                        BookOrder(OrderSide.BUY, Price(px, 2),
                                  Quantity(1.0 + (k % 7) * 0.1, 6), 0),
                        F_LAST, seq + k, ts, ts,
                    )
                ],
            )
        )
    return out


def run(depth: int, mode: str) -> tuple[float, str, int]:
    from nautilus_trader.data import DataEngineConfig

    engine = BacktestEngine(
        BacktestEngineConfig(
            trader_id=TraderId("PROBE-001"),
            logging=LoggerConfig(bypass_logging=True),
            run_analysis=False,
            data_engine=DataEngineConfig(emit_quotes_from_book=(mode == "quote")),
        )
    )
    engine.add_venue(
        venue=VENUE,
        oms_type=OmsType.NETTING,
        account_type=AccountType.CASH,
        starting_balances=[Money(10, BTC), Money(1_000_000, USDT)],
        base_currency=None,
        book_type=BookType.L2_MBP,
    )
    engine.add_instrument(make_instrument())
    strategy = Probe(mode)
    engine.add_strategy(strategy)
    engine.add_data(build_data(depth, N_EVENTS))

    t0 = time.perf_counter_ns()
    engine.run()
    elapsed = (time.perf_counter_ns() - t0) / 1e9
    per_cb = elapsed / strategy.callbacks * 1e6
    digest = strategy.digest.hexdigest()
    calls = strategy.callbacks
    engine.dispose()
    return per_cb, digest, calls


if __name__ == "__main__":
    import nautilus_trader

    print(f"nautilus_trader {nautilus_trader.__version__}")
    print(f"one seed batch + {N_EVENTS} update batches = {N_EVENTS + 1} book callbacks per run\n")
    hdr = ("depth", "empty", "order_book+BBO", "order_book discarded", "cache.quote", "ratio")
    print(f"{hdr[0]:>7} {hdr[1]:>9} {hdr[2]:>16} {hdr[3]:>22} {hdr[4]:>13} {hdr[5]:>8}")
    digests = {}
    for depth in (50, 500, 2000, 8000):
        empty, _, calls = run(depth, "empty")
        book, d_book, _ = run(depth, "book")
        disc, _, _ = run(depth, "discard")
        quote, d_quote, _ = run(depth, "quote")
        digests[depth] = (d_book, d_quote)
        print(f"{depth:>7} {empty:>8.2f}u {book:>15.2f}u {disc:>21.2f}u "
              f"{quote:>12.2f}u {book / empty:>7.1f}x")
    print(f"\ncallbacks per run: {calls}")
    print("top-of-book (bid px/size, ask px/size) identical, order_book() vs cache.quote():")
    for depth, (a, b) in digests.items():
        print(f"  depth {depth:>5}: {'MATCH' if a == b else 'DIFFER'}  sha256={a[:16]}...")
repro_v1.py (control)
python
"""Control: the same probe on nautilus_trader 1.230.0 (Cython v1 core).

Self-contained: generates its own synthetic L2 data.
Expected: cost per callback is flat in book depth.

    python repro_v1.py
"""

from __future__ import annotations

import time
from decimal import Decimal

from nautilus_trader.backtest.engine import BacktestEngine, BacktestEngineConfig
from nautilus_trader.config import LoggingConfig, StrategyConfig
from nautilus_trader.model.currencies import BTC, USDT
from nautilus_trader.model.data import BookOrder, OrderBookDelta, OrderBookDeltas
from nautilus_trader.model.enums import AccountType, BookAction, BookType, OmsType, OrderSide
from nautilus_trader.model.identifiers import InstrumentId, Symbol, TraderId, Venue
from nautilus_trader.model.instruments import CurrencyPair
from nautilus_trader.model.objects import Money, Price, Quantity
from nautilus_trader.trading.strategy import Strategy

VENUE = Venue("SIM")
INSTRUMENT_ID = InstrumentId(Symbol("BTCUSDT"), VENUE)
START_NS = 1_700_000_000_000_000_000
F_LAST = 1 << 7
N_EVENTS = 10_000


class Probe(Strategy):
    """Reads best bid/ask on every book update, exactly as a quoting strategy would."""

    def __init__(self, read_book: bool) -> None:
        super().__init__(StrategyConfig(strategy_id="PROBE-001"))
        self.read_book = read_book
        self.callbacks = 0

    def on_start(self) -> None:
        self.subscribe_order_book_deltas(INSTRUMENT_ID, BookType.L2_MBP, managed=True)

    def on_order_book_deltas(self, deltas: OrderBookDeltas) -> None:
        del deltas
        self.callbacks += 1
        if not self.read_book:
            return
        book = self.cache.order_book(INSTRUMENT_ID)
        if book is not None:
            book.best_bid_price()
            book.best_bid_size()
            book.best_ask_price()
            book.best_ask_size()


def make_instrument() -> CurrencyPair:
    return CurrencyPair(
        instrument_id=INSTRUMENT_ID,
        raw_symbol=Symbol("BTCUSDT"),
        base_currency=BTC,
        quote_currency=USDT,
        price_precision=2,
        size_precision=6,
        price_increment=Price.from_str("0.01"),
        size_increment=Quantity.from_str("0.000001"),
        ts_event=START_NS,
        ts_init=START_NS,
        margin_init=Decimal(0),
        margin_maint=Decimal(0),
        maker_fee=Decimal(0),
        taker_fee=Decimal(0),
    )


def build_data(depth: int, n_events: int) -> list[OrderBookDeltas]:
    """One seeding batch installing `depth` levels per side, then single-level updates."""
    mid, tick, seq, ts = 50_000.00, 0.01, 1, START_NS
    out: list[OrderBookDeltas] = []

    seed: list[OrderBookDelta] = []
    for i in range(depth):
        for side, px in (
            (OrderSide.BUY, round(mid - tick * (i + 1), 2)),
            (OrderSide.SELL, round(mid + tick * (i + 1), 2)),
        ):
            seed.append(
                OrderBookDelta(
                    INSTRUMENT_ID, BookAction.ADD,
                    BookOrder(side, Price(px, 2), Quantity(1.0, 6), 0),
                    0, seq, ts, ts,
                )
            )
            seq += 1
    last = seed[-1]
    seed[-1] = OrderBookDelta(
        INSTRUMENT_ID, last.action, last.order, F_LAST, last.sequence, ts, ts
    )
    out.append(OrderBookDeltas(INSTRUMENT_ID, seed))

    for k in range(n_events):
        ts += 1_000_000
        px = round(mid - tick * ((k % depth) + 1), 2)
        out.append(
            OrderBookDeltas(
                INSTRUMENT_ID,
                [
                    OrderBookDelta(
                        INSTRUMENT_ID, BookAction.UPDATE,
                        BookOrder(OrderSide.BUY, Price(px, 2),
                                  Quantity(1.0 + (k % 7) * 0.1, 6), 0),
                        F_LAST, seq + k, ts, ts,
                    )
                ],
            )
        )
    return out


def run(depth: int, read_book: bool) -> float:
    engine = BacktestEngine(
        BacktestEngineConfig(
            trader_id=TraderId("PROBE-001"),
            logging=LoggingConfig(bypass_logging=True),
            run_analysis=False,
        )
    )
    engine.add_venue(
        venue=VENUE,
        oms_type=OmsType.NETTING,
        account_type=AccountType.CASH,
        starting_balances=[Money(10, BTC), Money(1_000_000, USDT)],
        base_currency=None,
        book_type=BookType.L2_MBP,
    )
    engine.add_instrument(make_instrument())
    strategy = Probe(read_book)
    engine.add_strategy(strategy)
    engine.add_data(build_data(depth, N_EVENTS))

    t0 = time.perf_counter_ns()
    engine.run()
    elapsed = (time.perf_counter_ns() - t0) / 1e9
    per_cb = elapsed / strategy.callbacks * 1e6
    engine.dispose()
    return per_cb


if __name__ == "__main__":
    import nautilus_trader

    print(f"nautilus_trader {nautilus_trader.__version__}")
    print(f"one seed batch + {N_EVENTS} update batches = {N_EVENTS + 1} book callbacks per run\n")
    print(f"{'depth':>8} {'empty cb (us)':>15} {'+ order_book() (us)':>21} {'ratio':>8}")
    for depth in (50, 500, 2000, 8000):
        base = run(depth, read_book=False)
        with_book = run(depth, read_book=True)
        print(f"{depth:>8} {base:>15.2f} {with_book:>21.2f} {with_book / base:>7.1f}x")

Environment:

  • OS platform: Linux (WSL2, kernel 6.6.87.2-microsoft-standard-WSL2)
  • Python version: 3.13.7
  • nautilus_trader version: 2.0.0rc3 and 2.0.0rc4.dev20260829+18016
  • Installed from: PyPI wheel (--pre) and package index wheel (development)
  • Adapter/venue: backtest, SimulatedExchange, L2_MBP

Source: nautechsystems/nautilus_trader