Add direct Python Cache top-of-book access without cloning the full OrderBook
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:
#[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:
#[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
top = self.cache.top_of_book(self.instrument_id)
if top is not None:
bid_price, bid_size, ask_price, ask_size = topAlternatives 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"""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)"""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_traderversion: 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