diff --git a/cpp/matching_engine_core.hpp b/cpp/matching_engine_core.hpp index 339f720..9ee6086 100644 --- a/cpp/matching_engine_core.hpp +++ b/cpp/matching_engine_core.hpp @@ -40,17 +40,35 @@ class MatchingEngineCpp { int64_t price_ticks, int32_t size, int64_t order_id + ) { + return apply_event_code( + ts_ns, + action_code(action), + side, + price_ticks, + size, + order_id + ); + } + + std::vector apply_event_code( + int64_t ts_ns, + char action, + char side, + int64_t price_ticks, + int32_t size, + int64_t order_id ) { now_ns_ = ts_ns; const size_t before = passive_fills_.size(); - if (action == "add") { + if (action == 'A') { add_public(order_id, side, price_ticks, size); - } else if (action == "cancel") { + } else if (action == 'C') { cancel_public(order_id, side, price_ticks, size); - } else if (action == "modify") { + } else if (action == 'M') { modify_public(order_id, side, price_ticks, size); - } else if (action == "trade") { + } else if (action == 'T') { consume_level(side, price_ticks, size, ts_ns); } else { throw std::runtime_error("unknown MBO action"); @@ -188,6 +206,14 @@ class MatchingEngineCpp { static constexpr int64_t missing_price() { return -1; } + static char action_code(const std::string& action) { + if (action == "add") return 'A'; + if (action == "cancel") return 'C'; + if (action == "modify") return 'M'; + if (action == "trade") return 'T'; + throw std::runtime_error("unknown MBO action"); + } + static void validate_order(int32_t size, int64_t price_ticks) { if (size <= 0) throw std::runtime_error("size must be positive"); if (price_ticks <= 0) throw std::runtime_error("price must be positive"); diff --git a/cpp/matching_engine_cpp.cpp b/cpp/matching_engine_cpp.cpp index d405541..245d267 100644 --- a/cpp/matching_engine_cpp.cpp +++ b/cpp/matching_engine_cpp.cpp @@ -5,6 +5,105 @@ namespace py = pybind11; +namespace { + +template +const T* checked_buffer( + const py::buffer& buffer, + const std::string& expected_format, + const char* name, + py::ssize_t expected_size = -1 +) { + const py::buffer_info info = buffer.request(); + if (info.ndim != 1) { + throw py::value_error(std::string(name) + " must be one-dimensional"); + } + if (info.itemsize != static_cast(sizeof(T)) + || info.format != expected_format) { + throw py::value_error(std::string(name) + " has the wrong dtype"); + } + if (info.strides[0] != static_cast(sizeof(T))) { + throw py::value_error(std::string(name) + " must be contiguous"); + } + if (expected_size >= 0 && info.shape[0] != expected_size) { + throw py::value_error("batch columns must have equal length"); + } + return static_cast(info.ptr); +} + +std::vector apply_events_batch( + MatchingEngineCpp& engine, + const py::buffer& ts_ns, + const py::buffer& action, + const py::buffer& side, + const py::buffer& price_ticks, + const py::buffer& size, + const py::buffer& order_id +) { + const py::buffer_info ts_info = ts_ns.request(); + if (ts_info.ndim != 1) { + throw py::value_error("ts_ns must be one-dimensional"); + } + if (ts_info.itemsize != static_cast(sizeof(int64_t)) + || ts_info.format != py::format_descriptor::format()) { + throw py::value_error("ts_ns has the wrong dtype"); + } + if (ts_info.strides[0] != static_cast(sizeof(int64_t))) { + throw py::value_error("ts_ns must be contiguous"); + } + + const py::ssize_t n = ts_info.shape[0]; + const auto* ts_data = static_cast(ts_info.ptr); + const auto* action_data = checked_buffer( + action, + py::format_descriptor::format().c_str(), + "action", + n + ); + const auto* side_data = checked_buffer( + side, + py::format_descriptor::format().c_str(), + "side", + n + ); + const auto* price_data = checked_buffer( + price_ticks, + py::format_descriptor::format().c_str(), + "price_ticks", + n + ); + const auto* size_data = checked_buffer( + size, + py::format_descriptor::format().c_str(), + "size", + n + ); + const auto* order_data = checked_buffer( + order_id, + py::format_descriptor::format().c_str(), + "order_id", + n + ); + + std::vector fills; + + for (py::ssize_t i = 0; i < n; ++i) { + const auto event_fills = engine.apply_event_code( + ts_data[i], + static_cast(action_data[i]), + static_cast(side_data[i]), + price_data[i], + size_data[i], + order_data[i] + ); + fills.insert(fills.end(), event_fills.begin(), event_fills.end()); + } + + return fills; +} + +} // namespace + PYBIND11_MODULE(_matching_engine_cpp, module) { py::class_(module, "FillRow") .def_readonly("order_id", &FillRow::order_id) @@ -16,6 +115,7 @@ PYBIND11_MODULE(_matching_engine_cpp, module) { py::class_(module, "MatchingEngineCpp") .def(py::init<>()) .def("apply_event", &MatchingEngineCpp::apply_event) + .def("apply_events_batch", &apply_events_batch) .def("place_limit", &MatchingEngineCpp::place_limit) .def("place_market", &MatchingEngineCpp::place_market) .def("cancel", &MatchingEngineCpp::cancel) diff --git a/docs/execution-engines.md b/docs/execution-engines.md index cbe44b0..985516a 100644 --- a/docs/execution-engines.md +++ b/docs/execution-engines.md @@ -82,6 +82,20 @@ strategy has isolated order state while sharing the same immutable event stream. stores integer ticks; the Python wrapper accepts an explicit `tick_size` so the public API remains exact-`Decimal`. +For callers that already hold normalized events in memory, the wrapper also +exposes a compiled batch-ingest path. It accepts primitive columns derived from +the same `MBOEvent` schema and returns passive fills without changing the public +matching semantics. Ordinary `Replay(...)` still applies one event at a time so +it can record the per-event valuation marks that build the default equity curve. + +```python +from ordersim import CompiledEventColumns, CppMatchingEngine + +columns = CompiledEventColumns.from_events(events, tick_size=spec.tick_size) +engine = CppMatchingEngine(tick_size=spec.tick_size) +fills = engine.apply_events_batch(columns.slice(0, len(events))) +``` + Install a source checkout normally to build the extension: ```bash diff --git a/src/ordersim/__init__.py b/src/ordersim/__init__.py index da92397..e2be2a1 100644 --- a/src/ordersim/__init__.py +++ b/src/ordersim/__init__.py @@ -31,7 +31,7 @@ default_latency_model_factory, ) from ordersim.recording import RecordingGateway -from ordersim.replay import Replay, ReplayGateway, ReplayResult +from ordersim.replay import CompiledEventColumns, Replay, ReplayGateway, ReplayResult from ordersim.sim import ( CppMatchingEngine, ExecutionEngine, @@ -59,6 +59,7 @@ __all__ = [ "BookSide", + "CompiledEventColumns", "ConstantLatency", "CppMatchingEngine", "CsvSource", diff --git a/src/ordersim/replay/__init__.py b/src/ordersim/replay/__init__.py index bbcb147..3a8f767 100644 --- a/src/ordersim/replay/__init__.py +++ b/src/ordersim/replay/__init__.py @@ -1,5 +1,6 @@ """Replay orchestration for strategy functions.""" +from ordersim.replay.compiled_events import CompiledEventColumns from ordersim.replay.simulator import Replay, ReplayGateway, ReplayResult -__all__ = ["Replay", "ReplayGateway", "ReplayResult"] +__all__ = ["CompiledEventColumns", "Replay", "ReplayGateway", "ReplayResult"] diff --git a/src/ordersim/replay/compiled_events.py b/src/ordersim/replay/compiled_events.py new file mode 100644 index 0000000..9c072ec --- /dev/null +++ b/src/ordersim/replay/compiled_events.py @@ -0,0 +1,82 @@ +"""Internal columnar view of one canonical replay stream.""" + +from array import array +from dataclasses import dataclass +from decimal import Decimal + +from ordersim.types import MBOEvent, Price + +_ACTION_CODE = { + "add": ord("A"), + "cancel": ord("C"), + "modify": ord("M"), + "trade": ord("T"), +} +_SIDE_CODE = { + "bid": ord("B"), + "ask": ord("A"), +} + + +@dataclass(frozen=True, slots=True) +class CompiledEventColumns: + """Primitive event columns shared by repeated compiled-engine runs.""" + + ts_ns: array + action: array + side: array + price_ticks: array + size: array + order_id: array + + @classmethod + def from_events( + cls, + events: tuple[MBOEvent, ...], + *, + tick_size: Price, + ) -> "CompiledEventColumns": + """Compile immutable public events into integer columns once.""" + + return cls( + ts_ns=array("q", (event.ts_ns for event in events)), + action=array("B", (_ACTION_CODE[event.action] for event in events)), + side=array("B", (_SIDE_CODE[event.side] for event in events)), + price_ticks=array( + "q", + (_price_to_ticks(event.price, tick_size) for event in events), + ), + size=array("i", (event.size for event in events)), + order_id=array("q", (event.order_id for event in events)), + ) + + def slice(self, start: int, stop: int) -> "CompiledEventSlice": + """Return zero-copy memory views for one replay interval.""" + + return CompiledEventSlice( + ts_ns=memoryview(self.ts_ns)[start:stop], + action=memoryview(self.action)[start:stop], + side=memoryview(self.side)[start:stop], + price_ticks=memoryview(self.price_ticks)[start:stop], + size=memoryview(self.size)[start:stop], + order_id=memoryview(self.order_id)[start:stop], + ) + + +@dataclass(frozen=True, slots=True) +class CompiledEventSlice: + """Zero-copy primitive columns for one contiguous replay interval.""" + + ts_ns: memoryview + action: memoryview + side: memoryview + price_ticks: memoryview + size: memoryview + order_id: memoryview + + +def _price_to_ticks(price: Decimal, tick_size: Price) -> int: + ticks = price / tick_size + if ticks != ticks.to_integral_value(): + raise ValueError(f"price {price} is not aligned to tick_size {tick_size}") + return int(ticks) diff --git a/src/ordersim/sim/cpp_matching_engine.py b/src/ordersim/sim/cpp_matching_engine.py index 5ad4dd4..18dae8d 100644 --- a/src/ordersim/sim/cpp_matching_engine.py +++ b/src/ordersim/sim/cpp_matching_engine.py @@ -3,6 +3,7 @@ from decimal import Decimal from typing import Any +from ordersim.replay.compiled_events import CompiledEventSlice from ordersim.sim.matching_engine import PriceLevel from ordersim.types import ( Fill, @@ -36,6 +37,22 @@ def apply_event(self, event: MBOEvent) -> list[Fill]: ) return [self._fill_from_row(row) for row in rows] + def apply_events_batch( + self, + events: CompiledEventSlice, + ) -> list[Fill]: + """Apply one compiled event slice and return passive fills.""" + + rows = self._core.apply_events_batch( + events.ts_ns, + events.action, + events.side, + events.price_ticks, + events.size, + events.order_id, + ) + return [self._fill_from_row(row) for row in rows] + def advance_time(self, ts_ns: int) -> None: self._core.advance_time(ts_ns) @@ -141,8 +158,8 @@ def _load_cpp_module() -> Any: from ordersim import _matching_engine_cpp except ImportError as exc: raise ImportError( - "ordersim C++ engine is not built; run " - "`python setup_cpp.py build_ext --inplace` first" + "ordersim C++ engine is not built; install the project normally " + 'with `python -m pip install -e ".[dev]"` first' ) from exc return _matching_engine_cpp diff --git a/tests/test_cpp_execution_engine.py b/tests/test_cpp_execution_engine.py index eadf051..164b8a6 100644 --- a/tests/test_cpp_execution_engine.py +++ b/tests/test_cpp_execution_engine.py @@ -8,6 +8,7 @@ MBOEvent, cpp_execution_engine_available, ) +from ordersim.replay.compiled_events import CompiledEventColumns from ordersim.sim import cpp_matching_engine from ordersim.testing import assert_execution_equivalence_suite @@ -85,3 +86,62 @@ def test_cpp_execution_engine_exposes_decimal_book_state() -> None: bids, asks = engine.book_depth(1) assert bids[0].price == Decimal("100.00") assert asks[0].price == Decimal("101.00") + + +def test_cpp_execution_engine_applies_compiled_event_batches() -> None: + events = ( + MBOEvent( + ts_ns=1, + action="add", + side="bid", + price=Decimal("100.0"), + size=1, + order_id=1, + ), + MBOEvent( + ts_ns=2, + action="add", + side="ask", + price=Decimal("101.0"), + size=1, + order_id=2, + ), + MBOEvent( + ts_ns=3, + action="trade", + side="bid", + price=Decimal("100.0"), + size=2, + order_id=3, + ), + ) + columns = CompiledEventColumns.from_events(events, tick_size=Decimal("0.10")) + engine = CppMatchingEngine(tick_size=Decimal("0.10")) + + resting = engine.place_limit(side="buy", price=Decimal("100.0"), size=1) + fills = engine.apply_events_batch(columns.slice(0, len(events))) + + assert resting.order_id is not None + fill_rows = [ + (fill.order_id, fill.side, fill.price, fill.size, fill.ts_ns) + for fill in fills + ] + assert fill_rows == [ + (resting.order_id, "buy", Decimal("100.00"), 1, 3), + ] + + +def test_compiled_event_columns_reject_unaligned_prices() -> None: + events = ( + MBOEvent( + ts_ns=1, + action="add", + side="ask", + price=Decimal("101.05"), + size=1, + order_id=1, + ), + ) + + with pytest.raises(ValueError, match="not aligned"): + CompiledEventColumns.from_events(events, tick_size=Decimal("0.10"))