Skip to content

Commit a891765

Browse files
committed
add latency benchmark + update README with perf findings
Standalone bench script (no pytest): Numba path p50=200ns, full pipeline p50=649us. Fix stale README refs: drop --recursive clone, OrderBook-rs submodule mention.
1 parent a6a68c1 commit a891765

3 files changed

Lines changed: 295 additions & 17 deletions

File tree

README.md

Lines changed: 43 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,8 @@ Bybit public WS
6262
## Installation
6363

6464
```bash
65-
# 1. Clone with submodules
66-
git clone --recursive https://github.com/alihaskar/gap-mm
65+
# 1. Clone
66+
git clone https://github.com/alihaskar/gap-mm
6767
cd gap-mm
6868

6969
# 2. Install Python dependencies
@@ -159,6 +159,28 @@ Each callback receives a dict with:
159159

160160
---
161161

162+
## Performance
163+
164+
Measured on an AMD Ryzen 9 / Windows 11 machine against a localhost mock HTTP server
165+
(loopback RTT ≈ 50 µs). Run the benchmark yourself with:
166+
167+
```bash
168+
poetry run python tests/benchmarks/bench_latency.py
169+
```
170+
171+
| Segment | p50 | p99 | What it covers |
172+
|---|---|---|---|
173+
| Numba signal path | **200 ns** | 300 ns | `encode_signal` + `calculate_quotes_fast` |
174+
| Python tick dispatch | **300 ns** | 500 ns | dict unpack + Numba calls + price-change guard |
175+
| `reconcile()` roundtrip | **647 µs** | 901 µs | Python→Rust FFI + reqwest HTTP + JSON parse |
176+
| Full pipeline | **649 µs** | 901 µs | tick arrival → order on the wire |
177+
178+
**Key takeaway:** The signal math is ~300 ns — effectively free. The bottleneck is the REST
179+
round-trip (~650 µs to localhost, +1–5 ms for a co-located exchange). Moving to WebSocket
180+
order placement would collapse this to the ~300 ns range.
181+
182+
---
183+
162184
## Running tests
163185

164186
```bash
@@ -167,6 +189,9 @@ poetry run pytest tests/ -v
167189

168190
# Rust unit tests
169191
cd rust_engine && cargo test
192+
193+
# Latency benchmarks (prints report, no pass/fail)
194+
poetry run python tests/benchmarks/bench_latency.py
170195
```
171196

172197
---
@@ -177,34 +202,35 @@ cd rust_engine && cargo test
177202
gap-mm/
178203
├── src/
179204
│ └── gap_mm/
180-
│ ├── __init__.py # public API
181-
│ ├── engine.py # Numba JIT: signal encoding, quote calculation, P&L
182-
│ ├── live.py # LiveTradingEngine
183-
│ └── __main__.py # CLI entrypoint: python -m gap_mm
205+
│ ├── __init__.py # public API
206+
│ ├── engine.py # Numba JIT: signal encoding, quote calc, P&L
207+
│ ├── live.py # LiveTradingEngine
208+
│ └── __main__.py # CLI entrypoint: python -m gap_mm
184209
├── rust_engine/
185210
│ └── src/
186-
│ ├── bybit.rs # WS streaming, OrderBookState, gap analysis
187-
│ ├── execution.rs # OMS/EMS: order state, reconciliation, position
188-
│ ├── private_ws.rs # Private WS: fills
189-
│ ├── lib.rs # PyO3 bindings
190-
│ └── main.rs # Standalone binary (optional)
191-
├── OrderBook-rs/ # git submodule — lock-free L2 order book (MIT)
211+
│ ├── bybit.rs # WS streaming, OrderBookState, gap analysis
212+
│ ├── execution.rs # OMS/EMS: order state, reconciliation, position
213+
│ ├── private_ws.rs # Private WS: fills
214+
│ ├── lib.rs # PyO3 bindings
215+
│ └── main.rs # Standalone binary (optional)
192216
├── tests/
193-
│ ├── unit/ # Pure Python, no network
194-
│ └── integration/ # Requires rust_engine build; REST mocked
217+
│ ├── unit/ # Pure Python, no network
218+
│ ├── integration/ # Requires rust_engine build; REST mocked
219+
│ └── benchmarks/ # Latency benchmark script
195220
├── examples/
196-
│ └── minimal_stream.py # Stream only, no orders
221+
│ └── minimal_stream.py # Stream only, no orders
197222
├── .env.example
198223
├── pyproject.toml
199-
├── LICENSE # MIT
224+
├── LICENSE # MIT
200225
└── CONTRIBUTING.md
201226
```
202227

203228
---
204229

205230
## Attribution
206231

