Merge pull request #19 from manifoldbt/bench/hft-nautilus-linux

bench: build the Nautilus data objects at the scale of the installed wheel
This commit is contained in:
Exocet92
2026-10-23 23:06:58 +02:00
committed by Jimmy7892
4 changed files with 123 additions and 19 deletions
+4 -1
View File
@@ -49,7 +49,10 @@ them in its own idiom:
risk-adverse queue model, constant order latency.
* **NautilusTrader**: a `Strategy` subclass per rule on a `BacktestEngine`:
`OrderBookDelta` (L2_MBP / L3_MBO) or `QuoteTick` (L1_MBP) plus `TradeTick`,
`queue_position=True`, `trade_execution=True`, a `LatencyModel`.
`queue_position=True`, `trade_execution=True`, a `LatencyModel`. The data objects are
built from raw integers, scaled to the fixed precision of the installed wheel (9
decimals on Windows, 16 on Linux), which the adapter reads from Nautilus rather than
assuming; the journals of the six workloads are identical on both.
## The rule this harness is built around
+49 -16
View File
@@ -16,16 +16,41 @@ import numpy as np
import spec
PX_PREC, SZ_PREC = 1, 6
PX_UNIT = 10 ** (9 - PX_PREC) # raw = value x 1e9 at the venue's precision
SZ_UNIT = 10 ** (9 - SZ_PREC)
CHUNK = 400_000
# One price tick and one quantity step as a raw integer. They depend on the build of
# Nautilus, so _imports() sets them (see raw_unit).
PX_UNIT = SZ_UNIT = None
def raw_unit(precision):
"""The raw integer of one unit of the last decimal at `precision`, on this build of Nautilus.
The data objects built with `from_raw` take the value scaled to the build's fixed precision, not
to the instrument's: 9 decimals on the standard wheel (Windows), 16 on the high-precision wheel
(Linux). A scale written as 10 ** 9 is right on one and 10 ** 7 too small on the other, and
nothing complains: prices read as zero, the books look empty, a market order fills a quantity
that rounds to nothing and the position opens flat (an AssertionError in PositionOpened)."""
from nautilus_trader.model.objects import FIXED_PRECISION
return 10 ** (FIXED_PRECISION - precision)
def check_raw_scale(px_prec, sz_prec):
"""Refuse to run if a raw integer built with raw_unit is not read back as the value it stands for."""
from nautilus_trader.model.objects import Price, Quantity
for cls, prec in ((Price, px_prec), (Quantity, sz_prec)):
steps = 123_456
got = cls.from_raw(steps * raw_unit(prec), prec).as_double()
want = steps / 10 ** prec
if abs(got - want) > 1e-9 * want:
raise RuntimeError(f"raw scale: {cls.__name__} at {prec} decimals reads {got}, expected {want}")
def _imports():
global BacktestEngine, BacktestEngineConfig, FillModel, LatencyModel, LoggingConfig, StrategyConfig
global BTC, USDT, OrderBookDelta, QuoteTick, TradeTick, AccountType, AggressorSide, BookAction
global BookType, OmsType, OrderSide, RecordFlag, TimeInForce, InstrumentId, Symbol, TradeId, Venue
global CurrencyPair, Money, Price, Quantity, Strategy
global CurrencyPair, Money, Price, Quantity, Strategy, PX_UNIT, SZ_UNIT
from nautilus_trader.backtest.engine import BacktestEngine, BacktestEngineConfig
from nautilus_trader.backtest.models import FillModel, LatencyModel
from nautilus_trader.config import LoggingConfig, StrategyConfig
@@ -37,6 +62,8 @@ def _imports():
from nautilus_trader.model.instruments import CurrencyPair
from nautilus_trader.model.objects import Money, Price, Quantity
from nautilus_trader.trading.strategy import Strategy
check_raw_scale(PX_PREC, SZ_PREC)
PX_UNIT, SZ_UNIT = raw_unit(PX_PREC), raw_unit(SZ_PREC)
def instrument():
@@ -66,13 +93,16 @@ def stream(d, tier, iid, feed_ns, conv_clock):
conv_clock[0] accumulates the seconds spent converting."""
t0 = time.perf_counter()
n_t = len(d["tr_ts"])
tr_px = (np.round(d["tr_px"] * 10 ** PX_PREC).astype(np.int64) * PX_UNIT)
tr_sz = (np.round(d["tr_qty"] * 10 ** SZ_PREC).astype(np.int64) * SZ_UNIT)
# Ticks (price x 10^PX_PREC) and steps (quantity x 10^SZ_PREC) as int64. They become raw integers
# (x PX_UNIT, x SZ_UNIT) one by one as Python ints: on the high-precision build a raw price is
# 128 bits wide and an int64 array would wrap around without a word.
tr_px = np.round(d["tr_px"] * 10 ** PX_PREC).astype(np.int64)
tr_sz = np.round(d["tr_qty"] * 10 ** SZ_PREC).astype(np.int64)
if tier == "l2":
n_d = len(d["d25_ts"])
ts_d = d["d25_ts"]
px_d = d["d25_px"] * PX_UNIT # ticks (= price x 10^PX_PREC) -> raw
sz_d = (np.round(d["d25_qty"] * 10 ** SZ_PREC).astype(np.int64) * SZ_UNIT)
px_d = d["d25_px"]
sz_d = np.round(d["d25_qty"] * 10 ** SZ_PREC).astype(np.int64)
side_d, last_d = d["d25_side"], d["d25_last"]
else:
n_d = len(d["q_ts"])
@@ -87,10 +117,10 @@ def stream(d, tier, iid, feed_ns, conv_clock):
F_LAST = int(RecordFlag.F_LAST)
feed = int(feed_ns)
if tier != "l2":
q_bp = d["q_bpx"] * PX_UNIT
q_ap = d["q_apx"] * PX_UNIT
q_bq = (np.round(d["q_bqty"] * 10 ** SZ_PREC).astype(np.int64) * SZ_UNIT)
q_aq = (np.round(d["q_aqty"] * 10 ** SZ_PREC).astype(np.int64) * SZ_UNIT)
q_bp, q_ap = d["q_bpx"], d["q_apx"]
q_bq = np.round(d["q_bqty"] * 10 ** SZ_PREC).astype(np.int64)
q_aq = np.round(d["q_aqty"] * 10 ** SZ_PREC).astype(np.int64)
pu, su = PX_UNIT, SZ_UNIT
for a in range(0, len(order), CHUNK):
t0 = time.perf_counter()
out = []
@@ -102,14 +132,14 @@ def stream(d, tier, iid, feed_ns, conv_clock):
if tier == "l2":
q = int(sz_d[j])
push(Delta(iid, dele if q == 0 else upd, buy if side_d[j] == 0 else sell,
int(px_d[j]), PX_PREC, q, SZ_PREC, 0, F_LAST if last_d[j] else 0, 0, t, t + feed))
int(px_d[j]) * pu, PX_PREC, q * su, SZ_PREC, 0, F_LAST if last_d[j] else 0, 0, t, t + feed))
else:
push(Quote(iid, int(q_bp[j]), int(q_ap[j]), PX_PREC, PX_PREC, int(q_bq[j]), int(q_aq[j]),
SZ_PREC, SZ_PREC, t, t + feed))
push(Quote(iid, int(q_bp[j]) * pu, int(q_ap[j]) * pu, PX_PREC, PX_PREC, int(q_bq[j]) * su,
int(q_aq[j]) * su, SZ_PREC, SZ_PREC, t, t + feed))
else:
i = j - n_d
t = int(d["tr_ts"][i])
push(Trade(iid, int(tr_px[i]), PX_PREC, int(tr_sz[i]), SZ_PREC,
push(Trade(iid, int(tr_px[i]) * pu, PX_PREC, int(tr_sz[i]) * su, SZ_PREC,
a_buy if d["tr_side"][i] == 0 else a_sell, TradeId(str(i)), t, t + feed))
conv_clock[0] += time.perf_counter() - t0
yield out
@@ -380,7 +410,10 @@ def _make_taker_class():
self.sent_pos = target
def on_order_filled(self, event):
self.fills.append((event.ts_event, event.last_px.as_double(), event.last_qty.as_double(),
# as_double() of a 128-bit raw price is off by one unit in the last place on some prices
# (high-precision build); the price is a whole number of ticks, so round it back to one
# and both builds hand over the same double.
self.fills.append((event.ts_event, round(event.last_px.as_double(), PX_PREC), event.last_qty.as_double(),
1 if event.order_side == OrderSide.BUY else -1))
def on_order_rejected(self, event):
+5 -2
View File
@@ -15,8 +15,10 @@ import engine_nt as N
import spec
L3_PX_PREC, L3_SZ_PREC = 4, 0
L3_PX_UNIT = 10 ** (9 - L3_PX_PREC) # ITCH prices are in 1e-4 units: raw = price x 1e5
L3_SZ_UNIT = 10 ** (9 - L3_SZ_PREC)
# ITCH prices are in 1e-4 units and sizes in shares: raw = ticks x unit, the unit being that of the build
# of Nautilus (see engine_nt.raw_unit), not a constant.
L3_PX_UNIT = N.raw_unit(L3_PX_PREC)
L3_SZ_UNIT = N.raw_unit(L3_SZ_PREC)
def l3_instrument(symbol):
@@ -181,6 +183,7 @@ def _make_maker_class():
def run_mbo(m, t_open, t_close, strategy=True):
N._imports()
N.check_raw_scale(L3_PX_PREC, L3_SZ_PREC)
from nautilus_trader.model.currencies import USD
Maker = _make_maker_class()
+65
View File
@@ -0,0 +1,65 @@
"""The raw scale of the NautilusTrader adapter, on whichever build of Nautilus is installed.
python -m pytest benchmarks/vs_hft/test_nt_scale.py -q
The standard wheel (Windows) keeps prices and quantities on 9 decimals and the
high-precision wheel (Linux) on 16. The adapter builds its data objects from raw integers,
so a scale written for one build is wrong on the other without any error: prices read as
zero and nothing trades. These tests read the objects back.
"""
import os
import sys
import pytest
np = pytest.importorskip("numpy")
pytest.importorskip("nautilus_trader")
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import engine_nt as N # noqa: E402
def _data():
return {
"tr_ts": np.array([2_000], dtype=np.int64), "tr_px": np.array([120101.7]),
"tr_qty": np.array([0.000123]), "tr_side": np.array([0], dtype=np.int8),
"q_ts": np.array([1_000], dtype=np.int64), "q_bpx": np.array([1201016], dtype=np.int64),
"q_apx": np.array([1201017], dtype=np.int64), "q_bqty": np.array([1.5]), "q_aqty": np.array([2.25]),
"d25_ts": np.array([1_000], dtype=np.int64), "d25_px": np.array([1201016], dtype=np.int64),
"d25_qty": np.array([0.75]), "d25_side": np.array([0], dtype=np.int8), "d25_last": np.array([1], dtype=np.int8),
}
def _objects(tier):
N._imports()
_, iid, _ = N.instrument()
(chunk,) = list(N.stream(_data(), tier, iid, 0, [0.0]))
return chunk
def test_raw_units_follow_the_build():
N._imports()
from nautilus_trader.model.objects import FIXED_PRECISION
assert N.PX_UNIT == 10 ** (FIXED_PRECISION - N.PX_PREC)
assert N.SZ_UNIT == 10 ** (FIXED_PRECISION - N.SZ_PREC)
N.check_raw_scale(N.PX_PREC, N.SZ_PREC)
N.check_raw_scale(4, 0)
def test_best_bid_ask_ticks_and_trades_read_back():
quote, trade = _objects("bbo")
assert quote.bid_price.as_double() == pytest.approx(120101.6, rel=1e-12)
assert quote.ask_price.as_double() == pytest.approx(120101.7, rel=1e-12)
assert quote.bid_size.as_double() == pytest.approx(1.5, rel=1e-12)
assert quote.ask_size.as_double() == pytest.approx(2.25, rel=1e-12)
assert trade.price.as_double() == pytest.approx(120101.7, rel=1e-12)
assert trade.size.as_double() == pytest.approx(0.000123, rel=1e-12)
def test_book_deltas_read_back():
delta, trade = _objects("l2")
assert delta.order.price.as_double() == pytest.approx(120101.6, rel=1e-12)
assert delta.order.size.as_double() == pytest.approx(0.75, rel=1e-12)
assert trade.price.as_double() == pytest.approx(120101.7, rel=1e-12)