diff --git a/cpp/matching_engine_cpp.cpp b/cpp/matching_engine_cpp.cpp index 245d267..008f97c 100644 --- a/cpp/matching_engine_cpp.cpp +++ b/cpp/matching_engine_cpp.cpp @@ -7,6 +7,16 @@ namespace py = pybind11; namespace { +struct BatchBuffers { + py::ssize_t n; + const int64_t* ts_ns; + const uint8_t* action; + const uint8_t* side; + const int64_t* price_ticks; + const int32_t* size; + const int64_t* order_id; +}; + template const T* checked_buffer( const py::buffer& buffer, @@ -31,8 +41,7 @@ const T* checked_buffer( return static_cast(info.ptr); } -std::vector apply_events_batch( - MatchingEngineCpp& engine, +BatchBuffers read_batch_buffers( const py::buffer& ts_ns, const py::buffer& action, const py::buffer& side, @@ -53,48 +62,70 @@ std::vector apply_events_batch( } const py::ssize_t n = ts_info.shape[0]; - const auto* ts_data = static_cast(ts_info.ptr); - const auto* action_data = checked_buffer( + return BatchBuffers{ + n, + static_cast(ts_info.ptr), + checked_buffer( + action, + py::format_descriptor::format().c_str(), + "action", + n + ), + checked_buffer( + side, + py::format_descriptor::format().c_str(), + "side", + n + ), + checked_buffer( + price_ticks, + py::format_descriptor::format().c_str(), + "price_ticks", + n + ), + checked_buffer( + size, + py::format_descriptor::format().c_str(), + "size", + n + ), + checked_buffer( + order_id, + py::format_descriptor::format().c_str(), + "order_id", + n + ), + }; +} + +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 BatchBuffers batch = read_batch_buffers( + ts_ns, 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 + order_id ); std::vector fills; - for (py::ssize_t i = 0; i < n; ++i) { + for (py::ssize_t i = 0; i < batch.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] + batch.ts_ns[i], + static_cast(batch.action[i]), + static_cast(batch.side[i]), + batch.price_ticks[i], + batch.size[i], + batch.order_id[i] ); fills.insert(fills.end(), event_fills.begin(), event_fills.end()); } @@ -102,6 +133,44 @@ std::vector apply_events_batch( return fills; } +py::tuple apply_events_until_fill( + 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 BatchBuffers batch = read_batch_buffers( + ts_ns, + action, + side, + price_ticks, + size, + order_id + ); + + std::vector fills; + + for (py::ssize_t i = 0; i < batch.n; ++i) { + const auto event_fills = engine.apply_event_code( + batch.ts_ns[i], + static_cast(batch.action[i]), + static_cast(batch.side[i]), + batch.price_ticks[i], + batch.size[i], + batch.order_id[i] + ); + if (!event_fills.empty()) { + fills.insert(fills.end(), event_fills.begin(), event_fills.end()); + return py::make_tuple(i + 1, fills); + } + } + + return py::make_tuple(batch.n, fills); +} + } // namespace PYBIND11_MODULE(_matching_engine_cpp, module) { @@ -116,6 +185,7 @@ PYBIND11_MODULE(_matching_engine_cpp, module) { .def(py::init<>()) .def("apply_event", &MatchingEngineCpp::apply_event) .def("apply_events_batch", &apply_events_batch) + .def("apply_events_until_fill", &apply_events_until_fill) .def("place_limit", &MatchingEngineCpp::place_limit) .def("place_market", &MatchingEngineCpp::place_market) .def("cancel", &MatchingEngineCpp::cancel) diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 7ccbcab..73512c8 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -88,5 +88,10 @@ up the auditability of `ReplayResult`. It also keeps fill-connected strategies honest: Python should regain control when execution state changes in a way the strategy can observe. +The first engine primitive for that design is +`CppMatchingEngine.apply_events_until_fill(...)`. It consumes a compiled slice +until the first passive fill boundary and returns the event count needed to +resume replay at the correct row. + CI should keep benchmark code runnable; it should not enforce fixed speed thresholds across hardware. diff --git a/docs/execution-engines.md b/docs/execution-engines.md index 3e9a9ab..b8e59d6 100644 --- a/docs/execution-engines.md +++ b/docs/execution-engines.md @@ -99,6 +99,23 @@ engine = CppMatchingEngine(tick_size=spec.tick_size) fills = engine.apply_events_batch(columns.slice(0, len(events))) ``` +The wrapper also exposes `apply_events_until_fill(...)`, which is the first +low-level primitive for boundary-batched replay. It lets the C++ engine consume +a compiled market-data slice independently until either the slice ends or a +passive fill occurs. The method returns both the number of events consumed and +the fills produced at the boundary, so a future replay loop can resume Python at +the exact point where strategy-visible execution state changed. + +```python +events_consumed, fills = engine.apply_events_until_fill( + columns.slice(start, stop), +) +``` + +This primitive does not change ordinary audited `Replay(...)` yet. It exists so +the faster path can be wired in carefully without weakening replay +inspectability. + Install a source checkout normally to build the extension: ```bash diff --git a/src/ordersim/sim/cpp_matching_engine.py b/src/ordersim/sim/cpp_matching_engine.py index 18dae8d..cbbf390 100644 --- a/src/ordersim/sim/cpp_matching_engine.py +++ b/src/ordersim/sim/cpp_matching_engine.py @@ -53,6 +53,22 @@ def apply_events_batch( ) return [self._fill_from_row(row) for row in rows] + def apply_events_until_fill( + self, + events: CompiledEventSlice, + ) -> tuple[int, list[Fill]]: + """Apply a compiled slice until the first passive fill boundary.""" + + events_applied, rows = self._core.apply_events_until_fill( + events.ts_ns, + events.action, + events.side, + events.price_ticks, + events.size, + events.order_id, + ) + return int(events_applied), [self._fill_from_row(row) for row in rows] + def advance_time(self, ts_ns: int) -> None: self._core.advance_time(ts_ns) diff --git a/tests/test_cpp_execution_engine.py b/tests/test_cpp_execution_engine.py index 164b8a6..36b90ec 100644 --- a/tests/test_cpp_execution_engine.py +++ b/tests/test_cpp_execution_engine.py @@ -131,6 +131,87 @@ def test_cpp_execution_engine_applies_compiled_event_batches() -> None: ] +def test_cpp_execution_engine_stops_compiled_batch_at_passive_fill() -> None: + events = ( + MBOEvent( + ts_ns=1, + action="add", + side="bid", + price=Decimal("100.0"), + size=1, + order_id=1, + ), + MBOEvent( + ts_ns=2, + action="trade", + side="bid", + price=Decimal("100.0"), + size=2, + order_id=2, + ), + MBOEvent( + ts_ns=3, + action="add", + side="ask", + price=Decimal("101.0"), + size=1, + 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) + events_applied, fills = engine.apply_events_until_fill( + columns.slice(0, len(events)) + ) + + assert resting.order_id is not None + assert events_applied == 2 + assert [(fill.order_id, fill.ts_ns, fill.size) for fill in fills] == [ + (resting.order_id, 2, 1), + ] + assert engine.book_top() == (None, None) + + remaining_fills = engine.apply_events_batch( + columns.slice(events_applied, len(events)) + ) + + assert remaining_fills == [] + assert engine.book_top() == (None, Decimal("101.00")) + + +def test_cpp_execution_engine_reports_full_batch_without_passive_fill() -> 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, + ), + ) + columns = CompiledEventColumns.from_events(events, tick_size=Decimal("0.10")) + engine = CppMatchingEngine(tick_size=Decimal("0.10")) + + events_applied, fills = engine.apply_events_until_fill( + columns.slice(0, len(events)) + ) + + assert events_applied == len(events) + assert fills == [] + assert engine.book_top() == (Decimal("100.00"), Decimal("101.00")) + + def test_compiled_event_columns_reject_unaligned_prices() -> None: events = ( MBOEvent(