207-
The order-book engine is [OrderBook-rs](https://github.com/joaquinbejar/OrderBook-rs) by Joaquín Béjar García (MIT license), included as a git submodule.
232+
The order-book engine uses [orderbook-rs](https://github.com/joaquinbejar/OrderBook-rs)
233+
by Joaquín Béjar García (MIT license), pulled from crates.io.
208234

209235
---
210236

tests/benchmarks/__init__.py

Whitespace-only changes.

tests/benchmarks/bench_latency.py

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
"""
2+
gap-mm internal latency benchmark.
3+
4+
Run:
5+
poetry run python tests/benchmarks/bench_latency.py
6+
7+
Measures three segments of the tick-to-order pipeline:
8+
9+
[1] Numba signal path
10+
encode_signal() + calculate_quotes_fast()
11+
Pure JIT math, no I/O.
12+
13+
[2] Python tick dispatch (no I/O)
14+
Dict unpack + encode_signal + calculate_quotes_fast + price-change guard.
15+
16+
[3] reconcile() roundtrip (localhost mock HTTP)
17+
Python → Rust FFI → reqwest keep-alive → instant ThreadingHTTPServer
18+
→ JSON parse → Python dict.
19+
Lower bound for a co-located exchange.
20+
21+
[4] Full pipeline [1]+[2]+[3] under a single timestamp.
22+
"""
23+
24+
import json
25+
import socket
26+
import statistics
27+
import sys
28+
import threading
29+
import time
30+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
31+
32+
# ── import gap_mm ─────────────────────────────────────────────────────────────
33+
try:
34+
from gap_mm.engine import calculate_quotes_fast, encode_signal
35+
except ImportError:
36+
sys.exit("ERROR: gap_mm not found. Run from the project root with `poetry run python ...`")
37+
38+
try:
39+
from rust_engine import ExecutionNode
40+
except ImportError:
41+
sys.exit("ERROR: rust_engine not built. Run `poetry run maturin develop --manifest-path rust_engine/Cargo.toml`")
42+
43+
# ── constants ─────────────────────────────────────────────────────────────────
44+
MID = 90_000.0
45+
TICK = 0.10
46+
47+
N_WARMUP = 500
48+
N_BENCH_NUMBA = 5_000
49+
N_BENCH_HTTP = 500 # each call → up to 2 HTTP round-trips
50+
51+
SUBMIT_BODY = json.dumps({
52+
"retCode": 0, "retMsg": "OK",
53+
"result": {"orderId": "BENCH-001", "orderLinkId": "link-001"},
54+
}).encode()
55+
56+
57+
# ── mock HTTP server ──────────────────────────────────────────────────────────
58+
59+
class _Handler(BaseHTTPRequestHandler):
60+
def do_POST(self):
61+
# drain body
62+
length = int(self.headers.get("Content-Length", 0))
63+
if length:
64+
self.rfile.read(length)
65+
self.send_response(200)
66+
self.send_header("Content-Type", "application/json")
67+
self.send_header("Content-Length", str(len(SUBMIT_BODY)))
68+
self.send_header("Connection", "keep-alive")
69+
self.end_headers()
70+
self.wfile.write(SUBMIT_BODY)
71+
72+
def log_message(self, *_): # silence access log
73+
pass
74+
75+
76+
def _start_mock_server() -> tuple[ThreadingHTTPServer, str]:
77+
server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
78+
port = server.server_address[1]
79+
t = threading.Thread(target=server.serve_forever, daemon=True)
80+
t.start()
81+
return server, f"http://127.0.0.1:{port}"
82+
83+
84+
# ── stats / reporting ─────────────────────────────────────────────────────────
85+
86+
def _stats(samples_ns: list[int]) -> dict:
87+
s = sorted(samples_ns)
88+
n = len(s)
89+
return {
90+
"n": n,
91+
"min": s[0],
92+
"p50": s[n // 2],
93+
"p95": s[int(n * 0.95)],
94+
"p99": s[int(n * 0.99)],
95+
"max": s[-1],
96+
"mean": int(statistics.mean(s)),
97+
}
98+
99+
100+
def _report(name: str, st: dict) -> None:
101+
def fmt(ns: int) -> str:
102+
if ns < 1_000:
103+
return f"{ns} ns"
104+
if ns < 1_000_000:
105+
return f"{ns / 1_000:.2f} µs"
106+
return f"{ns / 1_000_000:.3f} ms"
107+
108+
print(f"\n{'─' * 58}")
109+
print(f" {name}")
110+
print(f" n={st['n']:,}")
111+
print(f"{'─' * 58}")
112+
print(f" min {fmt(st['min'])}")
113+
print(f" p50 {fmt(st['p50'])}")
114+
print(f" p95 {fmt(st['p95'])}")
115+
print(f" p99 {fmt(st['p99'])}")
116+
print(f" max {fmt(st['max'])}")
117+
print(f" mean {fmt(st['mean'])}")
118+
119+
120+
# ── warmup ────────────────────────────────────────────────────────────────────
121+
122+
def _warmup():
123+
print(f"Warming up Numba ({N_WARMUP} iters)...", end=" ", flush=True)
124+
for _ in range(N_WARMUP):
125+
sig, conf = encode_signal(0.82)
126+
calculate_quotes_fast(MID, sig, conf, TICK)
127+
print("done")
128+
129+
130+
# ── segment 1 ─────────────────────────────────────────────────────────────────
131+
132+
def bench_segment1():
133+
samples = []
134+
for i in range(N_BENCH_NUMBA):
135+
score = 0.82 if i % 2 == 0 else 0.18
136+
t0 = time.perf_counter_ns()
137+
sig, conf = encode_signal(score)
138+
calculate_quotes_fast(MID, sig, conf, TICK)
139+
samples.append(time.perf_counter_ns() - t0)
140+
_report("Segment 1 — encode_signal + calculate_quotes_fast (Numba JIT)", _stats(samples))
141+
142+
143+
# ── segment 2 ─────────────────────────────────────────────────────────────────
144+
145+
def bench_segment2():
146+
last_bid: float | None = None
147+
last_ask: float | None = None
148+
149+
def _should_update(b: float, a: float) -> bool:
150+
nonlocal last_bid, last_ask
151+
if last_bid is None:
152+
last_bid, last_ask = b, a
153+
return True
154+
half = TICK / 2.0
155+
changed = abs(b - last_bid) > half or abs(a - last_ask) > half
156+
if changed:
157+
last_bid, last_ask = b, a
158+
return changed
159+
160+
tick_a = {"mid_price": 90_000.0, "gap_prob_resistance_up": 0.82}
161+
tick_b = {"mid_price": 89_999.0, "gap_prob_resistance_up": 0.18}
162+
163+
samples = []
164+
for i in range(N_BENCH_NUMBA):
165+
data = tick_a if i % 2 == 0 else tick_b
166+
t0 = time.perf_counter_ns()
167+
sig, conf = encode_signal(data["gap_prob_resistance_up"])
168+
mm_bid, mm_ask, *_ = calculate_quotes_fast(data["mid_price"], sig, conf, TICK)
169+
_should_update(mm_bid, mm_ask)
170+
samples.append(time.perf_counter_ns() - t0)
171+
172+
_report("Segment 2 — Python tick dispatch (no HTTP)", _stats(samples))
173+
174+
175+
# ── segment 3 ─────────────────────────────────────────────────────────────────
176+
177+
def bench_segment3(base_url: str):
178+
node = ExecutionNode(
179+
api_key="bench-key",
180+
api_secret="bench-secret",
181+
symbol="BTCUSDT",
182+
market_type="spot",
183+
tick_size=TICK,
184+
max_position=1_000.0,
185+
min_order_size=0.001,
186+
api_base_url=base_url,
187+
)
188+
189+
# warm up the HTTP connection pool (first call opens the socket)
190+
node.reconcile(target_bid=89_000.0, target_ask=89_010.0)
191+
192+
samples = []
193+
for i in range(N_BENCH_HTTP):
194+
# Cycle through 50 distinct price levels → always submit or amend
195+
bid = 89_000.0 + (i % 50) * TICK
196+
ask = 89_010.0 + (i % 50) * TICK
197+
t0 = time.perf_counter_ns()
198+
node.reconcile(target_bid=bid, target_ask=ask)
199+
samples.append(time.perf_counter_ns() - t0)
200+
201+
_report("Segment 3 — reconcile() roundtrip (localhost mock HTTP)", _stats(samples))
202+
return node
203+
204+
205+
# ── full pipeline ─────────────────────────────────────────────────────────────
206+
207+
def bench_full_pipeline(base_url: str):
208+
node = ExecutionNode(
209+
api_key="bench-key",
210+
api_secret="bench-secret",
211+
symbol="BTCUSDT",
212+
market_type="spot",
213+
tick_size=TICK,
214+
max_position=1_000.0,
215+
min_order_size=0.001,
216+
api_base_url=base_url,
217+
)
218+
node.reconcile(target_bid=89_000.0, target_ask=89_010.0) # warm up
219+
220+
samples = []
221+
for i in range(N_BENCH_HTTP):
222+
mid = MID + (i % 100) * TICK
223+
score = 0.82 if i % 2 == 0 else 0.18
224+
t0 = time.perf_counter_ns()
225+
sig, conf = encode_signal(score)
226+
mm_bid, mm_ask, *_ = calculate_quotes_fast(mid, sig, conf, TICK)
227+
node.reconcile(target_bid=mm_bid, target_ask=mm_ask)
228+
samples.append(time.perf_counter_ns() - t0)
229+
230+
_report("Full pipeline — tick dict → reconcile() returns", _stats(samples))
231+
232+
233+
# ── main ──────────────────────────────────────────────────────────────────────
234+
235+
if __name__ == "__main__":
236+
print("=" * 58)
237+
print(" gap-mm latency benchmark")
238+
print("=" * 58)
239+
240+
_warmup()
241+
bench_segment1()
242+
bench_segment2()
243+
244+
print(f"\nStarting localhost mock HTTP server...", end=" ", flush=True)
245+
server, base_url = _start_mock_server()
246+
print(f"listening on {base_url}")
247+
248+
bench_segment3(base_url)
249+
bench_full_pipeline(base_url)
250+
251+
server.shutdown()
252+
print("\nDone.")

0 commit comments

Comments
 (0)