Skip to content

Commit 1cd7e3a

Browse files
Add boundary advance helper (#49)
1 parent 9dcc467 commit 1cd7e3a

6 files changed

Lines changed: 252 additions & 2 deletions

File tree

docs/benchmarks.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,5 +93,9 @@ The first engine primitive for that design is
9393
until the first passive fill boundary and returns the event count needed to
9494
resume replay at the correct row.
9595

96+
The corresponding Python helper is `advance_until_fill_boundary(...)`. It keeps
97+
the boundary contract testable on the scalar reference engine and the compiled
98+
engine, which is the step before changing the ordinary replay loop.
99+
96100
CI should keep benchmark code runnable; it should not enforce fixed speed
97101
thresholds across hardware.

docs/execution-engines.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,12 @@ This primitive does not change ordinary audited `Replay(...)` yet. It exists so
116116
the faster path can be wired in carefully without weakening replay
117117
inspectability.
118118

119+
`advance_until_fill_boundary(...)` is the Python-side helper that wraps this
120+
idea. It uses the compiled boundary method when the engine and columns support
121+
it, and otherwise falls back to scalar `apply_event(...)` calls. That lets tests
122+
prove the same boundary behavior through the readable Python path and the C++
123+
path before replay integration depends on it.
124+
119125
Install a source checkout normally to build the extension:
120126

121127
```bash

src/ordersim/__init__.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,14 @@
3131
default_latency_model_factory,
3232
)
3333
from ordersim.recording import RecordingGateway
34-
from ordersim.replay import CompiledEventColumns, Replay, ReplayGateway, ReplayResult
34+
from ordersim.replay import (
35+
BoundaryAdvance,
36+
CompiledEventColumns,
37+
Replay,
38+
ReplayGateway,
39+
ReplayResult,
40+
advance_until_fill_boundary,
41+
)
3542
from ordersim.sim import (
3643
CppMatchingEngine,
3744
ExecutionEngine,
@@ -59,6 +66,7 @@
5966

6067
__all__ = [
6168
"BookSide",
69+
"BoundaryAdvance",
6270
"CompiledEventColumns",
6371
"ConstantLatency",
6472
"CppMatchingEngine",
@@ -99,6 +107,7 @@
99107
"Side",
100108
"TimeInForce",
101109
"ValuationMark",
110+
"advance_until_fill_boundary",
102111
"build_equity_curve",
103112
"cpp_execution_engine_available",
104113
"default_execution_engine_factory",

src/ordersim/replay/__init__.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
"""Replay orchestration for strategy functions."""
22

3+
from ordersim.replay.boundary import BoundaryAdvance, advance_until_fill_boundary
34
from ordersim.replay.compiled_events import CompiledEventColumns
45
from ordersim.replay.simulator import Replay, ReplayGateway, ReplayResult
56

6-
__all__ = ["CompiledEventColumns", "Replay", "ReplayGateway", "ReplayResult"]
7+
__all__ = [
8+
"BoundaryAdvance",
9+
"CompiledEventColumns",
10+
"Replay",
11+
"ReplayGateway",
12+
"ReplayResult",
13+
"advance_until_fill_boundary",
14+
]

src/ordersim/replay/boundary.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Helpers for advancing replay state to execution boundaries."""
2+
3+
from collections.abc import Sequence
4+
from dataclasses import dataclass
5+
6+
from ordersim.replay.compiled_events import CompiledEventColumns
7+
from ordersim.sim import ExecutionEngine
8+
from ordersim.types import Fill, MBOEvent
9+
10+
11+
@dataclass(frozen=True, slots=True)
12+
class BoundaryAdvance:
13+
"""Result from advancing until the first passive fill or slice end."""
14+
15+
events_consumed: int
16+
fills: tuple[Fill, ...]
17+
18+
@property
19+
def stopped_on_fill(self) -> bool:
20+
"""Whether advancement stopped because a passive fill appeared."""
21+
22+
return bool(self.fills)
23+
24+
25+
def advance_until_fill_boundary(
26+
engine: ExecutionEngine,
27+
events: Sequence[MBOEvent],
28+
*,
29+
start: int = 0,
30+
stop: int | None = None,
31+
compiled_events: CompiledEventColumns | None = None,
32+
) -> BoundaryAdvance:
33+
"""Advance an engine until a passive fill appears or the slice ends.
34+
35+
This is the small bridge between scalar replay and compiled replay. If the
36+
engine exposes a compiled boundary method and compiled columns are provided,
37+
the engine advances through the slice internally. Otherwise this helper
38+
falls back to the readable scalar path.
39+
"""
40+
41+
end = len(events) if stop is None else stop
42+
_validate_slice(start=start, stop=end, length=len(events))
43+
if start == end:
44+
return BoundaryAdvance(events_consumed=0, fills=())
45+
46+
apply_compiled = getattr(engine, "apply_events_until_fill", None)
47+
if compiled_events is not None and apply_compiled is not None:
48+
events_consumed, fills = apply_compiled(compiled_events.slice(start, end))
49+
return BoundaryAdvance(
50+
events_consumed=events_consumed,
51+
fills=tuple(fills),
52+
)
53+
54+
fills: list[Fill] = []
55+
for offset, event in enumerate(events[start:end], start=1):
56+
event_fills = engine.apply_event(event)
57+
if event_fills:
58+
fills.extend(event_fills)
59+
return BoundaryAdvance(events_consumed=offset, fills=tuple(fills))
60+
61+
return BoundaryAdvance(events_consumed=end - start, fills=())
62+
63+
64+
def _validate_slice(*, start: int, stop: int, length: int) -> None:
65+
if start < 0:
66+
raise ValueError("start must be non-negative")
67+
if stop < start:
68+
raise ValueError("stop must be greater than or equal to start")
69+
if stop > length:
70+
raise ValueError("stop cannot exceed the number of events")

tests/test_boundary_advance.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
from decimal import Decimal
2+
3+
import pytest
4+
5+
from ordersim import (
6+
CompiledEventColumns,
7+
CppMatchingEngine,
8+
MatchingEngine,
9+
MBOEvent,
10+
advance_until_fill_boundary,
11+
cpp_execution_engine_available,
12+
)
13+
14+
15+
def boundary_events() -> tuple[MBOEvent, ...]:
16+
return (
17+
MBOEvent(
18+
ts_ns=1,
19+
action="add",
20+
side="bid",
21+
price=Decimal("100.0"),
22+
size=1,
23+
order_id=1,
24+
),
25+
MBOEvent(
26+
ts_ns=2,
27+
action="trade",
28+
side="bid",
29+
price=Decimal("100.0"),
30+
size=2,
31+
order_id=2,
32+
),
33+
MBOEvent(
34+
ts_ns=3,
35+
action="add",
36+
side="ask",
37+
price=Decimal("101.0"),
38+
size=1,
39+
order_id=3,
40+
),
41+
)
42+
43+
44+
def test_boundary_advance_scalar_path_stops_on_first_passive_fill() -> None:
45+
engine = MatchingEngine()
46+
resting = engine.place_limit(side="buy", price=Decimal("100.0"), size=1)
47+
48+
advance = advance_until_fill_boundary(engine, boundary_events())
49+
50+
assert resting.order_id is not None
51+
assert advance.events_consumed == 2
52+
assert advance.stopped_on_fill is True
53+
assert [(fill.order_id, fill.ts_ns, fill.size) for fill in advance.fills] == [
54+
(resting.order_id, 2, 1),
55+
]
56+
assert engine.book_top() == (None, None)
57+
58+
59+
@pytest.mark.skipif(
60+
not cpp_execution_engine_available(),
61+
reason="optional C++ execution engine is not built",
62+
)
63+
def test_boundary_advance_compiled_path_matches_scalar_boundary() -> None:
64+
events = boundary_events()
65+
columns = CompiledEventColumns.from_events(events, tick_size=Decimal("0.10"))
66+
scalar_engine = MatchingEngine()
67+
compiled_engine = CppMatchingEngine(tick_size=Decimal("0.10"))
68+
69+
scalar_order = scalar_engine.place_limit(
70+
side="buy",
71+
price=Decimal("100.0"),
72+
size=1,
73+
)
74+
compiled_order = compiled_engine.place_limit(
75+
side="buy",
76+
price=Decimal("100.0"),
77+
size=1,
78+
)
79+
80+
scalar_advance = advance_until_fill_boundary(scalar_engine, events)
81+
compiled_advance = advance_until_fill_boundary(
82+
compiled_engine,
83+
events,
84+
compiled_events=columns,
85+
)
86+
87+
assert scalar_order.order_id is not None
88+
assert compiled_order.order_id is not None
89+
assert scalar_advance.events_consumed == compiled_advance.events_consumed
90+
assert scalar_advance.stopped_on_fill is True
91+
assert compiled_advance.stopped_on_fill is True
92+
assert [
93+
(fill.side, fill.price, fill.size, fill.ts_ns)
94+
for fill in scalar_advance.fills
95+
] == [
96+
(fill.side, fill.price, fill.size, fill.ts_ns)
97+
for fill in compiled_advance.fills
98+
]
99+
assert scalar_engine.book_top() == compiled_engine.book_top()
100+
101+
102+
def test_boundary_advance_reports_slice_end_without_fill() -> None:
103+
events = (
104+
MBOEvent(
105+
ts_ns=1,
106+
action="add",
107+
side="bid",
108+
price=Decimal("100.0"),
109+
size=1,
110+
order_id=1,
111+
),
112+
MBOEvent(
113+
ts_ns=2,
114+
action="add",
115+
side="ask",
116+
price=Decimal("101.0"),
117+
size=1,
118+
order_id=2,
119+
),
120+
)
121+
engine = MatchingEngine()
122+
123+
advance = advance_until_fill_boundary(engine, events, start=0, stop=2)
124+
125+
assert advance.events_consumed == 2
126+
assert advance.stopped_on_fill is False
127+
assert advance.fills == ()
128+
assert engine.book_top() == (Decimal("100.0"), Decimal("101.0"))
129+
130+
131+
def test_boundary_advance_accepts_empty_slice() -> None:
132+
events = boundary_events()
133+
engine = MatchingEngine()
134+
135+
advance = advance_until_fill_boundary(engine, events, start=1, stop=1)
136+
137+
assert advance.events_consumed == 0
138+
assert advance.stopped_on_fill is False
139+
assert advance.fills == ()
140+
141+
142+
def test_boundary_advance_validates_slice_bounds() -> None:
143+
events = boundary_events()
144+
engine = MatchingEngine()
145+
146+
with pytest.raises(ValueError, match="start"):
147+
advance_until_fill_boundary(engine, events, start=-1)
148+
149+
with pytest.raises(ValueError, match="greater than or equal"):
150+
advance_until_fill_boundary(engine, events, start=2, stop=1)
151+
152+
with pytest.raises(ValueError, match="number of events"):
153+
advance_until_fill_boundary(engine, events, stop=len(events) + 1)

0 commit comments

Comments
 (0)